Pin ID token aud claim to the client id #59
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH
The bug
The ID token's
audclaim is not pinned to Circa's client id. Verified against installedAuthlib 1.6.10:
parse_id_tokenbuildsclaims_optionscontainingissonly:and
validate_audreturns immediately when noaudoption is configured:So
audmust be present (it is an essential claim) but is never compared to the client id.The only remaining check is
validate_azp, which rejects a mismatchedaudonly if thereis also no
azpclaim — a token carryingazp == client_idwith a foreignaudpasses.Signature verification against the provider JWKS,
iss, andexpare all validated correctly.Why this matters for this deployment
The IdP is self-hosted Authentik, which is exactly the multi-client scenario where
auddoesthe real work. Authentik typically fronts several internal services with very different trust
levels. An ID token minted for a lower-value application — one the attacker legitimately uses,
or can register — has a valid signature and the correct issuer. The
audpin is the onlycontrol that would reject it. Combined with auto-provisioning, acceptance means immediate
reviewer access.
Fix
Done when
audis pinned to the configured client idaudis rejected, covered by a test with a crafted claim setReferences
backend/app/auth/oauth.py:27-34oidc/core/claims.py:129-151,jose/rfc7519/claims.py:130Fixed in
244ebb7. CI green.The first version of this fix was wrong, and it is worth recording why, because it failed in a
way that looks correct.
What did not work
Passing
claims_optionstooauth.register(...). Checking the pinned Authlib 1.7.2 source:OAuth2Base.__init__has noclaims_optionsparameter. Unrecognised kwargs land inself.server_metadata = kwargs.starlette_client/apps.py:131—claims_options = kwargs.pop("claims_options", None)pops itfrom
authorize_access_token's kwargs, not from the registration.So the option would have been silently swallowed and
audwould still have gone unchecked. Authlibdoes not reject a misplaced option, so nothing would have surfaced the mistake.
What works
build_claims_options()inapp/auth/oauth.py, passed at the call site:The second subtlety
Supplying
claims_optionsreplaces Authlib's default — and the default is where issuer pinningcomes from:
So a naive
{"aud": ...}would have closed the audience gap and opened an issuer gap. The expectedissuer is read from discovery metadata and pinned explicitly alongside
aud.Tests
backend/tests/test_oidc_hardening.pycovers audience pinning, issuer pinning surviving, everypinned claim being
essential, and a foreign audience not matching. There is also an explicitregression guard asserting
claims_optionsappears at theauthorize_access_tokencall site andnot in
register_provider— so the inert version cannot come back.Note for #43
When
GenericOAuth2Backendis built, the audience must stay pinned to the configured client id perprovider, and
build_claims_optionsis the seam for that.