Skip to content

Commit 01a9c51

Browse files
authored
Bugfix: id_token claims missing when sub is not consistent (#931)
* v2.3.3 hotfix: ensure user_id is not null for in-memory userinfo storage (#924) * Fix userinfo flow for in-memory storage Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * Use in-memory storage for single pod mode Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * Bump project version Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * Update tests Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> --------- Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * bugfix: restore missing claims for non-consistent sub Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * Bump app version Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * Add test for non-consistent sub scenario Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> * Fix linting errors Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com> --------- Signed-off-by: Emiliano Suñé <emiliano.sune@gmail.com>
1 parent cedba81 commit 01a9c51

File tree

3 files changed

+114
-22
lines changed

3 files changed

+114
-22
lines changed

oidc-controller/api/routers/oidc.py

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -376,15 +376,21 @@ async def post_token(request: Request, db: Database = Depends(get_db)):
376376
),
377377
)
378378

379+
# CRITICAL: For StatelessWrapper (in-memory storage), claims MUST be
380+
# stored INSIDE the authorization code in authz_info["user_info"] before
381+
# packing, otherwise PyOP will retrieve empty claims when generating the
382+
# ID token. This must happen regardless of whether claims contain a "sub"
383+
# field. For RedisWrapper, this field is not used (claims are in Redis).
384+
authz_info = provider.provider.authz_state.authorization_codes[
385+
form_dict["code"]
386+
]
387+
authz_info["user_info"] = claims
388+
379389
# Replace auto-generated sub with one coming from proof, if available
380390
# The Redis storage uses a shared data store, so a new item can be
381391
# added and the reference in the form needs to be updated with the
382392
# new key value
383393
if claims.get("sub"):
384-
authz_info = provider.provider.authz_state.authorization_codes[
385-
form_dict["code"]
386-
]
387-
388394
# Update the subject with the one from the presentation
389395
presentation_sub = claims.pop("sub")
390396

@@ -408,29 +414,14 @@ async def post_token(request: Request, db: Database = Depends(get_db)):
408414
# Update our local reference to the new user_id
409415
user_id = presentation_sub
410416

411-
# CRITICAL: For StatelessWrapper (in-memory storage), claims are
412-
# stored INSIDE the authorization code in authz_info["user_info"].
413-
# We must update this with the actual claims before repacking,
414-
# otherwise PyOP will retrieve empty claims when generating the ID token.
415-
# For RedisWrapper, this field is not used (claims are in Redis).
416-
authz_info["user_info"] = claims
417-
418417
# Create subject identifier mapping: subject -> user_id
419418
# This is needed because PyOP will look up the user_id for this
420419
# subject. Store the public subject identifier with Redis-aware
421420
# persistence
422421
store_subject_identifier(user_id, "public", presentation_sub)
423422

424-
new_code = provider.provider.authz_state.authorization_codes.pack(
425-
authz_info
426-
)
427-
form_dict["code"] = new_code
428-
429-
# NOTE: Do NOT add sub back to claims dict. PyOP will get the
430-
# sub from authz_info["sub"] when generating the ID token.
431-
# If we include sub in claims as well, PyOP will receive it
432-
# twice and raise: "TypeError: IdToken() got multiple values
433-
# for keyword argument 'sub'"
423+
# Need to update user_info again after removing "sub" from claims
424+
authz_info["user_info"] = claims
434425

435426
logger.info(
436427
"Replaced authorization code with presentation subject",
@@ -442,6 +433,24 @@ async def post_token(request: Request, db: Database = Depends(get_db)):
442433
updated_user_info_in_code=True,
443434
)
444435

436+
# Pack the authorization code with updated authz_info
437+
new_code = provider.provider.authz_state.authorization_codes.pack(authz_info)
438+
form_dict["code"] = new_code
439+
440+
# NOTE: Do NOT add sub back to claims dict. PyOP will get the
441+
# sub from authz_info["sub"] when generating the ID token.
442+
# If we include sub in claims as well, PyOP will receive it
443+
# twice and raise: "TypeError: IdToken() got multiple values
444+
# for keyword argument 'sub'"
445+
446+
logger.info(
447+
"Updated authorization code with claims for StatelessWrapper",
448+
operation="update_auth_code_claims",
449+
auth_session_id=str(auth_session.id),
450+
user_id=user_id,
451+
claims_keys=list(claims.keys()),
452+
)
453+
445454
# Store claims in VCUserinfo so PyOP can retrieve them when
446455
# generating the ID token. This is critical - without this,
447456
# get_claims_for will return empty dict.

oidc-controller/api/routers/tests/test_oidc_token.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,3 +676,86 @@ async def test_user_info_contains_all_presentation_attributes(
676676
assert user_info["acr"] == "vc_authn"
677677
assert "given_names" in user_info["vc_presented_attributes"]
678678
assert "family_name" in user_info["vc_presented_attributes"]
679+
680+
@pytest.mark.asyncio
681+
async def test_claims_stored_without_sub_field(
682+
self, mock_db, mock_auth_session, mock_provider
683+
):
684+
"""Test claims are stored in authz_info when no sub field exists.
685+
686+
This covers the scenario where:
687+
- generate_consistent_identifier = False (no hash-based sub)
688+
- subject_identifier is empty or doesn't match (no attribute-based sub)
689+
Result: Token.get_claims() returns claims WITHOUT a "sub" field
690+
691+
Bug fix: authz_info["user_info"] must be set BEFORE checking if sub exists,
692+
otherwise claims are lost in StatelessWrapper mode.
693+
"""
694+
from api.authSessions.crud import AuthSessionCRUD
695+
from api.routers.oidc import post_token
696+
from api.verificationConfigs.crud import VerificationConfigCRUD
697+
698+
# Config that won't generate a sub
699+
mock_config = MagicMock()
700+
mock_config.subject_identifier = "" # Empty - won't create sub from attribute
701+
mock_config.generate_consistent_identifier = False # Won't create hash sub
702+
mock_config.include_v1_attributes = False
703+
704+
with patch.object(
705+
AuthSessionCRUD,
706+
"get_by_pyop_auth_code",
707+
return_value=mock_auth_session,
708+
):
709+
with patch.object(VerificationConfigCRUD, "get", return_value=mock_config):
710+
with patch.object(
711+
AuthSessionCRUD,
712+
"update_pyop_user_id",
713+
new_callable=AsyncMock,
714+
) as mock_update:
715+
# Mock jwt.decode - return claims WITHOUT sub field
716+
with patch("jwt.decode") as mock_decode:
717+
mock_decode.return_value = {
718+
"pres_req_conf_id": "showcase-person",
719+
"vc_presented_attributes": {
720+
"given_names": "John",
721+
"family_name": "Doe",
722+
},
723+
"acr": "vc_authn",
724+
# NOTE: NO "sub" field - this is the critical test case
725+
}
726+
727+
mock_request = MagicMock()
728+
mock_form = MagicMock()
729+
mock_form._dict = {
730+
"code": "test-auth-code",
731+
"grant_type": "authorization_code",
732+
}
733+
mock_request.form = MagicMock(
734+
return_value=MagicMock(
735+
__aenter__=AsyncMock(return_value=mock_form),
736+
__aexit__=AsyncMock(return_value=None),
737+
)
738+
)
739+
mock_request.headers = {}
740+
741+
await post_token(mock_request, mock_db)
742+
743+
# Verify authz_info["user_info"] was populated despite no sub
744+
pack_call = (
745+
mock_provider.provider.authz_state.authorization_codes.pack
746+
)
747+
assert pack_call.called
748+
749+
authz_info = pack_call.call_args[0][0]
750+
assert "user_info" in authz_info
751+
752+
user_info = authz_info["user_info"]
753+
assert user_info["pres_req_conf_id"] == "showcase-person"
754+
assert user_info["acr"] == "vc_authn"
755+
assert "given_names" in user_info["vc_presented_attributes"]
756+
assert "family_name" in user_info["vc_presented_attributes"]
757+
# Verify no sub was added
758+
assert "sub" not in user_info
759+
760+
# Verify pyop_user_id was NOT updated (no sub to replace it with)
761+
mock_update.assert_not_called()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "acapy-vc-authn-oidc"
3-
version = "2.3.4"
3+
version = "2.3.5"
44
description = "Verifiable Credential Identity Provider for OpenID Connect."
55
authors = [ { name = "OpenWallet Foundation" } ]
66
license = "Apache-2.0"

0 commit comments

Comments
 (0)