Skip to content

fix(oauth2)!: harden the authorization server endpoints - #119

Merged
euskadi31 merged 19 commits into
masterfrom
fix/audit-oauth2-endpoints
Oct 13, 2026
Merged

euskadi31 merged 19 commits into
masterfrom
fix/audit-oauth2-endpoints

Conversation

@euskadi31

@euskadi31 euskadi31 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the following findings of the internal security audit of 2026-10-12 (commit c5c2ac3): OAUTH-04, OAUTH-05, OAUTH-06, OAUTH-10, OAUTH-11, STORE-01, STORE-03, OAUTH-I3, OAUTH-I4, OAUTH-I5, OAUTH-I8, OAUTH-I10. It documents OAUTH-I9 and OAUTH-I11, deliberate design choices.

It completes the OAuth2 part of the campaign started by #114 (the fail-closed client policy). #114 covered the client policy; this PR hardens the endpoints themselves: /authorize, /token, /revoke, /introspect and the RFC 8414 metadata. It also fixes the in-memory store. It applies maintainer decision 12 (an opt-in trust hook for error redirects) and the decisions taken for this PR, listed below.

Goal / Problem

  • OAUTH-04. /authorize responses set no anti-framing, caching or referrer header, and these are the login and consent pages the ConsentFunc renders. The canonical example also approved any POST carrying decision=approve. So a framed page or a cross-site form could approve on the user's behalf (RFC 6749 §10.12, §10.13; RFC 9700 §4.16).
  • OAUTH-05. /introspect and /revoke ignored the issuer of the request. Behind an IssuerResolver serving several realms, a client registered in two of them could introspect and revoke one realm's tokens at the other. Without an IntrospectionPolicy, any confidential client (every resource server) could also read the claims of any client's refresh token (RFC 6749 §1.5; RFC 7662 §4; OIDC Core §16.15).
  • OAUTH-06. The invalid_client descriptions told an unknown client from one with a wrong secret, and revealed a client's type and authentication methods. That made /token, /revoke and /introspect a client-enumeration oracle.
  • OAUTH-10. Any registered client could send a user an erroneous or prompt=none request. The server then redirected the user to the client's redirect URI before anyone authenticated: an open redirector (RFC 9700 §4.11.2).
  • OAUTH-11 / STORE-01. Every endpoint read up to 10 MB of form. /authorize echoed any state in its redirects and stored any nonce with the code, which every store persists and the Redis consume script re-encodes. About 100 codes with a 10 MB nonce fill 1 GB of a noeviction Redis, whose writes, revocations included, then fail.
  • STORE-03. The in-memory store kept the raw access and refresh tokens and returned them from its lookups, contrary to the storage contract ("MUST store hashes").
  • OAUTH-I3. Registered redirect URIs were used without validation: relative URIs, URIs with a fragment, javascript:. Their query was re-encoded, and a loopback redirect URI requested with another port was refused, although RFC 8252 §7.3 requires accepting it.
  • OAUTH-I4. The RFC 8414 metadata disagreed with the server: no token response type or fragment mode with the implicit flow, no revocation or introspection auth-method lists (an absent revocation list means client_secret_basic alone), grant types in random map order. It also answered every HTTP method.
  • OAUTH-I5. A redirect answering a POST was a 302, where RFC 9700 §4.12 asks for a 303.
  • OAUTH-I8. A failing ClientStore was answered as an unknown client (invalid_client, which tells a client its credentials are wrong), and a failed token lookup at /introspect reached no hook.
  • OAUTH-I10. DefaultClient.SecretMatches revealed the length of the registered secret through its timing.
  • OAUTH-I9 / OAUTH-I11. Client authentication is not throttled. /revoke answers 200 for another client's token.

Design

ID Resolution
OAUTH-04, I5 Every /authorize response carries X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (added to an existing policy, never replacing it), Cache-Control: no-store and Referrer-Policy: same-origin. A redirect answering a POST is a 303, and a 405 lists Allow: GET, POST. The ConsentFunc godoc states the CSRF contract: protect the request carrying the decision, not the endpoint. examples/oauth2 refuses cross-origin decisions with http.CrossOriginProtection, and its consent form now also re-posts a request sent in the body.
OAUTH-05 A token is introspected and revoked only at the issuer it was issued under; issuers are compared exactly, as the grants do. A token of another issuer answers {"active":false} without consulting the policy, and /revoke leaves it untouched (still 200). Without a policy, a refresh token is disclosed to its own client only. A token recorded without issuer is inactive at a non-empty issuer, but its client can still revoke it.
OAUTH-06 Every failed client authentication answers the same bytes: 401, invalid_client, "client authentication failed", the Basic challenge. The detailed refusal is the cause OnError receives.
OAUTH-10 New opt-in AuthorizeConfig.TrustedRedirect(ctx, Client, redirectURI) bool. For a client it does not trust, an error detected before the resource owner authenticated, or a prompt=none-style refusal (login_required, interaction_required, account_selection_required, consent_required), is a bare page with the error's status, never a redirect. Without the hook, every client is trusted, as before (decision 12).
OAUTH-11, STORE-01 ServerConfig.MaxRequestBytes (DefaultMaxRequestBytes = 64 KiB) bounds the query and the body of the four endpoints; the declared length is checked before the body is read. Over the bound: invalid_request (a bare 400 at /authorize). Every request's nonce is limited to 512 bytes of UTF-8 without NUL (the OP's rule). The state is limited to 4096 bytes of VSCHAR; an invalid one is refused and never echoed. A client_id over 255 bytes is an unknown client and is never looked up. The PKCE parameters of an implicit request are ignored.
STORE-03 The memory store keeps hashes only. The storetest conformance suite now fails any store that returns a raw token or changes the caller's pair.
OAUTH-I3 New ValidateRedirectURI / ErrInvalidRedirectURI, enforced at /authorize with a bare 400. Its rules: absolute; no fragment; for http(s), a host, no userinfo and a port within 1–65535; a "." in a private-use scheme (RFC 8252 §8.4), which also refuses javascript:, data: and urn:ietf:wg:oauth:2.0:oob; no response parameter in the query. The registered query is kept byte for byte, and the implicit flow no longer adds a second fragment. A loopback IP redirect URI accepts any port (RFC 8252 §7.3), and the code is bound to the URI requested.
OAUTH-I4 The metadata reflects the server: the implicit members appear once an implicit AuthorizeHandler is built, grant types are sorted, the revocation list holds the ClientAuth methods and the introspection list the same without none. It answers GET and HEAD only. The OpenID Provider's discovery document publishes the same lists (RFC 9068 §4).
OAUTH-I8 A ClientStore error that does not carry invalid_client is an outage: a bare 500 at /authorize, 500 server_error at /token and /introspect, 503 temporarily_unavailable at /revoke (RFC 7009 §2.2.1), reported to OnError. A failed introspection lookup answers {"active":false} (RFC 7662 §2.1) and is now reported too.
OAUTH-I10 DefaultClient.SecretMatches compares the SHA-256 digests of the two secrets in constant time. The SecretMatcher godoc explains how to keep only a hash at rest.
OAUTH-I9 Documented: security-considerations "Client secrets and guessing" (RFC 6749 §2.3.1, §10.10; RFC 7662 §4), a checklist line, a LIMITATIONS entry.
OAUTH-I11 Documented and pinned: answering a foreign token like an unknown one is a deliberate deviation from RFC 7009 §2.1 (see the notes).

Decisions applied

  • Decision 12 + UD-A: the trust hook above. Its default trusts every client, as before.
  • Q1 (bounds), Q2 (redirect URIs), Q4/UD-B (issuer binding, failures), Q5 (Referrer-Policy: same-origin, not no-referrer: with no-referrer, Chrome sends Origin: null on the consent POST), Q6 (metadata), Q7 (I9 and I10 documentation, plus the length-leak fix), Q8 (303).
  • UD-1 (the code review's F2): the OpenID Provider's authorization endpoint serves no implicit flow. oidcprovider.Provider.AuthorizeHandler panics when AuthorizeConfig.AllowImplicit is set, before the server records the flow, like the server's own misconfiguration panics. RFC 9700 §2.1.2 deprecates the implicit grant. The discovery document and the RFC 8414 metadata therefore stay consistent (RFC 9068 §4). A handler built from Provider.Server() is not checked: the godoc and docs warn never to enable the flow there. A guard would need a new oauth2 hook, which the decision left out.

Breaking changes

Each has a MIGRATION.md section (before/after table and steps):

  • The request bounds: a state or nonce over its bound or of an invalid form is refused, and a request over 64 KiB is refused.
  • /authorize answers a POST with 303 (for clients asserting 302).
  • Registered redirect URIs that ValidateRedirectURI refuses get a bare 400.
  • invalid_client descriptions are constant.
  • A ClientStore reporting "not found" with a plain error now causes a 500; it should return (nil, nil) or ErrInvalidClient.
  • /introspect and /revoke are bound to the request's issuer. Rollout: resource servers must call /introspect, and clients /revoke, at the issuer of the token.
  • Without a policy, refresh tokens introspect for their own client only.
  • The metadata answers GET and HEAD only.
  • Provider.AuthorizeHandler with AllowImplicit panics at construction.

Tests

  • TDD, with the red phase recorded from the audit probes, rewritten in repository style. Every audit probe of this PR stops reproducing.

  • Mutation (run in place, always restored):

    • 126 mutants of my own. All are killed except two equivalent ones: a redundant branch, removed; and the raw-secret comparison, a timing-only change.
    • Three survivors were turned into kills by new tests: the issuer binding under a failing resolver, a truncated digest comparison (pinned with two secrets whose SHA-256 digests share four bytes), and an empty decision re-posted by the example.
    • The independent review's set: 59 mutants, 56 killed. The two non-equivalent survivors are now killed by new rows, and one is equivalent.
  • Fuzz: FuzzLoopbackRedirect and FuzzAuthorizeRedirectTarget. The latter checks that, for every URI ValidateRedirectURI accepts, code, state and iss come back exactly once; 13.3 M executions were clean.

  • The new test doubles are the generated mockery mocks (MockClientStore, MockClient). Hand-written decorators delegating to a real store remain (writeCountingStore, savedCodes, lookupFailingStore): they are not plain doubles.

  • The independent adversarial code review found 0 blocker, 0 major, 5 minor and 7 nits, all resolved in 55c717d..5e98bd3:

    • F1: a registered query ending with ? swallowed the code;
    • F2: UD-1;
    • F3: http(s) URIs without a host, and out-of-range ports;
    • F4 and N1: two surviving mutants of the trust hook;
    • F5: generated mocks;
    • nits on docs, the example and hygiene.

    A focused re-review of the fixes found one more docs-only minor point, RR1: it scoped the implicit-flow statements to Provider.AuthorizeHandler and added the Server() warning. It is fixed in 5e98bd3.

  • Opt-in runs by the reviewer, before the post-review fixes (which change no store code): real Redis 7.4 storage conformance PASS; Tales OIDC browser suite 21 scenarios PASS.

Checks

make generate sync build test lint check-modules on Go 1.27.2 with golangci-lint v2.14.0. All green:

  • lint: 0 issues;
  • check-modules: ok;
  • make sync: no changes;
  • govulncheck: no vulnerabilities in any of the 25 modules. This machine's network blocks the govulncheck binary, so I ran it with -db file:// on a fresh vuln.go.dev snapshot; the CI workflow runs it online.
Package Coverage master
oauth2 99.2 % 97.1 %
oauth2/clientauth 100 % 100 %
oauth2/grant 98.9 % 98.9 %
oauth2/storage/memory 100 % 100 %
oauth2/storetest 98.2 % (concurrency branches vary between runs) 97.9 %
oauth2/token 96.3 % 96.3 %
oauth2/store/sql 96.5 % 96.5 %
oauth2/store/redis 94.7 % 94.7 %
oidc/provider 98.3 % 98.2 %
examples/oauth2 78.6 % (examples are excluded from the bar) 73.2 %

Every new or changed function is at 100 %, except error paths that cannot be reached: the json.Encoder write error after WriteHeader (serveIntrospect, serveMetadata) and the crypto/rand failure.

Follow-ups (out of scope)

  • oidcprovider.MaxNonceLength duplicates oauth2.MaxNonceLength (both 512). Aliasing it is a nit left for later.

Notes for the maintainer

  • Two tests from PR G (merged meanwhile) were adapted.

    • TestPromptIsReadWhole sends a 1 MiB prompt, so it now raises MaxRequestBytes: the prompt has no bound of its own, but the request does.
    • The offline_access subtest of an implicit request needed an implicit-enabled provider, which UD-1 refuses. The provider's code-flow rule is now unit-tested directly instead.

    G's docs on that rule now note that the provider serves no implicit flow.

  • OAUTH-I11. /revoke answers a token of another client, or another issuer, exactly like an unknown token: 200, nothing revoked. RFC 7009 §2.1 says to refuse such a request with an error, but an error would confirm the token is valid for someone else. The gain against probing is small: the caller already holds the token, and /introspect is closed to public clients. Revisit if you prefer the RFC's error.

  • Deploying OAUTH-05. Point every resource server at its realm's /introspect before deploying. A single shared URL now answers {"active":false} for the other realms' tokens.

  • TrustedRedirect is opt-in: set it when third parties register clients.

  • Under Fixed. 8760cde, typed docs(oauth2), also changes DefaultClient.SecretMatches (timing) and adds a test; CHANGELOG lists it under Fixed. Three intermediate commit subjects exceed 72 characters, which the squash makes moot.

The storage contract says stores keep tokens hashed only ("MUST store
hashes"; refresh tokens are "ALWAYS stored hashed"), and the SQL and Redis
stores persist DTOs without the raw value. The in-memory store saved the
AccessToken and RefreshToken structs it was handed as they were, so
LookupAccessToken and LookupRefreshToken returned the raw tokens — after a
rotation too — while SaveAuthorizationCode already cleared the raw code.

persist now stores copies without the raw value and leaves the caller's
pair untouched (the server writes the token response from it). The
storetest conformance suite gains TokensAreNeverStoredRaw — no lookup,
after saving, rotating, for the consumed ancestor or an access token outside
any family, returns a raw token, and the caller's pair keeps its tokens —
and requireContext asserts the same on every lookup it makes: a custom
store keeping raw tokens now fails the suite.

Audit: STORE-03.
Every endpoint parsed its form with net/http's defaults — up to 10 MB of
body — and /authorize echoed any state in its redirects and recorded any
nonce with the code, which every store persists and the Redis consume
script decodes and re-encodes: a 9 MB nonce cost tens of milliseconds of a
blocked Redis per redemption, and about 100 such codes filled 1 GB of a
noeviction Redis, whose writes — family revocations included — then fail.
oidc/provider bounded the nonce of OpenID requests only.

- ServerConfig.MaxRequestBytes (DefaultMaxRequestBytes, 64 KiB, when zero
  or negative) bounds the query and the body of /authorize, /token,
  /revoke and /introspect: a declared Content-Length over it is refused
  before the body is read, a body of unknown length through
  http.MaxBytesReader; the answer is invalid_request "request too large"
  (RFC 6749 §5.2), a bare 400 at /authorize.
- The nonce of every authorization request is at most MaxNonceLength (512)
  bytes of valid UTF-8 without NUL — the rule and text of oidc/provider —
  else a redirected invalid_request: no store is handed more.
- The state is at most MaxStateLength (4096) bytes of VSCHAR (RFC 6749
  App. A.5, §10.14), else a redirected invalid_request that does not echo
  it.
- A client_id over MaxClientIDLength (255) bytes is an unknown client,
  never looked up — at /authorize whatever the resolver did, and by every
  client-authentication method (RFC 6749 §2.2).
- An implicit request records no PKCE parameter, and a
  code_challenge_method without code_challenge is not recorded (RFC 7636
  §4.3): neither reached a store or a callback unbounded any more.

Test fixtures: the scope-syntax test raises the bound so that its 40 000
tokens still reach the scope bounds; the OP's plain-OAuth test no longer
sends a 600-byte nonce, which the core now refuses everywhere.

Audit: OAUTH-11, STORE-01.
The authorization endpoint renders the application's login and consent
pages through the ConsentFunc but set no protective header: the pages
could be framed (clickjacking, RFC 6749 §10.13: the server MUST prevent
it, RFC 9700 §4.16) and cached, and the godoc said nothing about CSRF —
while the canonical example approved any POST carrying decision=approve,
so a cross-site form could approve on the user's behalf (RFC 6749 §10.12:
the server MUST protect its authorization endpoint against CSRF). A POST
was also answered with a 302, which user agents may replay as a POST
carrying the resource owner's credentials (RFC 9700 §4.12 asks for 303).

- Every /authorize response — the ConsentFunc's page, the redirects, the
  bare errors — carries X-Frame-Options: DENY, Content-Security-Policy:
  frame-ancestors 'none' (added, so a policy an outer handler set is
  kept), Cache-Control: no-store and Referrer-Policy: same-origin (RFC
  9700 §4.2.4). same-origin rather than no-referrer: under no-referrer
  browsers send Origin: null on the consent page's own POST, which
  Origin-based CSRF checks refuse. The ConsentFunc may replace them to let
  a trusted origin frame its page. Browsers check framing on the final
  response only: prompt=none in an iframe still follows the redirect
  (checked in headless Chrome).
- A redirect answering a POST is a 303 See Other, a GET keeps 302; a 405
  lists Allow: GET, POST (RFC 9110 §15.5.6).
- The ConsentFunc godoc states the CSRF contract: protect the request
  carrying the decision — a session-bound token or
  net/http.CrossOriginProtection — not the endpoint, to which clients may
  POST authorization requests (RFC 6749 §3.1).
- examples/oauth2 refuses a cross-origin decision with
  http.CrossOriginProtection while a cross-site authorization request
  still gets the consent page.

Audit: OAUTH-04, OAUTH-I5.
The authorization endpoint redirected to whatever a client registered:
relative URIs, URIs with a fragment (the implicit flow then answered
cb#section#access_token=…), javascript:, a query naming the response
parameters. RFC 6749 §3.1.2 requires an absolute URI without fragment
whose query "MUST be retained when adding additional query parameters";
the endpoint instead re-encoded it — reordered, a=1;b=2 dropped (Go
refuses ";"), flag turned into flag=, a registered state or code
overwritten. And exact matching refused a loopback redirect URI whose port
differed from the registered one, which RFC 8252 §7.3 requires ("The
authorization server MUST allow any port to be specified at the time of
the request for loopback IP redirect URIs").

- oauth2.ValidateRedirectURI (wrapping ErrInvalidRedirectURI) checks a
  redirect URI at registration: an absolute URI of RFC 3986 characters with
  valid escapes and no fragment; a host and no userinfo for http(s); for
  any other scheme a "." in it (RFC 8252 §7.1, §8.4), which also refuses
  javascript:, data:, file: and myapp:; a query naming none of code,
  state, iss, error, error_description, error_uri — split on "&" and ";",
  percent-decoded (RFC 6749 §3.1: no parameter twice). /authorize applies
  it to the registered URI a request resolves to: a refusal is a bare 400,
  never redirected (RFC 6749 §3.1.2.4), the cause in OnError.
- A registered http://127.0.0.1 or http://[::1] redirect URI accepts any
  port from 1 to 65535 without leading zero, the URIs byte-for-byte equal
  once the port of each authority is removed (fragment, query, userinfo
  and path included); localhost gets no variance (RFC 8252 §8.3). The code
  is bound to the URI requested, which /token must present.
- Redirects append the response parameters to the registered URI as is.

Audit: OAUTH-I3.
/authorize redirected every protocol error to the client's registered
redirect URI as soon as it was validated — a bad scope, response type or
PKCE parameter, a failing resolver or extension — before the ConsentFunc
ran, so before anyone authenticated. RFC 9700 §4.11.2: a registered client
controlling its redirect URI can thus use the authorization server as a
redirector to a phishing page; "The authorization server MUST always
authenticate the user first … before redirecting the user" and "SHOULD only
automatically redirect the user agent if it trusts the redirection URI".

AuthorizeConfig.TrustedRedirect(ctx, client, redirectURI) decides, for the
errors detected before the resource owner authenticated — every check of
the request, the extension's, a ConsentFunc failure — and for a
Consent.Refusal stating the request cannot complete without a user
interface (login_required, interaction_required,
account_selection_required, consent_required: OpenID Connect Core §3.1.2.6,
the answers to prompt=none, which authenticate nobody — RFC 9700 §4.11.2's
third attack). For a client it does not trust they are a bare error page
(Error.HTTPStatus: 400, 500 for server_error…), reported to OnError like a
redirect; once the ConsentFunc returned any other decision, the code,
access_denied and later failures are redirected for every client. Without
the hook every registered client is trusted, as before. It flows through
oidc/provider, which takes the AuthorizeConfig from the application.

Audit: OAUTH-10.
/token, /revoke and /introspect described why a client authentication
failed: "unknown client" against "client authentication failed" for a
known client with a wrong secret, "none method reserved to public
clients", "method not allowed for client", "client cannot verify secret",
"introspection requires confidential client authentication"… Client
identifiers are not secret (RFC 6749 §2.2), but the answers confirmed which
clients exist and disclosed their type and authentication methods — while
the realm design wanted a client of another issuer to read exactly like an
unknown one.

clientAuthFailed turns every invalid_client of the client authentication —
the shipped methods', a custom authenticator's, the realm check's, the
introspection caller check's — into the same answer: 401 invalid_client,
"client authentication failed", the Basic challenge (RFC 6749 §5.2). The
refusal it replaces is the cause: OnError reads its description with
errors.As, and ErrClientNotAtIssuer still matches. Other errors —
invalid_request for repeated or several credentials, server errors — pass
through unchanged.

Audit: OAUTH-06.
The ClientAuthenticator contract returns "(nil, other) on storage /
unexpected errors", yet the shipped methods wrapped a ClientStore failure
in invalid_client: a database outage answered 401 invalid_client — telling
every client its credentials were wrong — and /authorize answered it as an
unknown client. A token lookup that failed at /introspect answered
{"active":false} without reaching OnError, so an outage looked like a
stream of invalid tokens.

- The ClientStore contract is explicit: (nil, nil) — or an error carrying
  invalid_client — is an unknown client; any other error is a failure.
  clientauth returns it wrapped; /authorize answers a bare 500, /token and
  /introspect a 500 server_error, each reported to OnError.
- /revoke answers 503 temporarily_unavailable (RFC 7009 §2.2.1: the
  revocation did not take place, the client retries) for any failure of
  its client authentication that is not a refused credential — a failing
  ClientStore or IssuerResolver — reported once.
- /introspect still answers {"active":false} for a token whose lookup
  failed (RFC 7662 §2.1: a server unable to determine the state of a token
  SHOULD answer it is not active) and now reports the failure; a lookup
  that merely misses (invalid_grant: unknown, revoked family) is not one.

Audit: OAUTH-I8.
/introspect and /revoke resolved the issuer of the request to
authenticate the client, then ignored it: behind an IssuerResolver
serving several realms, a client registered in two of them could
introspect, and revoke, the tokens of one realm at the other, while the
grants refused them there (OpenID Connect Core §16.15: issuers are
separate realms). Without an IntrospectionPolicy, any confidential
client could also read the claims of any client's refresh token,
although refresh tokens are meant for the authorization server and
their client (RFC 6749 §1.5).

A token whose recorded issuer differs from the request's, compared
exactly as the grants do, now introspects {"active":false} without
consulting the policy, which therefore only sees tokens of the
request's issuer (RFC 7662 §4), and is left untouched by /revoke,
answered 200 as before (RFC 7009 §2.1, §2.2). Without a policy, a
refresh token is disclosed to its own client only; a policy, when set,
decides for every token. A token recorded without issuer is inactive at
a non-empty issuer, but its client can still revoke it: revocation only
takes away.

Breaking for multi-issuer deployments: resource servers call
/introspect, and clients /revoke, at the issuer of the token
(MIGRATION.md).

Audit: OAUTH-05.
The RFC 8414 document disagreed with what the server serves. With the
implicit flow enabled it still listed response_types_supported ["code"]
and response_modes_supported ["query"], and never the implicit grant
type. It omitted revocation_endpoint_auth_methods_supported, whose
absence means client_secret_basic alone (RFC 8414 §2), while /revoke
accepts every ClientAuth method, and
introspection_endpoint_auth_methods_supported. grant_types_supported
came in map order, changing from one response to the next, and any
method was answered 200 although the document is retrieved with GET
(RFC 8414 §3.1).

An AuthorizeHandler enabling the implicit flow now records it on the
server, and the document then adds the token response type, the
fragment response mode and the implicit grant type (RFC 7591 §2). The
revocation list is the ClientAuth methods, the introspection list the
same without "none" (/introspect answers confidential clients only),
omitted when nothing is left; grant types are sorted; methods other
than GET and HEAD get 405 with Allow (RFC 9110 §15.5.6). The godoc
gives the jwks_uri and .well-known locations actually built (RFC 8414
§3).

The oidc/provider consistency test skips the two auth-method lists its
discovery document does not publish yet.

Audit: OAUTH-I4.
…cation

DefaultClient.SecretMatches compared the secrets themselves with
subtle.ConstantTimeCompare, which answers at once when their lengths
differ: the timing disclosed the length of the registered secret. It now
compares their SHA-256 digests, in constant time; what matches is
unchanged.

The rest is documentation of what the server leaves to its deployment:

- Client secrets at rest: DefaultClient keeps the cleartext; production
  clients implement SecretMatcher over a hash, the SHA-256 of a
  generated secret of at least 128 bits (RFC 6749 §10.10) or a password
  hash for human-chosen secrets, with a dummy verification for unknown
  clients.
- Brute force: the server does not throttle client authentication.
  RFC 6749 §2.3.1 asks for brute-force protection and RFC 7662 §4 warns
  against an un-throttled /introspect: rate-limit /token, /revoke and
  /introspect per client_id and source address, and alert on OnError.
- Foreign-token revocation: /revoke answers a token of another client,
  or of another issuer, with the 200 of an invalid token (RFC 7009 §2.2)
  and leaves it alone, where RFC 7009 §2.1 refuses the request with an
  error, which would confirm the token is valid for someone else. The
  test pins that the answer is the one of an unknown token.

LIMITATIONS also lists that a failed introspection lookup answers
inactive (RFC 7662 §2.1), and CLAUDE.md summarizes the endpoint
hardening of this series.

Audit: OAUTH-I9, OAUTH-I10, OAUTH-I11, OAUTH-I8.
…iscovery

The OpenID Provider discovery document, also servable as the RFC 8414
document of its issuer, omitted revocation_endpoint_auth_methods_supported,
whose absence means client_secret_basic alone (RFC 8414 §2) while
/revoke accepts every ClientAuth method, and
introspection_endpoint_auth_methods_supported, and listed its grant
types in configuration order. The oauth2 metadata now publishes both
lists and sorts its grant types: the two documents disagreed, which
RFC 9068 §4 forbids, and their consistency test skipped the two lists.

The discovery document lists the ClientAuth methods for /revoke, the
same without "none" for /introspect (confidential clients only; omitted
when nothing is left), and sorts grant_types_supported, as the oauth2
metadata does. The consistency test compares every member strictly
again, for a server of public clients too, and checks that the two
lists are published or omitted alike.

Audit: OAUTH-I4.
authorizeRedirectTarget chose its separator from the whole redirect
URI: a registered query whose last value ends with "?" (cb?next=/home?)
was taken for an empty query, and the response parameters were glued
to that value. The client read no code and next="/home?code=...", and
with a next URL the code traveled inside a third-party URL. RFC 6749
§4.1.2 adds code to the query component, and §3.1.2 retains the
registered query as it is.

The separator now depends on the query component alone. A fuzz target
pins the property: for every redirect URI ValidateRedirectURI accepts,
code, state and iss come back exactly once, with their values.

Audit: OAUTH-I3.
ValidateRedirectURI tested the authority, not the host: http://:80/cb,
https://:443/cb and http://:/cb passed, and /authorize redirected to
them, although RFC 9110 §4.2.1 and §4.2.2 forbid generating an http(s)
URI with an empty host and require rejecting one. A port outside
1-65535 (http://app.example:99999/cb) passed as well.

The validator now requires a host name and, when a port is present, one
within 1-65535; an empty port stays valid (RFC 3986 §3.2.3). The docs
of the redirect URI rules also name the out-of-band
urn:ietf:wg:oauth:2.0:oob among the registrations the period rule
refuses.

Audit: OAUTH-I3.
Two behaviours of the TrustedRedirect hook were untested, so mutants
changing them survived. An extension refusal carrying a code the
authorization endpoint does not define (invalid_client) must reach an
untrusted client as the bare 500 server_error page the godoc promises,
and the trusted path as a redirected server_error, not as a 401
invalid_client (RFC 6749 §4.1.2.1). A decision a ConsentFunc returns on
a request it modified must count as an authentication: the server_error
is redirected and the hook is not asked.

The ConsentFunc godoc now states the premise the hook relies on: a
decision means the resource owner authenticated; before that, render the
login page again or refuse with an interaction code, since any other
decision, a denial included, is redirected even to an untrusted client
(RFC 9700 §4.11.2).

Audit: OAUTH-10.
A Profile20 provider could enable the legacy implicit flow for plain
OAuth 2.0 requests. The server then advertised response_type token, the
fragment response mode and the implicit grant in its RFC 8414 metadata,
while the OpenID discovery document of the same issuer advertised the
code flow alone: RFC 9068 §4 requires the two publications to be
consistent. RFC 9700 §2.1.2 deprecates the implicit grant, and OpenID
requests could never use it (OIDC Core §3).

Provider.AuthorizeHandler now panics when AuthorizeConfig.AllowImplicit
is set, before the server records the flow, as the server panics on its
own misconfigurations; every response_type=token request then gets
unsupported_response_type. The consistency test covers a Profile20
provider attempting it.

BREAKING CHANGE: a provider configured with AllowImplicit panics at
construction; drop the option and move implicit clients to the
authorization code flow with PKCE (MIGRATION.md).

Audit: OAUTH-I4.
The tests added by this series stubbed oauth2 interfaces by hand, while
generated mockery mocks now serve every interface double: a ClientStore
answering every lookup with an error, a Client unable to verify a
secret, and the bounds environment's client store counting its lookups,
which now records them on a MockClientStore answering from the test
registry, as the client-policy tests already do.

The decorators delegating to a real store (the write-counting and
lookup-failing stores, the code recorder) stay hand-written: they are not
plain doubles. The touched clientauth test file is gofmt-clean again.

Audit: OAUTH-11, OAUTH-06, OAUTH-I8.
…t page

The example's consent form posted the decision to /oauth2/authorize
with the query string it was served at. An authorization request POSTed
with its parameters in the body, which RFC 6749 §3.1 and OpenID Connect
Core §3.1.2.1 allow and the README advertises, rendered a form that
posted no request at all, and the approval failed as an unknown client.
The form now also re-posts the body parameters as hidden fields,
HTML-escaped, except an empty decision, which would precede the
button's.

Docs: ServerConfig.MaxRequestBytes says that a body a middleware parsed
before the handler was read without the bound, and the security
considerations qualify the size of a 4 KiB state once percent-encoded.

Audit: OAUTH-04, OAUTH-11.
The provider re-checks the response type and the nonce of every OpenID
request. The core now refuses those requests first: it bounds the nonce
of every request, and the provider serves no implicit flow, while a
handler built from Server() is not mounted for OpenID requests. So no
request reaches the two refusals any more. They stay as the provider's
own defense in depth and are now tested directly, as the package already
does for its token-response client check.

Audit: STORE-01, OAUTH-I4.
@euskadi31
euskadi31 force-pushed the fix/audit-oauth2-endpoints branch from bff323a to 6636822 Compare October 13, 2026 06:30
The docs said the OpenID Provider never serves the implicit flow, and
that its RFC 8414 metadata never advertises it. But only
Provider.AuthorizeHandler refuses AllowImplicit: a handler built from
Provider.Server().AuthorizeHandler, the path MIGRATION documents for
plain OAuth 2.0 requests approved by another ConsentFunc, still accepts
it. The server then serves the flow to those requests and advertises
it in its RFC 8414 metadata, while the discovery document does not,
which RFC 9068 §4 forbids.

The CHANGELOG, docs/oidc-provider.md, LIMITATIONS and MIGRATION
statements now apply to Provider.AuthorizeHandler. The Provider.Server
and AuthorizeHandler godoc, the provider docs, LIMITATIONS and the
MIGRATION path warn never to enable AllowImplicit on a handler built
from Server(): nothing refuses it there, and RFC 9700 §2.1.2 deprecates
the flow. No code changes, as decided (UD-1): a guard would need a new
oauth2 hook.

Audit: OAUTH-I4.
@euskadi31
euskadi31 merged commit 6772159 into master Oct 13, 2026
2 checks passed
@euskadi31
euskadi31 deleted the fix/audit-oauth2-endpoints branch October 13, 2026 06:58
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