diff --git a/README.md b/README.md index 6a02c017..d6d9c52a 100644 --- a/README.md +++ b/README.md @@ -122,10 +122,10 @@ After all these steps have been completed, you should be able to authenticate wi To connect a debugger to the `vc-authn` controller service, start the project using `DEBUGGER=true ./manage start` and then launch the debugger, it should connect automatically to the container. -This is a sample debugger launch configuration for VSCode that can be used by adding it to `launch.json`: +This is a sample debugger launch configuration for VSCode that can be used by adding it to `launch.json`, it assumes a `.venv` folder containing the virtual environment was created in the repository root: ```json { - "version": "0.1.0", + "version": "0.1.1", "configurations": [ { "name": "Python: Debug VC-AuthN Controller", @@ -137,8 +137,13 @@ This is a sample debugger launch configuration for VSCode that can be used by ad { "localRoot": "${workspaceFolder}/oidc-controller", "remoteRoot": "/app" + }, + { + "localRoot": "${workspaceFolder}/.venv/Lib/site-packages", + "remoteRoot": "/usr/local/lib/python3.11/site-packages" } - ] + ], + "justMyCode": false } ] } diff --git a/oidc-controller/api/core/models.py b/oidc-controller/api/core/models.py index b5b1d606..62055385 100644 --- a/oidc-controller/api/core/models.py +++ b/oidc-controller/api/core/models.py @@ -2,6 +2,7 @@ from bson import ObjectId from pydantic import BaseModel, Field +from pyop.userinfo import Userinfo class PyObjectId(ObjectId): @@ -50,3 +51,24 @@ class GenericErrorMessage(BaseModel): class RevealedAttribute(BaseModel): sub_proof_index: int values: dict + + +class VCUserinfo(Userinfo): + """ + User database for VC-based Identity provider: since no users are + known ahead of time, a new user is created with + every authentication request. + """ + + def __getitem__(self, item): + """ + There is no user info database, we always return an empty dictionary + """ + return {} + + def get_claims_for(self, user_id, requested_claims, userinfo=None): + # type: (str, Mapping[str, Optional[Mapping[str, Union[str, List[str]]]]) -> Dict[str, Union[str, List[str]]] + """ + There is no user info database, we always return an empty dictionary + """ + return {} diff --git a/oidc-controller/api/core/oidc/issue_token_service.py b/oidc-controller/api/core/oidc/issue_token_service.py index e7de5eb0..5983e09f 100644 --- a/oidc-controller/api/core/oidc/issue_token_service.py +++ b/oidc-controller/api/core/oidc/issue_token_service.py @@ -1,6 +1,5 @@ import dataclasses import json -import uuid from datetime import datetime from typing import Any, Dict, List @@ -86,22 +85,13 @@ def get_claims( ) raise RuntimeError(err) - # look at all presentation_claims and one should - # match the configured subject_identifier - sub_id_value = None + # look at all presentation_claims for one + # matching the configured subject_identifier, if any sub_id_claim = presentation_claims.get(ver_config.subject_identifier) - if not sub_id_claim: - logger.warning( - """subject_identifer not found in presentation values, - generating random subject_identifier""" - ) - sub_id_value = str(uuid.uuid4()) - else: - sub_id_value = sub_id_claim.value - - # add sub and append presentation_claims - oidc_claims.append(Claim(type="sub", value=sub_id_value)) + if sub_id_claim: + # add sub and append presentation_claims + oidc_claims.append(Claim(type="sub", value=sub_id_claim.value)) result = {c.type: c.value for c in oidc_claims} result[PROOF_CLAIMS_ATTRIBUTE_NAME] = json.dumps( diff --git a/oidc-controller/api/core/oidc/provider.py b/oidc-controller/api/core/oidc/provider.py index f0e4bb41..b50c1154 100644 --- a/oidc-controller/api/core/oidc/provider.py +++ b/oidc-controller/api/core/oidc/provider.py @@ -6,6 +6,7 @@ import structlog.typing from api.clientConfigurations.models import TOKENENDPOINTAUTHMETHODS from api.core.config import settings +from api.core.models import VCUserinfo from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa @@ -15,7 +16,6 @@ from pyop.provider import Provider from pyop.storage import StatelessWrapper from pyop.subject_identifier import HashBasedSubjectIdentifierFactory -from pyop.userinfo import Userinfo logger: structlog.typing.FilteringBoundLogger = structlog.get_logger() DIR_PATH = os.path.dirname(os.path.realpath(__file__)) @@ -128,9 +128,6 @@ async def init_provider(db: Database): all_client_configs = await ClientConfigurationCRUD(db).get_all() client_db = {d.client_name: d.dict() for d in all_client_configs} - user_db = { - "vc-user": {"sub": None} - } # placeholder, this will be replaced by the subject defined in the proof-configuration provider = Provider( signing_key, @@ -142,5 +139,5 @@ async def init_provider(db: Database): refresh_token_db=stateless_storage, ), client_db, - Userinfo(user_db), + VCUserinfo({}), ) diff --git a/oidc-controller/api/core/oidc/tests/test_issue_token_service.py b/oidc-controller/api/core/oidc/tests/test_issue_token_service.py index c97bd6aa..ca9c9444 100644 --- a/oidc-controller/api/core/oidc/tests/test_issue_token_service.py +++ b/oidc-controller/api/core/oidc/tests/test_issue_token_service.py @@ -3,7 +3,6 @@ from api.authSessions.models import AuthSession from api.core.oidc.issue_token_service import Token from api.core.oidc.tests.__mocks__ import auth_session, presentation, ver_config -from api.test_utils import is_valid_uuid basic_valid_requested_attributes = { "req_attr_0": { @@ -25,7 +24,7 @@ "raw": "test@email.com", "encoded": "73814602767252868561268261832462872577293109184327908660400248444458427915643", } - } + }, } } @@ -52,16 +51,20 @@ "age_1": { "raw": "30", "encoded": "73814602767252868561268261832462872577293109184327908660400248444458427915644", - } - } + }, + }, } } @pytest.mark.asyncio async def test_valid_proof_presentation_with_one_attribute_returns_claims(): - presentation['presentation_request']['requested_attributes'] = basic_valid_requested_attributes - presentation['presentation']['requested_proof']['revealed_attr_groups'] = basic_valid_revealed_attr_groups + presentation["presentation_request"][ + "requested_attributes" + ] = basic_valid_requested_attributes + presentation["presentation"]["requested_proof"][ + "revealed_attr_groups" + ] = basic_valid_revealed_attr_groups with mock.patch.object(AuthSession, "presentation_exchange", presentation): claims = Token.get_claims(auth_session, ver_config) assert claims is not None @@ -69,7 +72,7 @@ async def test_valid_proof_presentation_with_one_attribute_returns_claims(): @pytest.mark.asyncio async def test_valid_proof_presentation_with_multiple_attributes_returns_claims(): - presentation['presentation_request']['requested_attributes'] = { + presentation["presentation_request"]["requested_attributes"] = { "req_attr_0": { "names": ["email"], "restrictions": [ @@ -87,9 +90,9 @@ async def test_valid_proof_presentation_with_multiple_attributes_returns_claims( "issuer_did": "MTYqmTBoLT7KLP5RNfgK3c", } ], - } + }, } - presentation['presentation']['requested_proof']['revealed_attr_groups'] = { + presentation["presentation"]["requested_proof"]["revealed_attr_groups"] = { "req_attr_0": { "sub_proof_index": 0, "values": { @@ -97,7 +100,7 @@ async def test_valid_proof_presentation_with_multiple_attributes_returns_claims( "raw": "test@email.com", "encoded": "73814602767252868561268261832462872577293109184327908660400248444458427915643", } - } + }, }, "req_attr_1": { "sub_proof_index": 0, @@ -106,8 +109,8 @@ async def test_valid_proof_presentation_with_multiple_attributes_returns_claims( "raw": "30", "encoded": "73814602767252868561268261832462872577293109184327908660400248444458427915644", } - } - } + }, + }, } with mock.patch.object(AuthSession, "presentation_exchange", presentation): claims = Token.get_claims(auth_session, ver_config) @@ -116,52 +119,65 @@ async def test_valid_proof_presentation_with_multiple_attributes_returns_claims( @pytest.mark.asyncio async def test_include_v1_attributes_false_does_not_add_the_named_attributes(): - presentation['presentation_request']['requested_attributes'] = multiple_valid_requested_attributes - presentation['presentation']['requested_proof']['revealed_attr_groups'] = multiple_valid_revealed_attr_groups + presentation["presentation_request"][ + "requested_attributes" + ] = multiple_valid_requested_attributes + presentation["presentation"]["requested_proof"][ + "revealed_attr_groups" + ] = multiple_valid_revealed_attr_groups with mock.patch.object(AuthSession, "presentation_exchange", presentation): ver_config.include_v1_attributes = False claims = Token.get_claims(auth_session, ver_config) vc_presented_attributes_obj = eval(claims["vc_presented_attributes"]) assert claims is not None - assert vc_presented_attributes_obj["email_1"] == 'test@email.com' - assert vc_presented_attributes_obj["age_1"] == '30' + assert vc_presented_attributes_obj["email_1"] == "test@email.com" + assert vc_presented_attributes_obj["age_1"] == "30" assert "email_1" not in claims assert "age_1" not in claims @pytest.mark.asyncio async def test_include_v1_attributes_true_adds_the_named_attributes(): - presentation['presentation_request']['requested_attributes'] = multiple_valid_requested_attributes - presentation['presentation']['requested_proof']['revealed_attr_groups'] = multiple_valid_revealed_attr_groups + presentation["presentation_request"][ + "requested_attributes" + ] = multiple_valid_requested_attributes + presentation["presentation"]["requested_proof"][ + "revealed_attr_groups" + ] = multiple_valid_revealed_attr_groups with mock.patch.object(AuthSession, "presentation_exchange", presentation): ver_config.include_v1_attributes = True claims = Token.get_claims(auth_session, ver_config) vc_presented_attributes_obj = eval(claims["vc_presented_attributes"]) assert claims is not None - assert vc_presented_attributes_obj["email_1"] == 'test@email.com' - assert vc_presented_attributes_obj["age_1"] == '30' - assert claims["email_1"] == 'test@email.com' - assert claims["age_1"] == '30' + assert vc_presented_attributes_obj["email_1"] == "test@email.com" + assert vc_presented_attributes_obj["age_1"] == "30" + assert claims["email_1"] == "test@email.com" + assert claims["age_1"] == "30" + @pytest.mark.asyncio async def test_include_v1_attributes_none_does_not_add_the_named_attributes(): - presentation['presentation_request']['requested_attributes'] = multiple_valid_requested_attributes - presentation['presentation']['requested_proof']['revealed_attr_groups'] = multiple_valid_revealed_attr_groups + presentation["presentation_request"][ + "requested_attributes" + ] = multiple_valid_requested_attributes + presentation["presentation"]["requested_proof"][ + "revealed_attr_groups" + ] = multiple_valid_revealed_attr_groups with mock.patch.object(AuthSession, "presentation_exchange", presentation): ver_config.include_v1_attributes = None print(ver_config.include_v1_attributes) claims = Token.get_claims(auth_session, ver_config) vc_presented_attributes_obj = eval(claims["vc_presented_attributes"]) assert claims is not None - assert vc_presented_attributes_obj["email_1"] == 'test@email.com' - assert vc_presented_attributes_obj["age_1"] == '30' + assert vc_presented_attributes_obj["email_1"] == "test@email.com" + assert vc_presented_attributes_obj["age_1"] == "30" assert "email_1" not in claims assert "age_1" not in claims @pytest.mark.asyncio async def test_revealed_attrs_dont_match_requested_attributes_throws_exception(): - presentation['presentation_request']['requested_attributes'] = { + presentation["presentation_request"]["requested_attributes"] = { "req_attr_0": { "names": ["email"], "restrictions": [ @@ -172,7 +188,7 @@ async def test_revealed_attrs_dont_match_requested_attributes_throws_exception() ], } } - presentation['presentation']['requested_proof']['revealed_attr_groups'] = { + presentation["presentation"]["requested_proof"]["revealed_attr_groups"] = { "req_attr_0": { "sub_proof_index": 0, "values": { @@ -180,7 +196,7 @@ async def test_revealed_attrs_dont_match_requested_attributes_throws_exception() "raw": "test@email.com", "encoded": "73814602767252868561268261832462872577293109184327908660400248444458427915643", } - } + }, } } with mock.patch.object(AuthSession, "presentation_exchange", presentation): @@ -190,8 +206,12 @@ async def test_revealed_attrs_dont_match_requested_attributes_throws_exception() @pytest.mark.asyncio async def test_valid_presentation_with_matching_subject_identifier_has_identifier_in_claims_sub(): - presentation['presentation_request']['requested_attributes'] = basic_valid_requested_attributes - presentation['presentation']['requested_proof']['revealed_attr_groups'] = basic_valid_revealed_attr_groups + presentation["presentation_request"][ + "requested_attributes" + ] = basic_valid_requested_attributes + presentation["presentation"]["requested_proof"][ + "revealed_attr_groups" + ] = basic_valid_revealed_attr_groups with mock.patch.object(AuthSession, "presentation_exchange", presentation): claims = Token.get_claims(auth_session, ver_config) print(claims) @@ -199,10 +219,14 @@ async def test_valid_presentation_with_matching_subject_identifier_has_identifie @pytest.mark.asyncio -async def test_valid_presentation_with_non_matching_subject_identifier_and_has_uuid_in_claims_sub(): - presentation['presentation_request']['requested_attributes'] = basic_valid_requested_attributes - presentation['presentation']['requested_proof']['revealed_attr_groups'] = basic_valid_revealed_attr_groups +async def test_valid_presentation_with_non_matching_subject_identifier_and_has_no_sub(): + presentation["presentation_request"][ + "requested_attributes" + ] = basic_valid_requested_attributes + presentation["presentation"]["requested_proof"][ + "revealed_attr_groups" + ] = basic_valid_revealed_attr_groups with mock.patch.object(AuthSession, "presentation_exchange", presentation): ver_config.subject_identifier = "not-email" claims = Token.get_claims(auth_session, ver_config) - assert is_valid_uuid(claims["sub"]) is True + assert "sub" not in claims diff --git a/oidc-controller/api/routers/oidc.py b/oidc-controller/api/routers/oidc.py index 12e7cca4..22034c6e 100644 --- a/oidc-controller/api/routers/oidc.py +++ b/oidc-controller/api/routers/oidc.py @@ -1,5 +1,6 @@ import base64 import io +import uuid from datetime import datetime from urllib.parse import urlencode @@ -9,7 +10,7 @@ from fastapi import status as http_status from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from jinja2 import Template -from oic.oic.message import AccessTokenRequest, AuthorizationRequest +from oic.oic.message import AuthorizationRequest from pymongo.database import Database from pyop.exceptions import InvalidAuthenticationRequest @@ -92,8 +93,9 @@ async def get_authorize(request: Request, db: Database = Depends(get_db)): detail=f"Invalid auth request: {e}", ) - # fetch placeholder user/model and create proof - authn_response = provider.provider.authorize(model, "vc-user") + # create proof for this request + new_user_id = str(uuid.uuid4()) + authn_response = provider.provider.authorize(model, new_user_id) # retrieve presentation_request config. client = AcapyClient() @@ -160,21 +162,31 @@ async def get_authorize_callback(pid: str, db: Database = Depends(get_db)): @router.post(VerifiedCredentialTokenUri, response_class=JSONResponse) async def post_token(request: Request, db: Database = Depends(get_db)): """Called by oidc platform to retrieve token contents""" - form = await request.form() - model = AccessTokenRequest().from_dict(form._dict) - - auth_session = await AuthSessionCRUD(db).get_by_pyop_auth_code(model.get("code")) - ver_config = await VerificationConfigCRUD(db).get(auth_session.ver_config_id) - claims = Token.get_claims(auth_session, ver_config) - - # modify subject identifier value to use vc-attribute as configured - new_sub = claims.pop("sub") - provider.provider.authz_state.subject_identifiers["vc-user"]["public"] = new_sub - - # convert form data to what library expects, Flask.app.request.get_data() - data = urlencode(form._dict) - token_response = provider.provider.handle_token_request( - data, request.headers, claims - ) - logger.debug(f"Token response: {token_response.to_dict()}") - return token_response.to_dict() + async with request.form() as form: + form_dict = form._dict + auth_session = await AuthSessionCRUD(db).get_by_pyop_auth_code( + form_dict["code"] + ) + ver_config = await VerificationConfigCRUD(db).get(auth_session.ver_config_id) + claims = Token.get_claims(auth_session, ver_config) + + # Replace auto-generated sub with one coming from proof, if available + # The stateless storage uses a cypher, so a new item can be added and + # the reference in the form needs to be updated with the new key value + if claims.get("sub"): + authz_info = provider.provider.authz_state.authorization_codes[ + form_dict["code"] + ] + authz_info["sub"] = claims.pop("sub") + new_code = provider.provider.authz_state.authorization_codes.pack( + authz_info + ) + form_dict["code"] = new_code + + # convert form data to what library expects, Flask.app.request.get_data() + data = urlencode(form_dict) + token_response = provider.provider.handle_token_request( + data, request.headers, claims + ) + logger.debug(f"Token response: {token_response.to_dict()}") + return token_response.to_dict() diff --git a/oidc-controller/api/test_utils.py b/oidc-controller/api/test_utils.py deleted file mode 100644 index 2a8176f4..00000000 --- a/oidc-controller/api/test_utils.py +++ /dev/null @@ -1,8 +0,0 @@ -import uuid - -def is_valid_uuid(value: str): - try: - uuid.UUID(str(value)) - return True - except ValueError: - return False \ No newline at end of file