Pin ID token aud claim to the client id #59

Closed
opened 2026-07-28 05:57:11 +00:00 by claude-bot · 1 comment

Severity: HIGH

The bug

The ID token's aud claim is not pinned to Circa's client id. Verified against installed
Authlib 1.6.10: parse_id_token builds claims_options containing iss only:

if claims_options is None and "issuer" in metadata:
    claims_options = {"iss": {"values": [metadata["issuer"]]}}

and validate_aud returns immediately when no aud option is configured:

aud_option = self.options.get("aud")
if not aud_option or not aud:
    return

So aud must 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 mismatched aud only if there
is also no azp claim — a token carrying azp == client_id with a foreign aud passes.

Signature verification against the provider JWKS, iss, and exp are all validated correctly.

Why this matters for this deployment

The IdP is self-hosted Authentik, which is exactly the multi-client scenario where aud does
the 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 aud pin is the only
control that would reject it. Combined with auto-provisioning, acceptance means immediate
reviewer access.

Fix

oauth.register(
    name="circa",
    ...
    claims_options={
        "aud": {"essential": True, "values": [settings.oauth_client_id]},
        "iss": {"essential": True},
        "exp": {"essential": True},
    },
)

Done when

  • aud is pinned to the configured client id
  • A token with a foreign aud is rejected, covered by a test with a crafted claim set
  • Login against Authentik still succeeds

References

  • backend/app/auth/oauth.py:27-34
  • Installed Authlib 1.6.10: oidc/core/claims.py:129-151, jose/rfc7519/claims.py:130
## Severity: HIGH ## The bug The ID token's `aud` claim is not pinned to Circa's client id. Verified against installed Authlib 1.6.10: `parse_id_token` builds `claims_options` containing **`iss` only**: ```python if claims_options is None and "issuer" in metadata: claims_options = {"iss": {"values": [metadata["issuer"]]}} ``` and `validate_aud` returns immediately when no `aud` option is configured: ```python aud_option = self.options.get("aud") if not aud_option or not aud: return ``` So `aud` must 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 mismatched `aud` **only if** there is also no `azp` claim — a token carrying `azp == client_id` with a foreign `aud` passes. Signature verification against the provider JWKS, `iss`, and `exp` are all validated correctly. ## Why this matters for this deployment The IdP is self-hosted Authentik, which is exactly the multi-client scenario where `aud` does the 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 `aud` pin is the only control that would reject it. Combined with auto-provisioning, acceptance means immediate reviewer access. ## Fix ```python oauth.register( name="circa", ... claims_options={ "aud": {"essential": True, "values": [settings.oauth_client_id]}, "iss": {"essential": True}, "exp": {"essential": True}, }, ) ``` ## Done when - [ ] `aud` is pinned to the configured client id - [ ] A token with a foreign `aud` is rejected, covered by a test with a crafted claim set - [ ] Login against Authentik still succeeds ## References - `backend/app/auth/oauth.py:27-34` - Installed Authlib 1.6.10: `oidc/core/claims.py:129-151`, `jose/rfc7519/claims.py:130`
claude-bot added this to the v0.1.1 milestone 2026-07-28 05:57:11 +00:00
Author

Fixed 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_options to oauth.register(...). Checking the pinned Authlib 1.7.2 source:

  • OAuth2Base.__init__ has no claims_options parameter. Unrecognised kwargs land in
    self.server_metadata = kwargs.
  • starlette_client/apps.py:131claims_options = kwargs.pop("claims_options", None) pops it
    from authorize_access_token's kwargs, not from the registration.

So the option would have been silently swallowed and aud would still have gone unchecked. Authlib
does not reject a misplaced option, so nothing would have surfaced the mistake.

What works

build_claims_options() in app/auth/oauth.py, passed at the call site:

metadata = await client.load_server_metadata()
token = await client.authorize_access_token(
    request,
    claims_options=build_claims_options(metadata["issuer"], settings.oauth_client_id),
)

The second subtlety

Supplying claims_options replaces Authlib's default — and the default is where issuer pinning
comes from:

if claims_options is None and "issuer" in metadata:
    claims_options = {"iss": {"values": [metadata["issuer"]]}}

So a naive {"aud": ...} would have closed the audience gap and opened an issuer gap. The expected
issuer is read from discovery metadata and pinned explicitly alongside aud.

Tests

backend/tests/test_oidc_hardening.py covers audience pinning, issuer pinning surviving, every
pinned claim being essential, and a foreign audience not matching. There is also an explicit
regression guard asserting claims_options appears at the authorize_access_token call site and
not in register_provider — so the inert version cannot come back.

Note for #43

When GenericOAuth2Backend is built, the audience must stay pinned to the configured client id per
provider, and build_claims_options is the seam for that.

**Fixed** 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_options` to `oauth.register(...)`. Checking the pinned Authlib 1.7.2 source: - `OAuth2Base.__init__` has no `claims_options` parameter. Unrecognised kwargs land in `self.server_metadata = kwargs`. - `starlette_client/apps.py:131` — `claims_options = kwargs.pop("claims_options", None)` pops it from **`authorize_access_token`'s** kwargs, not from the registration. So the option would have been silently swallowed and `aud` would still have gone unchecked. Authlib does not reject a misplaced option, so nothing would have surfaced the mistake. ## What works `build_claims_options()` in `app/auth/oauth.py`, passed at the call site: ```python metadata = await client.load_server_metadata() token = await client.authorize_access_token( request, claims_options=build_claims_options(metadata["issuer"], settings.oauth_client_id), ) ``` ## The second subtlety Supplying `claims_options` **replaces** Authlib's default — and the default is where issuer pinning comes from: ```python if claims_options is None and "issuer" in metadata: claims_options = {"iss": {"values": [metadata["issuer"]]}} ``` So a naive `{"aud": ...}` would have closed the audience gap and opened an issuer gap. The expected issuer is read from discovery metadata and pinned explicitly alongside `aud`. ## Tests `backend/tests/test_oidc_hardening.py` covers audience pinning, issuer pinning surviving, every pinned claim being `essential`, and a foreign audience not matching. There is also an explicit regression guard asserting `claims_options` appears at the `authorize_access_token` call site and **not** in `register_provider` — so the inert version cannot come back. ## Note for #43 When `GenericOAuth2Backend` is built, the audience must stay pinned to the configured client id per provider, and `build_claims_options` is the seam for that.
Sign in to join this conversation.
No description provided.