Skip to content

fix(oidc): support additional trusted ID-token audiences (opt-in, fail-closed) - #12

Merged
fylorn merged 1 commit into
ThinkWatchProject:mainfrom
DaniW42:fix/oidc-additional-trusted-audiences
Sep 12, 2026
Merged

fylorn merged 1 commit into
ThinkWatchProject:mainfrom
DaniW42:fix/oidc-additional-trusted-audiences

Conversation

@DaniW42

@DaniW42 DaniW42 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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:

ID token verification failed: Invalid audiences: `<project-id>` is not a trusted audience

This happens because 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 multi-audience shape
cannot be used for SSO at all.

Reproducible against Zitadel 4.17.1 (self-hosted); Zitadel's own docs
confirm this aud shape is intentional and by design, and
zitadel/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):

  • Unset or empty (default): zero behavior change. The code takes
    the exact same client.id_token_verifier() path as before — no
    wrapper, no custom callback installed at all.
  • Set: the listed audiences are accepted in addition to the
    client ID, via set_other_audience_verifier_fn. Every other,
    unlisted audience is still rejected exactly as before.
  • Whenever the token doesn't unambiguously prove it was issued for
    this client — either aud has more than one entry, or the client ID
    itself isn't in aud at all — azp is additionally required to
    match 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 what
this does) — every additional audience must be explicitly, exactly
configured.

Scope

Single file changed: crates/auth/src/oidc.rs. No changes to the
wizard UI, database schema, or any other crate. Six new unit tests
cover: audience-string parsing, the azp requirement across all
audience-count/audience-membership combinations, and a marker
documenting the empty-set/stock-verifier split.

Testing

  • 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 flow (/api/auth/sso/authorize →
    /api/auth/sso/callback → /api/auth/register-key →
    /api/auth/me) completes successfully with
    OIDC_ADDITIONAL_TRUSTED_AUDIENCES set to the trusted Zitadel
    project ID, and — separately verified — with the variable unset,
    behaves identically to the current main branch (still rejects the
    same multi-audience token).

Backward compatibility

Fully backward compatible. No existing behavior changes unless an
operator explicitly sets the new environment variable.

Closes #10

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
@fylorn
fylorn merged commit 78a48d9 into ThinkWatchProject:main Sep 12, 2026
@fylorn

fylorn commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Merged — thank you, this is a well-built patch.

A few notes on what we checked, since it touches token validation:

  • Read openidconnect 4.0.1's src/verification/mod.rs to confirm the blast radius. client_id ∈ aud stays enforced unconditionally (the aud_match_required block), and set_other_audience_verifier_fn only governs the extra audiences when aud.len() > 1. So the allowlist widens exactly one check and nothing else.
  • The crate's own azp validation (steps 4–5) is commented out upstream, with a note that they'd rather let callers supply the check. validate_authorized_party is precisely that, which makes this PR stricter than stock behavior, not looser — worth stating plainly because "additional trusted audiences" reads like a relaxation at first glance.
  • Verified locally against current main: cargo clippy --workspace --all-targets -- -D warnings clean, full workspace test suite green, including your 5 new unit tests.

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:

  1. OIDC_ADDITIONAL_TRUSTED_AUDIENCES should be listed (commented out) in .env.example, next to JWT_LEEWAY_SECS which sets the precedent. Otherwise operators can't discover the knob.
  2. validate_authorized_party's !client_id_in_audiences arm is unreachable in practice, since the crate rejects those tokens before we get there. Good defense in depth — we'll add a comment so nobody later assumes it's load-bearing.
  3. OIDC Core step 5 asks for azp to be verified whenever it's present, not only when azp_required. Matching stock behavior here is fine, but since the check already exists, making it unconditional is free hardening.

Also: the test comment references docs/thinkwatch.md, which doesn't exist in the repo — probably a stale path.

Incidentally this fixes a bug that hits our own dev setup, since the dev OIDC provider in .env.example is Zitadel. Appreciated.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants