Files
ResolveSpec/pkg/security/OAUTH2_SERVER.md
T
Hein 640faeeeaf feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid,
at_hash, signed userinfo, RP-initiated and back-channel logout), managed
refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens,
DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592
registration, RFC 9207 iss, signing keyring with rotation.

State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct
backends, four dialect DDLs, conformance cases).

Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL.

PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse.

Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
2026-10-01 14:42:12 +02:00

16 KiB

OAuth 2.1 / OpenID Connect Authorization Server

OAuthServer turns a DatabaseAuthenticator into a standards-based identity provider, and OIDCConfig / WithOIDC make the same package a relying party for any OpenID Connect provider. This guide covers both. For the older "log in with Google/GitHub" client flow see OAUTH2.md; a runnable end-to-end wiring is in oauth2_full_example.go (ExampleOAuth2FullServer, ExampleOAuth2FullClient).

Every feature beyond the original authorization-code flow is opt-in: the zero value of each OAuthServerConfig option keeps the previous behaviour.

Contents

  1. Architecture
  2. Quick start
  3. Endpoints
  4. Configuration reference
  5. Flows
  6. Resource servers
  7. Client side: logging in with an OpenID Connect provider
  8. Database setup
  9. Security checklist
  10. Not supported

Architecture

 browser / app ──► OAuthServer.HTTPHandler() ──► DatabaseAuthenticator (users, sessions, login)
                          │
                          └────────────────────► lookup.Provider ──► procedure | direct backend
                                                  oauth_clients, oauth_codes, oauth_consents,
                                                  oauth_refresh_tokens, oauth_device_codes,
                                                  oauth_par_requests, oauth_jti
  • State is in the database (clients, codes, consents, rotating refresh tokens, device codes, pushed requests, the replay cache), so any number of instances can serve one issuer.
  • Stateless pieces (the SSO cookie, the login/consent form state, the device and logout state) are HMAC-sealed with CookieSecret. Instances must share it, or share the first signing key it is derived from.
  • Signing keys: RSA or ECDSA (P-256/P-384); kid is the RFC 7638 thumbprint. All keys are published in the JWKS.
  • Access tokens are either the opaque session token (default) or RFC 9068 JWTs. Either way an access-grant record stores scope, client, audience and DPoP binding, so introspection, revocation and logout work for both.

Quick start

auth := security.NewDatabaseAuthenticatorWithOptions(db, security.DatabaseAuthenticatorOptions{})
srv := security.NewOAuthServer(security.OAuthServerConfig{
    Issuer:               "https://auth.example.com",
    PersistClients:       true,
    PersistCodes:         true,
    RequireConsent:       true,
    ManagedRefreshTokens: true,
}, auth)
defer srv.Close()

mux := http.NewServeMux()
mux.Handle("/", srv.HTTPHandler())

Register a first-party client from code (no consent screen), or let clients register themselves with POST /oauth/register:

client, secret, err := srv.RegisterTrustedClient(ctx, security.OAuthServerClient{
    ClientName:   "Admin console",
    RedirectURIs: []string{"https://console.example.com/callback"},
})

Endpoints

Method Path Spec Notes
GET /.well-known/oauth-authorization-server RFC 8414 Also /{path} variants for issuers with a path
GET /.well-known/openid-configuration OIDC Discovery Same document, plus OIDC fields
GET /.well-known/oauth-protected-resource RFC 9728
POST /oauth/register RFC 7591 Needs InitialAccessToken when configured
GET PUT DELETE /oauth/register/{client_id} RFC 7592 registration_access_token as Bearer
POST /oauth/register/{client_id}/rotate-secret RFC 7592
GET POST /oauth/authorize RFC 6749, OIDC Core PKCE S256 required; response_mode query or form_post
POST /oauth/token RFC 6749 authorization_code, refresh_token, client_credentials, device code, token exchange
POST /oauth/par RFC 9126 EnablePAR / RequirePAR
POST /oauth/device_authorization RFC 8628 EnableDeviceFlow
GET POST /oauth/device RFC 8628 Verification page (login, consent)
POST /oauth/revoke RFC 7009 Client authentication required
POST /oauth/introspect RFC 7662 Client authentication required
GET POST /oauth/userinfo OIDC Core Scope-filtered; signed response if the client asks
GET /oauth/jwks.json RFC 7517
GET POST /oauth/logout OIDC RP-Initiated + Back-Channel Logout
GET ProviderCallbackPath Callback of a federated upstream provider

Client authentication methods: client_secret_basic, client_secret_post, private_key_jwt (jwks or jwks_uri), none (public clients).

Configuration reference

Option Default Purpose
Issuer required Public base URL; iss of every token. May contain a path
SigningKeys / SigningKey generated RSA-2048 Persistent keys for multi-instance and restarts; first signs
CookieSecret derived from first key HMAC key of cookie and form state
SSOCookie resolvespec_sso, 8h, Lax, Secure when issuer is https Enables prompt=none, max_age, single sign-on, logout
PersistClients, PersistCodes false Store clients/codes in the DB (needed for several instances)
RequireConsent, ConsentTTL false, 90 days Consent screen for non-first-party clients; remembered per user and client
ScopeDescriptions built-in for the OIDC scopes Text on the consent screen
ManagedRefreshTokens, RefreshTokenTTL false, 30 days Server-issued rotating refresh tokens with reuse detection
JWTAccessTokens, AccessTokenAudience false, ResourceIdentifier RFC 9068 access tokens
AccessTokenTTL, AuthCodeTTL 24h, 2 min
EnableDPoP false RFC 9449 sender-constrained tokens
EnablePAR, RequirePAR, PARTTL false, false, 90s
EnableDeviceFlow, DeviceCodeTTL, DevicePollSeconds false, 10 min, 5
EnableTokenExchange false RFC 8693
ClaimsProvider sub, preferred_username, email Source of profile/email/address/phone/custom claims
SupportedACR none Advertised and accepted acr_values
DisableLogout false Do not serve /oauth/logout
InitialAccessToken none Bearer secret required to register clients
AllowAnonymousIntrospection false Skip client authentication at introspect/revoke
RateLimiter none func(r, endpoint) bool; false answers 429
AllowPrivateNetworkFetch false Allow jwks_uri fetches to private addresses (SSRF guard)
LoginTemplate, ConsentTemplate built-in html/template overrides (OAuthLoginPage, OAuthConsentPage)

Flows

Authorization code with PKCE

GET /oauth/authorize?response_type=code&client_id=ID&redirect_uri=https://app/cb
    &scope=openid%20profile&state=S&nonce=N
    &code_challenge=BASE64URL(SHA256(V))&code_challenge_method=S256

The user signs in (a cookie keeps the session), approves the consent screen if required, and is redirected to redirect_uri?code=…&state=S&iss=<issuer> (RFC 9207; compare iss). Exchange it:

curl -X POST https://auth.example.com/oauth/token \
  -d grant_type=authorization_code -d code=CODE -d redirect_uri=https://app/cb \
  -d client_id=ID -d code_verifier=V

Request parameters: prompt (none, login, consent, select_account), max_age, id_token_hint, login_hint, acr_values, claims, resource (RFC 8707), response_mode=form_post. Once the redirect_uri is validated, errors are returned to the client as redirects (error, state, iss); before that they are shown to the user. prompt=none without a session answers login_required.

The id_token carries iss, sub, aud, exp, iat, nonce, auth_time, acr, amr, sid, at_hash, azp and the claims the granted scopes entitle the client to.

Requested scopes are intersected with the client's AllowedScopes; an empty result is invalid_scope. With RequireConsent (or per client require_consent), a third-party client sees a consent screen unless a stored consent already covers the scopes. A client marked first_party (RegisterTrustedClient) never does. Approval can be remembered for ConsentTTL; a denial redirects with access_denied.

Refresh token rotation

With ManagedRefreshTokens, every refresh returns a new refresh token and invalidates the old one:

curl -X POST …/oauth/token -d grant_type=refresh_token -d refresh_token=R1 -d client_id=ID

Presenting a rotated token again (a stolen copy) answers invalid_grant and revokes the whole family, so the legitimate holder has to sign in again. A refresh may downscope (scope=) but never widen. Confidential clients must authenticate. Request offline_access or allow the refresh_token grant to receive one. Without ManagedRefreshTokens the refresh token of the underlying authenticator is passed through as in earlier versions.

JWT access tokens (RFC 9068)

JWTAccessTokens issues at+jwt tokens with iss sub aud exp iat jti client_id scope (and cnf for DPoP). See Resource servers.

DPoP (RFC 9449)

With EnableDPoP a client sends a DPoP proof header (typ dpop+jwt, htm, htu, iat, jti, public jwk) to the token endpoint. The access and refresh tokens are then bound to the proof key; the response has token_type: DPoP. Use them as Authorization: DPoP <token> plus a proof carrying ath = base64url(SHA256(token)). Proof jtis are single use (replay cache in oauth_jti). A DPoP-bound token is refused as a Bearer token. Set the client's dpop_bound_access_tokens to require proofs.

Pushed authorization requests (RFC 9126)

curl -X POST …/oauth/par -d client_id=ID -d response_type=code … -d code_challenge=…   # → {"request_uri": "urn:ietf:params:oauth:request_uri:…", "expires_in": 90}
GET /oauth/authorize?client_id=ID&request_uri=urn:ietf:params:oauth:request_uri:…

Confidential clients authenticate at the PAR endpoint. A request_uri is single use. RequirePAR rejects plain authorization requests.

Device grant (RFC 8628)

curl -X POST …/oauth/device_authorization -d client_id=ID -d scope=openid
# → device_code, user_code, verification_uri, verification_uri_complete, interval
curl -X POST …/oauth/token -d grant_type=urn:ietf:params:oauth:grant-type:device_code -d device_code=… -d client_id=ID

The user opens verification_uri on another device, signs in, enters the code and approves. The device polls; answers are authorization_pending, slow_down (polled faster than interval), access_denied, expired_token. A code is consumed by the first successful poll.

Token exchange (RFC 8693)

A confidential client holding the urn:ietf:params:oauth:grant-type:token-exchange grant swaps a user's access token for a narrower one (scope, audience/resource). The scope can only shrink; DPoP-bound subject tokens and actor_token are not accepted.

Client credentials

Unchanged: grant_type=client_credentials with client authentication returns a token for the client itself.

Dynamic registration (RFC 7591/7592)

POST /oauth/register accepts redirect_uris (https, loopback http, or a custom scheme; no fragments), grant_types, response_types, token_endpoint_auth_method, scope, jwks / jwks_uri, client_name, client_uri, logo_uri, contacts, post_logout_redirect_uris, backchannel_logout_uri, id_token_signed_response_alg, userinfo_signed_response_alg, dpop_bound_access_tokens. The response includes registration_access_token and registration_client_uri; use them to read, update or delete the client and to rotate its secret. Secrets are stored hashed and shown once. Loopback redirect URIs ignore the port (RFC 8252).

Logout

/oauth/logout?id_token_hint=…&post_logout_redirect_uri=…&state=… ends the SSO session, revokes the session's tokens and refresh families, and redirects to a post_logout_redirect_uri the client registered. Without a hint the user is asked to confirm. Clients with backchannel_logout_uri receive a signed logout_token (best effort, in the background).

Federation

srv.RegisterExternalProvider(auth, "google") lets users sign in through an upstream provider; the server remains the issuer for your clients and your users get the same tokens, consent and logout behaviour. login_hint pre-fills the built-in login form.

Resource servers

claims, err := srv.VerifyAccessToken(ctx, token, security.VerifyAccessTokenOptions{
    Audience: "https://api.example.com",
    Scopes:   []string{"orders:read"},
})

JWT tokens are verified locally against the key set; opaque tokens are looked up. claims holds Subject, UserID, ClientID, Scopes, Audience, JTI and the DPoP key thumbprint. For DPoP-bound tokens also check the proof (verifyDPoP runs on the server's own endpoints; an external API validates the DPoP header and compares DPoPKey). A ready-made middleware is in ExampleOAuth2FullServer. Services in another process can call /oauth/introspect with their client credentials, or verify the JWT with GET /oauth/jwks.json.

Client side (relying party)

WithOIDC discovers the endpoints from {issuer}/.well-known/openid-configuration and registers a provider with these protections switched on:

  • PKCE (S256) and a nonce, both kept with the state and used once.
  • The id_token is verified: signature against the provider's JWKS (refetched once when a kid is unknown), algorithm allow-list (RS256 PS256 ES256 ES384 by default), iss, aud/azp, exp (1 minute skew), nonce, at_hash.
  • The userinfo sub must equal the id_token sub; the RFC 9207 iss parameter must match.
auth.WithOIDC(ctx, security.OIDCConfig{Issuer: "https://auth.example.com", ClientID: id, ClientSecret: secret,
    RedirectURL: "https://app/auth/callback", ProviderName: "company"})

url, _ := auth.OAuth2GetAuthURLWithOptions("company", state, security.OAuth2AuthOptions{Prompt: "login"})
login, err := auth.OAuth2HandleCallbackRequest(ctx, "company", r)   // r is the callback request
logout, _ := auth.OAuth2LogoutURL(ctx, "company", login.Meta["id_token"].(string), "https://app/", "")

OAuth2HandleCallback(ctx, provider, code, state) still works. The raw id_token is returned in LoginResponse.Meta["id_token"] (keep it for the logout hint); OAuth2RefreshToken re-validates a new id_token when the provider returns one. The Google preset validates id_tokens as well; for other providers set Issuer and JWKSURL on OAuth2Config, or use WithOIDC.

Database setup

Apply the schema for your backend (see the lookup section of the README):

  • Postgres procedures: lookup/database_schema.sql
  • Table-only (any dialect, direct mode): lookup/ddl/{postgres,sqlite,mysql,mssql}.sql

The OAuth additions are the oauth_clients.metadata and oauth_codes.extra JSON columns plus the tables oauth_consents, oauth_refresh_tokens, oauth_device_codes, oauth_par_requests and oauth_jti. Existing installs: see breaking_changes.md for the ALTER statements. Purge expired rows of the new tables periodically (expires_at < now).

Security checklist

  • Serve the issuer over HTTPS only; the SSO cookie is Secure automatically then.
  • Persist SigningKeys and share CookieSecret across instances.
  • Set InitialAccessToken unless open dynamic registration is intended.
  • Use ManagedRefreshTokens for public clients; keep RequireConsent on for third-party clients.
  • Put a RateLimiter in front of token, authorize, device and register.
  • Only PKCE S256 is accepted; redirect URIs match exactly (except the loopback port).
  • Keep AllowPrivateNetworkFetch off; jwks_uri fetches are SSRF-guarded.
  • Authenticate callers of introspect and revoke (the default).

Not supported

client_secret_jwt, signed request objects (request / request_uri to a remote JWT), the DPoP server nonce, c_hash, encrypted id_tokens, and actor tokens in token exchange. Discovery does not advertise them.