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.
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
- Architecture
- Quick start
- Endpoints
- Configuration reference
- Flows
- Resource servers
- Client side: logging in with an OpenID Connect provider
- Database setup
- Security checklist
- 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);
kidis 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.
Consent and scopes
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 thestateand used once. - The
id_tokenis verified: signature against the provider's JWKS (refetched once when akidis unknown), algorithm allow-list (RS256 PS256 ES256 ES384by default),iss,aud/azp,exp(1 minute skew),nonce,at_hash. - The userinfo
submust equal the id_tokensub; the RFC 9207issparameter 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
Secureautomatically then. - Persist
SigningKeysand shareCookieSecretacross instances. - Set
InitialAccessTokenunless open dynamic registration is intended. - Use
ManagedRefreshTokensfor public clients; keepRequireConsenton for third-party clients. - Put a
RateLimiterin front oftoken,authorize,deviceandregister. - Only PKCE S256 is accepted; redirect URIs match exactly (except the loopback port).
- Keep
AllowPrivateNetworkFetchoff;jwks_urifetches are SSRF-guarded. - Authenticate callers of
introspectandrevoke(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.