Skip to content

fix(oidc/client)!: close the security audit findings of the OIDC client - #125

Merged
euskadi31 merged 10 commits into
masterfrom
fix/audit2-oidc-client
Oct 21, 2026
Merged

euskadi31 merged 10 commits into
masterfrom
fix/audit2-oidc-client

Conversation

@euskadi31

Copy link
Copy Markdown
Contributor

Fixes the following findings of the internal security audit of 2026-10-21 (commit 1551aa6): F-11, F-12, I-14, I-16, I-17, D-12, D-13, D-14 — plus the oidc/client counterpart of I-11/I-12 (PSS salt length, Ed25519 key checks), raised by the jwt PR.

One commit per finding; every commit passed make generate sync build test lint check-modules.

F-11 — secrets printed through unexported fields and a dereferenced *Client

Problem. fmt prints a value it reaches through an unexported field of another value by reflection, without calling its Format/String methods. An application struct holding an oidcclient.Config, AuthRequest, Callback, Identity or Tokens in an unexported field — or %+v of a dereferenced *Client — printed the client secret, the transaction keys, the binding secret or the tokens; slog's text handler too. Holding the configuration behind one pointer is not enough: for the verbs a pointer does not take (%s, %q…) fmt reports a bad verb and prints what the pointer points to.

Fix (breaking). oidcclient.Secret (NewSecret, Reveal) for every secret of the API: Config.ClientSecret, Config.TransactionKeys ([]Secret), AuthRequest.Binding, Callback.Binding, Tokens.AccessToken/RefreshToken/IDToken, AuthOptions.IDTokenHint. Every fmt verb, slog and encoding/json render it REDACTED; its value sits behind a pointer to a string, which fmt prints as an address when it reaches it by reflection (a depth-0 re-print never expands a pointer to a string). gob refuses it, xml/YAML write it empty, it does not decode from JSON. Identity/Tokens therefore encode their tokens REDACTED. Not covered (documented): reflection dumpers that dereference pointers without calling methods (testify's diffs).

Migration. MIGRATION "every secret is an oidcclient.Secret": wrap configured secrets with NewSecret, Reveal() tokens where used; gob/YAML-persisted values and pre-upgrade JSON break — persist a struct of strings (recipe included).

F-12 — InsecureCookies accepted with an https RedirectURL

Problem. With an https redirect URI the binding cookie lost __Host- and Secure: a sibling subdomain or a network attacker answering for the http origin could plant the binding cookie of its own login and sign the victim in as the attacker (login CSRF).

Fix (breaking). New refuses InsecureCookies (ErrInvalidConfig, before any network access) unless RedirectURL is http — accepted on a loopback host only. RFC 9700 §4.7; rfc6265bis-22 §4.1.3.2, §8.6.

I-14 — a leaked state burns the pending login

Problem. An authorization response is not authenticated: with the state, a request to the callback through the victim's own browser expires the binding cookie (and with an error or a bogus code consumes the transaction). No protocol defense exists.

Fix. AuthRequest String/GoString/Format/LogValue drop the state and the URL query (hints included); its JSON hand-off is unchanged. The residual is documented (LIMITATIONS, security-considerations) and pinned by a cookie-jar test.

I-16 — every asymmetric algorithm accepted by default

Fix (breaking). An empty Config.Algorithms now means RS256 — OpenID Connect Core §3.1.3.7 step 7: "The alg value SHOULD be the default of RS256 or the algorithm sent by the Client in the id_token_signed_response_alg parameter during Registration"; every provider supports RS256 (Discovery §3). Errors name the option. Migration: set Config.Algorithms for a provider signing with another algorithm.

I-17 + D-13 — RFC 9207 iss policy; mix-up residual without RFC 9207

Problem. A matching iss from a provider not advertising authorization_response_iss_parameter_supported was accepted (RFC 9207 §2.4: "SHOULD discard"), and nothing could require iss; without it, distinct redirect URIs leave the gap RFC 9700 §4.4.2.2 names.

Fix (breaking). New Config.RequireIssParameter ("according to the client's configuration", §2.4): iss required and compared on every response. Without it, iss from a non-advertising provider is discarded (ErrIssuerParameter, text names the option). It protects the connection that receives the response: an application whose tenants configure providers sets it on every tenant connection. A stack test runs the §4.4.2.2 attack against an attacker connection with the flag: the honest code never reaches the attacker. Migration: set the flag for a provider that sends iss without advertising it (which real providers do is unknown).

D-12 — in-memory transaction store unbounded by default

Fix (breaking). memory.DefaultCapacity = 10_000 applied by memory.New; WithCapacity overrides; a full store refuses with ErrFull, never evicting a pending login.

D-14 — withdrawn provider key trusted for a while; Redis failover

Fix. JWK Set TTL 1 h → 5 min (the jwt module's DefaultCacheTTL; OpenID Connect Core §10.1.1 sets no bound). The 24 h staleness while fetches fail stays — the jwt trade-off — and its exact risk is documented with the remedy (rebuild the Client). Redis failover losing a consumption: documented, like the other Redis stores (single use then rests on the provider's single-use code and the binding secret).

oidc/client counterpart of I-11 / I-12

PS256/384/512 ID Tokens are verified with a salt as long as the hash (RFC 7518 §3.5) through a jose.OpaqueVerifier bound to its algorithm; JWK Set Ed25519 keys that are not canonical (RFC 8032 §5.1.3) or of small order (RFC 8725 §3.4) are ignored. Same rules as jwt.

Tests

  • TDD: each finding's failing test was run first; red phases recorded (e.g. TestSecretsNeverPrint leaked under %q/%x/gob, TestNewRejectsInvalidConfig/insecure_cookies_with_an_https_redirect got nil, TestCompleteIssParameter/not_advertised_and_equal… got nil, TestStoreIsBoundedByDefault stored the 10 001st, TestKeySetTTL still cached at 5 min, TestIDTokenPSSSaltLength verified every salt).
  • Mutation: 45 mutants on the guards (Secret renderings and representation, InsecureCookies, default algorithms, iss branches, default capacity, AuthRequest renderings, TTL, PSS, Ed25519) — all killed by named tests except one true equivalent (!= "http" vs == "https", no third scheme admitted). The independent code review ran 31 more: all killed.

Checks

make generate sync build test lint check-modules: green (lint 0 issues, make sync leaves no diff).

Package Coverage
oidc/client 99.4 % (new files and changed functions 100 %; uncovered: pre-existing unreachable defaults)
oidc/client/storage/memory 100 %
oidc/client/storetest 97.5 % (untouched)

Not run: make test-backends (no SQL/Redis store touched), make vuln (no dependency change), make conformance (opt-in OIDF suite; the ES256 module now configures Algorithms: [ES256], the static registration's algorithm, and keeps its expected outcome).

Follow-ups

  • The SAML and OAuth2 in-memory stores may take the same bounded-by-default policy.
  • Run make conformance (RP plans) before the release.

Notes for the maintainer

  • CLAUDE.md (OpenID Connect client invariants) becomes stale on the JSON encoding of Identity/Tokens; the proposed text is held for your consent.

@euskadi31
euskadi31 force-pushed the fix/audit2-oidc-client branch 5 times, most recently from f42ecee to 8fdf04f Compare October 21, 2026 19:35
fmt prints a value it reaches through an unexported field of another
value by reflection, without calling its String, GoString or Format
methods. An application struct holding an oidcclient.Config,
AuthRequest, Callback, Identity or Tokens in an unexported field, or a
dereferenced *Client (%+v of *client), therefore printed the client
secret, the transaction keys, the binding secret or the tokens - and so
did slog's text handler, which prints such a struct with fmt. Holding
the configuration behind one pointer would not do: for the verbs a
pointer does not take (%s, %q, %t...) fmt reports a bad verb and prints
what the pointer points to.

Every secret of the package's API is now an oidcclient.Secret:
Config.ClientSecret, Config.TransactionKeys ([]Secret), the Binding of
AuthRequest and Callback, the three tokens of Tokens and
AuthOptions.IDTokenHint. NewSecret builds one and Reveal reads it; every
other rendering - every fmt verb, log/slog, encoding/json - is REDACTED
("" when empty). Its value sits behind a pointer to a string, which fmt
renders as an address when it reaches a Secret by reflection, whatever
the verb; encoding/gob refuses a Secret and encoding/xml writes it
empty; it does not decode from JSON. Identity and Tokens consequently no
longer encode their tokens: applications persist them by revealing
them.

BREAKING CHANGE: the fields above change type; see MIGRATION.md.

Audit: F-11.
…back http

Config.InsecureCookies, meant for local development over plain http,
was refused only together with form_post. With a public https
RedirectURL it made the net/http helpers set the binding cookie without
the __Host- prefix and the Secure attribute, so a sibling subdomain, or
a network attacker answering for the site's http origin, could plant
the binding cookie of a login it started itself and send the victim to
the callback with its own code: the victim ended up signed in as the
attacker (login CSRF, RFC 9700 section 4.7; rfc6265bis-22 sections
4.1.3.2 and 8.6).

New now refuses InsecureCookies with ErrInvalidConfig, before any
network access, unless RedirectURL is http - which parseRedirectURL
already accepts on a loopback host only. A plain-http end-to-end test
on 127.0.0.1 keeps the development setup covered.

BREAKING CHANGE: InsecureCookies with an https RedirectURL now fails New.

Audit: F-12.
… set

An empty Config.Algorithms accepted every asymmetric algorithm the
provider listed in id_token_signing_alg_values_supported. OpenID Connect
Core section 3.1.3.7 step 7: "The alg value SHOULD be the default of
RS256 or the algorithm sent by the Client in the
id_token_signed_response_alg parameter during Registration". The
key/algorithm binding already prevented an algorithm substitution, but
the client accepted algorithms it never registered.

An empty Algorithms now means RS256, which every provider supports
(Discovery section 3); a provider signing with another algorithm needs
Config.Algorithms set to the algorithm registered for the client. The
errors name the option: ErrIDTokenAlgorithm at login, ErrInvalidMetadata
at New when the provider does not list RS256. The stack and conformance
tests that exercise other algorithms now configure them, so the ES256
conformance module still exercises ES256 verification.

BREAKING CHANGE: ID Tokens signed with anything but RS256 are refused
unless Config.Algorithms lists the algorithm.

Audit: I-16.
…rameter

RFC 9207 section 2.4: "Clients SHOULD discard authorization responses
with the iss parameter from authorization servers that do not indicate
their support for the parameter", and "MUST reject authorization
responses without the iss parameter from authorization servers that do
support the parameter according to the client's configuration". The
client accepted a matching iss from a provider that did not advertise
authorization_response_iss_parameter_supported, and offered no
configuration to require iss.

Config.RequireIssParameter declares that the provider sends iss in
every response: Complete then refuses a response without it, errors
included. Without it, iss stays required when the provider advertises
it, and a response carrying iss from a provider that does not is now
discarded with ErrIssuerParameter, whose text names the option.

Distinct redirect URIs leave the mix-up gap RFC 9700 section 4.4.2.2
names: the honest provider, sending no iss, answers on the callback of
the attacker's connection. The flag closes it on the connection that
receives the response; an application whose tenants configure
providers sets it on every tenant connection and then works only with
providers that send iss. A stack test runs that attack against an
attacker connection with the flag: the code never reaches the attacker.

BREAKING CHANGE: a provider that sends iss without advertising RFC 9207
fails every login until RequireIssParameter is set.

Audit: I-17, D-13.
oidc/client/storage/memory kept every pending login until it expired
unless the application passed memory.WithCapacity, while login starts
are unauthenticated: a flood of starts grew the store until the process
ran out of memory.

memory.New now keeps at most memory.DefaultCapacity (10 000) live
transactions - about 16 login starts a second over the default
10-minute TransactionTTL, 46 MB at most with 4 KiB AppStates - and,
like a store built with WithCapacity, drops the expired ones then
refuses the next Save with memory.ErrFull, never evicting a pending
login. WithCapacity still sets another bound.

BREAKING CHANGE: a store built without WithCapacity refuses logins
beyond 10 000 pending ones.

Audit: D-12.
An authorization response is not authenticated: whoever learns the
state of a pending login can send the victim's own browser, which
presents the binding cookie on a top-level navigation, to the callback
with it. That request expires the binding cookie and, with an error or
a bogus code, consumes the transaction, so the genuine response fails
and the user starts over. No protocol defense exists; the state must
stay out of logs, yet AuthRequest printed and logged its full URL,
state, nonce and hints included.

String, GoString, Format and LogValue now render the URL without its
query (REDACTED when it does not parse) and the state as REDACTED. The
JSON encoding, the hand-off of the request to a front end, is
unchanged. A cookie-jar characterization test pins the residual, which
the docs now state instead of claiming that a leaked state cannot burn
a login.

Audit: I-14.
The client cached the provider's JWK Set for one hour: a key the
provider withdrew - a leaked one - kept verifying ID Tokens for up to
an hour while the jwks_uri answered, twelve times longer than the jwt
module's remote key sets (DefaultCacheTTL) for the same threat. OpenID
Connect Core section 10.1.1 sets no bound; the cache now lives five
minutes. The 24-hour staleness limit while fetches fail is kept, the
same availability trade-off as jwtsec.MaxKeySetStaleness, and the docs
now state its exact risk: an attacker holding a withdrawn key and able
to make the fetches fail keeps it trusted that long; rebuilding the
Client drops it.

A Redis failover losing a consumption stays documented, like for the
other Redis stores: single use then rests on the provider's single-use
code and on the binding secret, whose cookie the first callback expires.

Audit: D-14.
The client verified ID Tokens through go-jose directly. go-jose
verifies RSASSA-PSS with rsa.PSSSaltLengthAuto, so PS256/384/512
signatures of any salt length verified, while RFC 7518 section 3.5
says "The size of the salt value is the same size as the hash function
output". It also checks an Ed25519 JWK only against a list of weak
encodings: a non-canonical encoding (y not below p), whose decoding RFC
8032 section 5.1.3 makes fail, was used as a key.

PS* signatures now go through a jose.OpaqueVerifier that calls
rsa.VerifyPSS with PSSSaltLengthEqualsHash, the hash serving both the
digest and MGF1; a JWK Set Ed25519 key must be canonical and not of
small order (RFC 8725 section 3.4) or it is ignored. These are the rules
of the jwt module, duplicated since oidc/client does not depend on it.
The ID Token header pre-decoding is untouched.

Audit: I-11, I-12 (oidc/client counterpart).
pssVerifier verified with whichever of PS256, PS384 and PS512 go-jose
handed it. The algorithm comes from the JWS header, the verifier is
chosen from that same header and candidates enforces one algorithm per
key, so the two always agreed; binding the verifier to the algorithm it
was chosen for (RFC 8725 section 3.1), as the jwt module does, makes
that a property of the verifier rather than of its callers.

Audit: I-12 (oidc/client counterpart).
The documentation of oidcclient.Secret overstated its reach and left
out the runtime break of persisted values:

- encoding/gob refuses any value holding a Secret, so a gob-encoded
  session holding an Identity fails to save; YAML writes a Secret
  empty; documents persisted before the upgrade no longer decode.
  MIGRATION gives the rows and a struct-of-strings recipe.
- fmt prints a Secret it reaches through an unexported field as an
  address, not REDACTED, and a reflection dumper that dereferences
  pointers without calling methods (go-spew with DisableMethods, as in
  testify's failure diffs) prints its value; embedding a Secret makes
  the whole struct print REDACTED.
- AuthRequest.URL carries every request parameter in clear, the
  id_token_hint included: its renderings drop the query, its JSON
  hand-off does not. The renderings test now covers the hint.

The DefaultCapacity memory figure also counts acr_values.

Audit: F-11, I-14, D-12.
@euskadi31
euskadi31 force-pushed the fix/audit2-oidc-client branch from 8fdf04f to 2cf2da1 Compare October 21, 2026 20:57
@euskadi31
euskadi31 merged commit 65f32e2 into master Oct 21, 2026
4 checks passed
@euskadi31
euskadi31 deleted the fix/audit2-oidc-client branch October 21, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant