diff --git a/README.md b/README.md index 6346dce8..3adfc68a 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ curl -X 'POST' \ -d '{ "ver_config_id": "verified-email", "subject_identifier": "email", + "generate_consistent_identifier": true, "proof_request": { "name": "BCGov Verified Email", "version": "1.0", diff --git a/docs/README.md b/docs/README.md index 302459c1..aef7b0e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -138,6 +138,7 @@ A verifiable credential presentation request configuration, takes the following { "id": "", "subject_identifier": "", + "generate_consistent_identifier": , "proof_request": { "name": "Basic Proof", "version": "1.0", @@ -156,6 +157,7 @@ This data model is inspired by that is defined and used in the [Hyperledger Indy - `id` : The identifier for the presentation configuration. - `subject_identifier` : See [here](#subject-identifer-mapping) for further details on the purpose of this field. +- `generate_consistent_identifier` : Optional field defaulting to false. See [here](#subject-identifer-mapping) for more details. - `proof_request` : Contains the details on the presentation request, e.g which attributes are to be disclosed - `name` : The name that will accompany the presentation request - `version` : The version of the presentation request @@ -308,7 +310,7 @@ To quote from the OpenID Connect specification on [ID tokens](https://openid.net `sub : REQUIRED. Subject Identifier. A locally unique and never reassigned identifier within the Issuer for the End-User, which is intended to be consumed by the Client, e.g., 24400320 or AItOawmwtWwcT0k51BayewNvutrJUqsvl6qs7A4. It MUST NOT exceed 255 ASCII characters in length. The sub value is a case sensitive string` -When an OP is performing VC-AuthN, and the request has reached the point where the VC presentation has been generated and sent by the IW to the OP, the OP must now map contents of this VC presentation to an OpenID ID token. The question is then raised on what should populate this field. The two options available are: +When an OP is performing VC-AuthN, and the request has reached the point where the VC presentation has been generated and sent by the IW to the OP, the OP must now map contents of this VC presentation to an OpenID ID token. The question is then raised on what should populate this field. The three options available are: 1. Nominate a disclosed attribute in the verifiable credential presentation that is used to populate the subject field. 2. Ephemeral generate an identifier for this field e.g a randomly generated GUID. @@ -317,6 +319,7 @@ When an OP is performing VC-AuthN, and the request has reached the point where t **Note:** - In option 2. this prevents the often desirable property of cross session correlation of an authenticated user, which will effect the ability for many integrating IAM solutions being able to conduct effective auditing. - In option 3. this method should be assessed and used with caution, as the chance of collisions for users holding credentials with exact same values is possible (e.g.: a proof-request using only `first_name` and `last_name`, would generate the same identifier for people with same first and last name). +- In order to enable option 3 the _presentation request configuration_ must have `generate_consistent_identifier` #### UserInfo Endpoint diff --git a/oidc-controller/api/core/models.py b/oidc-controller/api/core/models.py index 62055385..519d2456 100644 --- a/oidc-controller/api/core/models.py +++ b/oidc-controller/api/core/models.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import TypedDict from bson import ObjectId from pydantic import BaseModel, Field @@ -48,7 +49,10 @@ class GenericErrorMessage(BaseModel): detail: str -class RevealedAttribute(BaseModel): +# Currently used as a TypedDict since it can be used as a part of a +# Pydantic class but a Pydantic class can not inherit from TypedDict +# and and BaseModel +class RevealedAttribute(TypedDict, total=False): sub_proof_index: int values: dict diff --git a/oidc-controller/api/core/oidc/issue_token_service.py b/oidc-controller/api/core/oidc/issue_token_service.py index 5983e09f..cb912359 100644 --- a/oidc-controller/api/core/oidc/issue_token_service.py +++ b/oidc-controller/api/core/oidc/issue_token_service.py @@ -1,3 +1,4 @@ +import canonicaljson import dataclasses import json from datetime import datetime @@ -9,7 +10,7 @@ from ...authSessions.models import AuthSession from ...verificationConfigs.models import ReqAttr, VerificationConfig -from ..models import RevealedAttribute +from ...core.models import RevealedAttribute logger = structlog.getLogger(__name__) @@ -57,9 +58,10 @@ def get_claims( referent: str requested_attr: ReqAttr try: - for referent, requested_attr in auth_session.presentation_exchange[ + for referent, requested_attrdict in auth_session.presentation_exchange[ "presentation_request" ]["requested_attributes"].items(): + requested_attr = ReqAttr(**requested_attrdict) logger.debug( f"Processing referent: {referent}, requested_attr: {requested_attr}" ) @@ -72,7 +74,7 @@ def get_claims( ] logger.debug(f"revealed_attrs: {revealed_attrs}") # loop through each value and put it in token as a claim - for attr_name in requested_attr["names"]: + for attr_name in requested_attr.names: logger.debug(f"AttrName: {attr_name}") presentation_claims[attr_name] = Claim( type=attr_name, @@ -85,6 +87,9 @@ def get_claims( ) raise RuntimeError(err) + proof_claims = json.dumps( + {c.type: c.value for c in presentation_claims.values()} + ) # look at all presentation_claims for one # matching the configured subject_identifier, if any sub_id_claim = presentation_claims.get(ver_config.subject_identifier) @@ -92,11 +97,20 @@ def get_claims( if sub_id_claim: # add sub and append presentation_claims oidc_claims.append(Claim(type="sub", value=sub_id_claim.value)) + elif ver_config.generate_consistent_identifier: + # Do not create a sub based on the proof claims if the + # user requests a generated identifier + oidc_claims.append( + Claim( + type="sub", + value=canonicaljson.encode_canonical_json(proof_claims).decode( + "utf-8" + ), + ) + ) result = {c.type: c.value for c in oidc_claims} - result[PROOF_CLAIMS_ATTRIBUTE_NAME] = json.dumps( - {c.type: c.value for c in presentation_claims.values()} - ) + result[PROOF_CLAIMS_ATTRIBUTE_NAME] = proof_claims # TODO: Remove after full transistion to v2.0 # Add the presentation claims to the result as keys for backwards compatibility [v1.0] 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 ca9c9444..533a89d3 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 @@ -219,7 +219,7 @@ 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_no_sub(): +async def test_valid_presentation_with_non_matching_subject_identifier_and_generate_consistent_identifier_is_missing_and_has_no_sub(): presentation["presentation_request"][ "requested_attributes" ] = basic_valid_requested_attributes @@ -229,4 +229,47 @@ async def test_valid_presentation_with_non_matching_subject_identifier_and_has_n with mock.patch.object(AuthSession, "presentation_exchange", presentation): ver_config.subject_identifier = "not-email" claims = Token.get_claims(auth_session, ver_config) + assert not ver_config.generate_consistent_identifier assert "sub" not in claims + + +@pytest.mark.asyncio +async def test_valid_presentation_with_non_matching_subject_identifier_and_generate_consistent_identifier_false_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" + ver_config.generate_consistent_identifier = False + claims = Token.get_claims(auth_session, ver_config) + assert "sub" not in claims + + +@pytest.mark.asyncio +async def test_valid_presentation_with_non_matching_subject_identifier_and_generate_consistent_identifier_true_and_has_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" + ver_config.generate_consistent_identifier = True + claims = Token.get_claims(auth_session, ver_config) + assert "sub" in claims + + # Ensure that this sub is not using the ver_config.subject_identifier + ver_config.subject_identifier = "email" + ver_config.generate_consistent_identifier = False + claims_subject_identifier = Token.get_claims(auth_session, ver_config) + assert claims["sub"] != claims_subject_identifier["sub"] + + # Ensure that sub is consistent + ver_config.subject_identifier = "not-email" + ver_config.generate_consistent_identifier = True + claims_duplicate = Token.get_claims(auth_session, ver_config) + assert claims["sub"] == claims_duplicate["sub"] diff --git a/oidc-controller/api/routers/oidc.py b/oidc-controller/api/routers/oidc.py index 22034c6e..7c7d044d 100644 --- a/oidc-controller/api/routers/oidc.py +++ b/oidc-controller/api/routers/oidc.py @@ -1,5 +1,6 @@ import base64 import io +from typing import cast import uuid from datetime import datetime from urllib.parse import urlencode @@ -163,7 +164,8 @@ async def get_authorize_callback(pid: str, db: Database = Depends(get_db)): async def post_token(request: Request, db: Database = Depends(get_db)): """Called by oidc platform to retrieve token contents""" async with request.form() as form: - form_dict = form._dict + logger.warn(f"post_token: form was {form}") + form_dict = cast(dict[str, str], form._dict) auth_session = await AuthSessionCRUD(db).get_by_pyop_auth_code( form_dict["code"] ) diff --git a/oidc-controller/api/verificationConfigs/examples.py b/oidc-controller/api/verificationConfigs/examples.py index ed612324..da9f9399 100644 --- a/oidc-controller/api/verificationConfigs/examples.py +++ b/oidc-controller/api/verificationConfigs/examples.py @@ -1,6 +1,7 @@ ex_ver_config = { "ver_config_id": "test-request-config", "include_v1_attributes": False, + "generate_consistent_identifier": False, "subject_identifier": "first_name", "proof_request": { "name": "Basic Proof", diff --git a/oidc-controller/api/verificationConfigs/models.py b/oidc-controller/api/verificationConfigs/models.py index 60c41a88..cfb318d2 100644 --- a/oidc-controller/api/verificationConfigs/models.py +++ b/oidc-controller/api/verificationConfigs/models.py @@ -41,6 +41,7 @@ class VerificationProofRequest(BaseModel): class VerificationConfigBase(BaseModel): subject_identifier: str = Field() proof_request: VerificationProofRequest = Field() + generate_consistent_identifier: Optional[bool] = Field(default=False) include_v1_attributes: Optional[bool] = Field(default=False) def generate_proof_request(self): @@ -52,8 +53,7 @@ def generate_proof_request(self): } for i, req_attr in enumerate(self.proof_request.requested_attributes): label = req_attr.label or "req_attr_" + str(i) - result["requested_attributes"][label] = req_attr.dict( - exclude_none=True) + result["requested_attributes"][label] = req_attr.dict(exclude_none=True) if settings.SET_NON_REVOKED: result["requested_attributes"][label]["non_revoked"] = { "from": int(time.time()), @@ -62,8 +62,7 @@ def generate_proof_request(self): # TODO add I indexing for req_pred in self.proof_request.requested_predicates: label = req_pred.label or "req_pred_" + str(i) - result["requested_predicates"][label] = req_pred.dict( - exclude_none=True) + result["requested_predicates"][label] = req_pred.dict(exclude_none=True) if settings.SET_NON_REVOKED: result["requested_attributes"][label]["non_revoked"] = { "from": int(time.time()), diff --git a/oidc-controller/requirements.txt b/oidc-controller/requirements.txt index 000f64af..d4cab51e 100644 --- a/oidc-controller/requirements.txt +++ b/oidc-controller/requirements.txt @@ -8,3 +8,4 @@ qrcode[pil]==7.4.2 structlog==23.1.0 uvicorn[standard]==0.22.0 python-socketio==5.8.0 # required to run websockets +canonicaljson==2.0.0 # used to provide unique consistent user identifiers