fix(oidc): support additional trusted ID-token audiences (opt-in, fail-closed) - #12
Merged
fylorn merged 1 commit intoSep 12, 2026
Conversation
Providers that issue ID tokens with multiple values in aud -- e.g. Zitadel, which by default includes both the requesting client ID and the parent project ID -- fail ID-token verification with: ID token verification failed: Invalid audiences: `<project-id>` is not a trusted audience openidconnect-rs'\''s default other_aud_verifier_fn rejects every audience other than the configured client ID (a deliberately conservative default, see verification/mod.rs). ThinkWatch currently has no way to opt into trusting a specific additional audience value, so any IdP with this shape cannot be used for SSO at all. Reproducible against self-hosted Zitadel 4.17.1; Zitadel confirms this aud shape is intentional, and zitadel/zitadel#9200 is an open (not won'\''t-fix) request asking for configurable behavior on their side -- not practical to depend on in the near term. Adds one new environment variable, OIDC_ADDITIONAL_TRUSTED_AUDIENCES (comma-separated, exact-match, no wildcards/regex): - Unset or empty (default): zero behavior change. Takes the exact same client.id_token_verifier() path as before -- no wrapper installed at all. - Set: the listed audiences are additionally accepted via set_other_audience_verifier_fn. Every other, unlisted audience is still rejected exactly as before. No blanket-acceptance callback is used anywhere. - Whenever the token does not unambiguously prove it was issued for this client -- aud has more than one entry, or the client ID itself is absent from aud -- azp is additionally required to match OIDC_CLIENT_ID (OIDC Core 3.1.3.7). Single file changed: crates/auth/src/oidc.rs. Six new unit tests cover audience-string parsing and the azp requirement across all audience-count / audience-membership combinations, plus a marker documenting the empty-set/stock-verifier split. Tests: cargo test -p think-watch-auth 74/74 passing. cargo fmt --all -- --check clean. cargo clippy -p think-watch-auth --all-targets -- -D warnings clean. Verified against a live production deployment behind self-hosted Zitadel 4.17.1: full SSO login (sso/authorize -> sso/callback -> register-key -> me) succeeds with OIDC_ADDITIONAL_TRUSTED_AUDIENCES set to the trusted project ID; separately verified that with the variable unset, behavior is unchanged from current main (still rejects the same multi-audience token). Fully backward compatible -- no existing behavior changes unless an operator explicitly sets the new environment variable. Closes ThinkWatchProject#10
Contributor
|
Merged — thank you, this is a well-built patch. A few notes on what we checked, since it touches token validation:
The opt-in/fail-closed shape is the right call, and skipping the verifier install entirely on an empty allowlist is a nice touch — it keeps the unconfigured path on the stock code path instead of reimplementing it. Three small follow-ups we'll handle ourselves, no action needed from you:
Also: the test comment references Incidentally this fixes a bug that hits our own dev setup, since the dev OIDC provider in |
fylorn
pushed a commit
that referenced
this pull request
Sep 12, 2026
Providers that issue ID tokens with multiple values in aud -- e.g. Zitadel, which by default includes both the requesting client ID and the parent project ID -- fail ID-token verification with: ID token verification failed: Invalid audiences: `<project-id>` is not a trusted audience openidconnect-rs'\''s default other_aud_verifier_fn rejects every audience other than the configured client ID (a deliberately conservative default, see verification/mod.rs). ThinkWatch currently has no way to opt into trusting a specific additional audience value, so any IdP with this shape cannot be used for SSO at all. Reproducible against self-hosted Zitadel 4.17.1; Zitadel confirms this aud shape is intentional, and zitadel/zitadel#9200 is an open (not won'\''t-fix) request asking for configurable behavior on their side -- not practical to depend on in the near term. Adds one new environment variable, OIDC_ADDITIONAL_TRUSTED_AUDIENCES (comma-separated, exact-match, no wildcards/regex): - Unset or empty (default): zero behavior change. Takes the exact same client.id_token_verifier() path as before -- no wrapper installed at all. - Set: the listed audiences are additionally accepted via set_other_audience_verifier_fn. Every other, unlisted audience is still rejected exactly as before. No blanket-acceptance callback is used anywhere. - Whenever the token does not unambiguously prove it was issued for this client -- aud has more than one entry, or the client ID itself is absent from aud -- azp is additionally required to match OIDC_CLIENT_ID (OIDC Core 3.1.3.7). Single file changed: crates/auth/src/oidc.rs. Six new unit tests cover audience-string parsing and the azp requirement across all audience-count / audience-membership combinations, plus a marker documenting the empty-set/stock-verifier split. Tests: cargo test -p think-watch-auth 74/74 passing. cargo fmt --all -- --check clean. cargo clippy -p think-watch-auth --all-targets -- -D warnings clean. Verified against a live production deployment behind self-hosted Zitadel 4.17.1: full SSO login (sso/authorize -> sso/callback -> register-key -> me) succeeds with OIDC_ADDITIONAL_TRUSTED_AUDIENCES set to the trusted project ID; separately verified that with the variable unset, behavior is unchanged from current main (still rejects the same multi-audience token). Fully backward compatible -- no existing behavior changes unless an operator explicitly sets the new environment variable. Closes #10
fylorn
added a commit
that referenced
this pull request
Sep 12, 2026
The three loose ends from reviewing #12, none of which blocked it. - Document `OIDC_ADDITIONAL_TRUSTED_AUDIENCES` in `.env.example`, next to `JWT_LEEWAY_SECS` which set the precedent. An undocumented env var is one an operator cannot discover. - Correct the doc comment on `validate_authorized_party`. It claimed `client_id_in_audiences == false` happens when the allowlist accepts a single-audience token whose sole audience is a trusted non-client-ID value. It doesn't: `IdTokenVerifier` enforces `client_id ∈ aud` in its `aud_match_required` block before `other_aud_verifier_fn` is ever consulted, so such a token never reaches us. The parameter stays as defense in depth if that guarantee ever changes, and the comment now says it isn't load-bearing so nobody later builds on it. - Check `azp` unconditionally when present, per OIDC Core 3.1.3.7 step 5, which has no audience-count precondition. The old shape only looked at `azp` when it was already required by step 4, so a single-audience token naming us with `azp` pointing at a different client was accepted — the IdP telling us plainly that the token was authorized for someone else. Matching stock `openidconnect` there was not a regression (its azp check is commented out entirely), but the check already existed here, so making it unconditional is free. New test covers the case. 75 tests in think-watch-auth, clippy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Providers that issue OIDC ID tokens with multiple values in
aud— e.g.Zitadel, which by default includes both the requesting client ID and
the parent project ID — fail ID-token verification against
ThinkWatch's login and SSO callback with:
This happens because
openidconnect-rs's defaultother_aud_verifier_fnrejects every audience other than theconfigured client ID (a deliberately conservative default — see
verification/mod.rs).ThinkWatch currently has no way to opt into trusting a specific,
additional audience value, so any IdP with this multi-audience shape
cannot be used for SSO at all.
Reproducible against Zitadel 4.17.1 (self-hosted); Zitadel's own docs
confirm this
audshape is intentional and by design, andzitadel/zitadel#9200
is an open (not "won't fix") enhancement request asking for
configurable audience behavior on their side. Relying solely on an
upstream Zitadel change isn't practical for us in the near term, so
this PR proposes a small, opt-in fix on the relying-party side
instead.
Fix
Adds one new environment variable,
OIDC_ADDITIONAL_TRUSTED_AUDIENCES(comma-separated, exact-match, no wildcards/regex):
the exact same
client.id_token_verifier()path as before — nowrapper, no custom callback installed at all.
client ID, via
set_other_audience_verifier_fn. Every other,unlisted audience is still rejected exactly as before.
this client — either
audhas more than one entry, or the client IDitself isn't in
audat all —azpis additionally required tomatch
OIDC_CLIENT_ID(per OIDC Core §3.1.3.7).No blanket-acceptance function is used anywhere
(
set_other_audience_verifier_fn(|_| true)is explicitly not whatthis does) — every additional audience must be explicitly, exactly
configured.
Scope
Single file changed:
crates/auth/src/oidc.rs. No changes to thewizard UI, database schema, or any other crate. Six new unit tests
cover: audience-string parsing, the
azprequirement across allaudience-count/audience-membership combinations, and a marker
documenting the empty-set/stock-verifier split.
Testing
cargo test -p think-watch-auth: 74/74 passingcargo fmt --all -- --check: cleancargo clippy -p think-watch-auth --all-targets -- -D warnings: cleanZitadel 4.17.1: full SSO login flow (
/api/auth/sso/authorize→/api/auth/sso/callback→/api/auth/register-key→/api/auth/me) completes successfully withOIDC_ADDITIONAL_TRUSTED_AUDIENCESset to the trusted Zitadelproject ID, and — separately verified — with the variable unset,
behaves identically to the current
mainbranch (still rejects thesame multi-audience token).
Backward compatibility
Fully backward compatible. No existing behavior changes unless an
operator explicitly sets the new environment variable.
Closes #10