Architecture overview
op.New(...) returns an http.Handler backed by an http.ServeMux. This page walks through what happens between request arrival and response — the packages involved, the order of validation, and the storage seams the embedder controls.
Package layout
op/ ← public API surface (this is what you import)
op/profile/ ← FAPI 2.0 / future profiles
op/feature/ ← PAR / DPoP / mTLS / introspect / revoke / DCR / JAR
op/grant/ ← authorization_code, refresh_token, client_credentials
op/store/ ← Store interface (substores) + contract test suite
op/storeadapter/{inmem,sql,redis,composite}
op/interaction/ ← HTML / JSON driver seam for login UI
internal/ ← cannot be imported externally (Go visibility)
authn/ ← LoginFlow orchestrator, Authenticator runtime
authorizeendpoint, parendpoint, tokenendpoint, userinfo,
introspectendpoint, revokeendpoint, registrationendpoint,
endsession, backchannel
jose, jwks, keys ← signing / verification / key set
jar, dpop, mtls, pkce, sessions
cookie, csrf, cors, httpx, redact, log, metrics
discovery, scoperegistry, timex, i18nThe boundary is enforced structurally: external code cannot reach into internal/. Every embedder-controlled seam (option, store interface, authenticator, audit subscriber) is in op/ or one of its subpackages.
Routes
op.New constructs a *http.ServeMux and mounts one handler per configured path. The defaults, and the package behind each one:
| Path | Handler package | What it serves | Mounted |
|---|---|---|---|
/.well-known/openid-configuration | internal/discovery | the OP's effective configuration as a discovery document | always |
/jwks | internal/jwks | the public half of the signing keyset (plus use=enc keys when configured) | always |
/auth | internal/authorizeendpoint | request parsing, client and redirect_uri validation, the redirect into interaction | when the authorization_code grant is enabled |
/interaction/{uid} | internal/authorizeendpoint + the op/interaction driver | the login and consent ceremony, rendered as HTML or served as JSON to an SPA | with /auth |
/par | internal/parendpoint | pushed authorization requests, returning a one-time request_uri | feature.PAR |
/token | internal/tokenendpoint | client authentication, code and refresh redemption, token issuance | always |
/userinfo | internal/userinfo | claims for the subject behind a presented access token | always |
/revoke | internal/revokeendpoint | RFC 7009 revocation of a refresh or access token | feature.Revoke |
/introspect | internal/introspectendpoint | RFC 7662 token state for an authorized resource server | feature.Introspect |
/end_session | internal/endsession | RP-initiated logout, including the back-channel fan-out | when a session manager is configured |
/register | internal/registrationendpoint | dynamic client registration and the per-client management routes | WithDynamicRegistration |
Back-channel logout has no row of its own because it is not a mounted endpoint. It is an outbound fan-out that /end_session triggers: the OP POSTs a logout token to each RP's registered backchannel_logout_uri. The discovery document advertises only the endpoints that are actually mounted, so a deployment that leaves feature.Introspect off never announces /introspect.
Cross-cutting middleware
Every handler is wrapped by:
| Layer | Source | Role |
|---|---|---|
| CORS | internal/cors | public CORS for discovery and /jwks; strict allow-list for /userinfo, /token, interaction/session JSON surfaces, and mounted protocol endpoints such as /par, /revoke, /introspect, /register, /bc-authorize, /device_authorization, and /end_session |
| Trusted proxy | internal/httpx | resolves real client IP from X-Forwarded-* / Forwarded based on WithTrustedProxies |
| Cookie | internal/cookie | __Host- prefix, AES-256-GCM, SameSite=Lax for session, Strict where compatible |
| CSRF | internal/csrf | double-submit + Origin / Referer check on the consent / logout POST |
These are not optional — they apply structurally regardless of which options the embedder set.
The origin sets are deliberately separate. API CORS is widened only by explicit WithCORSOrigins (with redirect-URI origins included for API compatibility); interaction and end-session ceremony checks allow the issuer origin plus those explicit origins only. A redirect URI's origin never authorizes a cross-origin interaction or logout POST by itself.
Authorize → token lifecycle
The most-trodden path. Roughly:
/par and /end_session follow the same general shape; the sequence-diagram is the canonical happy path.
LoginFlow internals
WithLoginFlow(LoginFlow{...}) is compiled at construction time into an internal pipeline:
LoginFlow {Primary, Rules[], Decider, Risk}
│
▼ (compile)
internal/authn/CompiledLoginFlow
├── Primary → Authenticator (resolves Step descriptor → runtime impl)
├── Rules[] → ordered (When, Then) pairs
├── Decider → optional short-circuit
└── Risk → invoked once at chain start; score is cachedFor each authorize request:
Primary.Beginproduces aninteraction.Step(Prompt or Result).- UI driver (HTML or React) renders the prompt; the user submits.
Primary.Continueadvances to aResultcarrying the boundIdentity.- Orchestrator builds a
LoginContext(subject, scopes, completed steps, cached risk score, ACR values). Deciderruns (if non-nil); a non-Passdecision short-circuits.- Otherwise
Rulesevaluate in order; the first matching rule whoseStep.Kind()is not inCompletedStepsfires. - Loop until no rule fires; the session is then issued.
LoginFlow.Riskis consulted at most once for the chain and its outcome is cached. A provider-levelWithRiskAssessoris the alternative seam; combining it withLoginFlow.Riskis rejected at construction rather than choosing silently.
See Use case: Custom authenticator for how to plug your own factor in via ExternalStep.
Storage seams
The library never reads or writes the embedder's users table directly. It talks to the store.Store interface, which is the union of small substores:
| Substore | What lives there | Adapter notes |
|---|---|---|
Clients | OAuth client registry | typically durable |
Users | subjects + claims | embedder-implemented; commonly maps to existing users table |
AuthorizationCodes | one-shot codes (PKCE challenge, scope) | durable |
RefreshTokens | refresh chains, rotation history | durable |
AccessTokens | JWT id-side / opaque tokens | durable |
OpaqueAccessTokens | opaque AT lookup | durable |
Grants | consented scopes per (user, client) | durable |
GrantRevocations | tombstones for revoked grants | durable |
Sessions | browser session records | volatile-eligible |
Interactions | per-attempt interaction state | volatile-eligible |
ConsumedJTIs | JAR / DPoP jti replay set | volatile-eligible |
PARs | pushed authorization requests | durable when PAR participates in an authorization-code transaction |
IATs / RATs | DCR Initial / Registration Access Tokens | durable |
DeviceCodes | RFC 8628 device-authorization records | durable |
CIBARequests | OpenID Connect CIBA backchannel-authentication records | durable |
Metadata | OP-internal key/value state (e.g. the subject_mode marker) | durable, may be absent (nil) |
Volatile-eligible substores can live in a Redis tier behind the composite adapter. At construction time, the composite adapter requires every Kind to have a route. The seven members of composite.TxClusterKinds must all resolve to the same comparable backend, and that backend must implement store.Transactional:
- authorization codes
- refresh tokens
- grants
- PARs
- JWT access-token registrations
- opaque access tokens
- grant-revocation tombstones
Other kinds may use separate backends. Invalid kinds, missing routes, a split cluster, a non-comparable cluster backend, and a non-transactional anchor are reported as construction errors.
MFA factor stores (EmailOTPStore, TOTPStore, PasskeyStore, RecoveryStore, AuthnLockoutStore) are not substores of store.Store. They are supplied directly to the matching login-flow values (StepEmailOTP.Store, StepTOTP.Store, PrimaryPasskey.Store, StepRecoveryCode.Store, and WithAuthnLockoutStore) when the embedder builds the LoginFlow. In-memory, SQL, and DynamoDB adapters expose these stores with matching accessors. examples/27-durable-mfa-store uses the shipped SQL factor stores with the core OP tables in one database; custom backends implement the same contracts themselves.
See Architecture: storage tiering for production placement guidance.
Discovery document assembly
The discovery handler at /.well-known/openid-configuration builds its document from the OP's effective configuration. Every advertised field is the authoritative answer for what the OP will actually do — there is no drift between discovery and behaviour because:
response_types_supportedis computed fromWithGrants+ the FAPI profile; a client-credentials-only OP advertises an empty list and omits browser authorization/logout surfaces.token_endpoint_auth_methods_supportedis intersected with the FAPI allow-list whenWithProfile(profile.FAPI2Baseline)/FAPI2MessageSigningis active.scopes_supportedis the union of built-in scopes andWithScoperegistrations.ui_locales_supportedis auto-derived from the runtime locale resolver (seed bundles plusWithLocaleadditions) unlessWithDiscoveryMetadata(...).UILocalesSupportedsupplies an explicit non-empty override.code_challenge_methods_supportedis always["S256"]—plainis structurally absent.request_object_signing_alg_values_supportedis the JOSE allow-list (RS256,PS256,ES256,EdDSA).dpop_signing_alg_values_supportedis narrower (ES256,EdDSA,PS256) — see FAQ § DPoP discovery.
Where to read next
- Options reference — every
op.With*in one table, with cross-links into the route table above. - Audit event catalog — what fires from each handler at each stage.
- Custom authenticator — how the orchestrator's pipeline calls into your code.
- Hot/cold storage — how the substore tiering interacts with the volatile / durable boundary above.