17. Cognito adapter specialization
Date: 2026-03-17
Status
Accepted
Context
Tier 3 integration tests (moto) revealed that CognitoTokenValidator — currently a thin wrapper over the generic OidcTokenValidator — fails to validate Cognito access tokens and incorrectly resolves principal actor type.
Cognito token claim map (verified with moto)
| Claim | ID token (email username) | ID token (plain username) | Access token |
|---|---|---|---|
iss |
https://cognito-idp.{region}.amazonaws.com/{pool_id} |
same | same |
sub |
UUID | UUID | UUID |
aud |
✅ client_id | ✅ client_id | ❌ absent |
client_id |
absent | absent | ✅ client_id |
token_use |
"id" |
"id" |
"access" |
email |
✅ present | ❌ absent | absent |
cognito:username |
sub UUID | plain username | absent |
username |
absent | absent | email or username |
cognito:groups |
✅ present (if user in group) | ✅ present | absent |
scope |
absent | absent | aws.cognito.signin.user.admin |
Problem 1: Access token audience
The OIDC specification (OIDC Core §2) requires the aud claim in ID tokens only. Cognito follows this for ID tokens but diverges for access tokens:
| Token type | OIDC spec | Cognito behavior |
|---|---|---|
| ID token | aud = client_id (REQUIRED) |
aud = client_id ✅ |
| Access token | aud not required by OAuth2 |
aud absent — uses client_id as a separate field |
The generic OidcTokenValidator._check_audience() checks aud for all tokens → fails on Cognito access tokens.
Problem 2: Email claim location
Cognito includes email in the ID token only when:
- The pool has email in UsernameAttributes, OR
- The email scope is requested and the attribute is present
Moto replicates this behavior. When email is not a username attribute, it appears in UserAttributes but not in the JWT claims. The CognitoPrincipalResolver looks for email in custom_claims → doesn't find it → classifies the actor as MACHINE instead of HUMAN.
Root cause
CognitoTokenValidator was designed as a zero-logic thin wrapper (ADR-0013):
class CognitoTokenValidator:
def __init__(self, settings):
self._delegate = OidcTokenValidator(settings=settings, claims_mapper=CognitoClaimsMapper())
async def validate(self, token):
return await self._delegate.validate(token)
This design assumed Cognito tokens follow standard OIDC conventions uniformly. The Tier 3 tests proved this assumption wrong.
Decision
Specialize CognitoTokenValidator to handle Cognito-specific token behavior, while keeping OidcTokenValidator strictly OIDC-compliant.
What changes
CognitoTokenValidator — audience check override
For Cognito access tokens (token_use: "access"), the audience check must look at the client_id field instead of aud:
class CognitoTokenValidator:
async def validate(self, token: str) -> TokenValidationResult:
# Delegate to generic OIDC for ID tokens
# Override audience check for access tokens (client_id field)
The validator should:
1. Decode the token header + claims (via the generic validator's shared logic)
2. If token_use == "access": check client_id field against expected audience
3. If token_use == "id": delegate to standard aud check (OIDC-compliant)
4. All other validation (signature, issuer, expiry) remains in the generic OidcTokenValidator
CognitoClaimsMapper — email extraction
Extend CognitoClaimsMapper or CognitoPrincipalResolver to look for email in Cognito-specific locations:
1. Standard email claim (when present in JWT)
2. cognito:username if the pool uses email as username attribute
What stays the same
| Component | Change |
|---|---|
OidcTokenValidator |
No change — remains strictly OIDC-compliant |
OidcPrincipalResolver |
No change — remains generic |
StandardClaimsMapper |
No change |
| All existing Tier 1 and Tier 2 tests | No change |
Alternatives considered
B. Make audience field configurable in OidcTokenValidator
Add a parameter like audience_field="aud" that Cognito overrides to "client_id" for access tokens. Rejected because:
- The aud field behavior in Cognito depends on token type (id vs access), not on provider configuration
- This would leak Cognito-specific logic (token_use branching) into the generic OIDC validator
- OIDC spec is clear: aud is required in ID tokens — the generic validator is correct
C. Only validate ID tokens, reject access tokens
Document that the proxy only validates ID tokens and access tokens must be validated by the resource server. Rejected because:
- Both token types may be presented as Bearer tokens
- The proxy should handle both gracefully
- Cognito's AdminInitiateAuth returns both token types
Consequences
CognitoTokenValidatorbecomes a real specialization, not just a thin wrapper- Cognito-specific audience check logic is isolated in the Cognito adapter (not in generic OIDC)
OidcTokenValidatorremains strictly OIDC-compliant for other providers- Tier 3 tests (moto) will validate Cognito-specific behavior end-to-end
- Future providers with similar quirks can follow the same pattern: generic OIDC + provider-specific override
References
- OIDC Core §2 — ID Token —
audREQUIRED in ID tokens - OAuth2 RFC 6749 —
audnot defined for access tokens - ADR-0013 Provider-agnostic JWT Validation — original thin wrapper design
- ADR-0016 Cognito Testing Strategy — Tier 3 revealed the issue
- AWS Cognito Token Claims — Cognito token structure