Skip to content

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, i18n

The 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:

PathHandler packageWhat it servesMounted
/.well-known/openid-configurationinternal/discoverythe OP's effective configuration as a discovery documentalways
/jwksinternal/jwksthe public half of the signing keyset (plus use=enc keys when configured)always
/authinternal/authorizeendpointrequest parsing, client and redirect_uri validation, the redirect into interactionwhen the authorization_code grant is enabled
/interaction/{uid}internal/authorizeendpoint + the op/interaction driverthe login and consent ceremony, rendered as HTML or served as JSON to an SPAwith /auth
/parinternal/parendpointpushed authorization requests, returning a one-time request_urifeature.PAR
/tokeninternal/tokenendpointclient authentication, code and refresh redemption, token issuancealways
/userinfointernal/userinfoclaims for the subject behind a presented access tokenalways
/revokeinternal/revokeendpointRFC 7009 revocation of a refresh or access tokenfeature.Revoke
/introspectinternal/introspectendpointRFC 7662 token state for an authorized resource serverfeature.Introspect
/end_sessioninternal/endsessionRP-initiated logout, including the back-channel fan-outwhen a session manager is configured
/registerinternal/registrationendpointdynamic client registration and the per-client management routesWithDynamicRegistration

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:

LayerSourceRole
CORSinternal/corspublic 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 proxyinternal/httpxresolves real client IP from X-Forwarded-* / Forwarded based on WithTrustedProxies
Cookieinternal/cookie__Host- prefix, AES-256-GCM, SameSite=Lax for session, Strict where compatible
CSRFinternal/csrfdouble-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:

Authorize → token, with the store and the login flow in view
The happy path from the authorize request to the token response, showing which calls reach the store and which reach the configured login flow. The browser drives the first two phases; the third is a direct back-channel exchange between the RP and the OP.1 · the authorize request2 · interaction3 · redeeming the codeRPyour appUser agentbrowserOPgo-oidc-providerStoreyoursLoginFlowyours1redirect to /authorize?…2GET /authorize3Clients.GetClient · exact-match redirect_uri4PKCE · scope · response_type checks5302 → /interaction/{uid}6POST /interaction/{uid} — login7Begin / Continue — the Step chain8Result — subject · AAL · AMR9200 consent page10POST /interaction/{uid} — consent11AuthorizationCodes.Save — code + PKCE12302 → redirect_uri?code&state&iss13arrives carrying the code14POST /tokengrant_type=authorization_code15AuthorizationCodes.Consumethen verify PKCE and client auth16AccessTokens.Register · RefreshTokens.Save17200 · { access_token, id_token, refresh_token? }
The two rightmost lanes are yours. Every arrow that reaches them is an interface call, which is what makes the storage backend and the login experience replaceable without touching the protocol path in the middle.

/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 cached
LoginFlow — declared once, compiled once, driven per request
What you pass to WithLoginFlow is a specification. It is compiled once into a CompiledLoginFlow, which the orchestrator then drives in a loop for each request: begin the primary factor, prompt, bind an identity, evaluate the decider and the rules, and repeat until no rule fires.declaredcompiled oncedriven per requestLoginFlowwhat you hand to WithLoginFlowPrimarythe first factorRules[]when to ask for moreDecider · RiskAAL policy and signalscompileCompiledLoginFlowinternal/authnprimaryrulesdeciderriskresolved and validated at op.NewPrimary.Begin → Steponce, at the start of the chainprompt → the user submitsone Step at a timeResult binds an Identitysubject · AAL · AMRLoginContext → Decider, then Rulesa rule fires → another StepNo rule firesthe session is issued
Compiling up front is what makes a misconfigured flow a start-up error rather than a runtime one. By the time a request arrives there is nothing left to interpret — only a chain to walk.

For each authorize request:

  1. Primary.Begin produces an interaction.Step (Prompt or Result).
  2. UI driver (HTML or React) renders the prompt; the user submits.
  3. Primary.Continue advances to a Result carrying the bound Identity.
  4. Orchestrator builds a LoginContext (subject, scopes, completed steps, cached risk score, ACR values).
  5. Decider runs (if non-nil); a non-Pass decision short-circuits.
  6. Otherwise Rules evaluate in order; the first matching rule whose Step.Kind() is not in CompletedSteps fires.
  7. Loop until no rule fires; the session is then issued. LoginFlow.Risk is consulted at most once for the chain and its outcome is cached. A provider-level WithRiskAssessor is the alternative seam; combining it with LoginFlow.Risk is 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:

SubstoreWhat lives thereAdapter notes
ClientsOAuth client registrytypically durable
Userssubjects + claimsembedder-implemented; commonly maps to existing users table
AuthorizationCodesone-shot codes (PKCE challenge, scope)durable
RefreshTokensrefresh chains, rotation historydurable
AccessTokensJWT id-side / opaque tokensdurable
OpaqueAccessTokensopaque AT lookupdurable
Grantsconsented scopes per (user, client)durable
GrantRevocationstombstones for revoked grantsdurable
Sessionsbrowser session recordsvolatile-eligible
Interactionsper-attempt interaction statevolatile-eligible
ConsumedJTIsJAR / DPoP jti replay setvolatile-eligible
PARspushed authorization requestsdurable when PAR participates in an authorization-code transaction
IATs / RATsDCR Initial / Registration Access Tokensdurable
DeviceCodesRFC 8628 device-authorization recordsdurable
CIBARequestsOpenID Connect CIBA backchannel-authentication recordsdurable
MetadataOP-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_supported is computed from WithGrants + the FAPI profile; a client-credentials-only OP advertises an empty list and omits browser authorization/logout surfaces.
  • token_endpoint_auth_methods_supported is intersected with the FAPI allow-list when WithProfile(profile.FAPI2Baseline) / FAPI2MessageSigning is active.
  • scopes_supported is the union of built-in scopes and WithScope registrations.
  • ui_locales_supported is auto-derived from the runtime locale resolver (seed bundles plus WithLocale additions) unless WithDiscoveryMetadata(...).UILocalesSupported supplies an explicit non-empty override.
  • code_challenge_methods_supported is always ["S256"]plain is structurally absent.
  • request_object_signing_alg_values_supported is the JOSE allow-list (RS256, PS256, ES256, EdDSA).
  • dpop_signing_alg_values_supported is narrower (ES256, EdDSA, PS256) — see FAQ § DPoP discovery.