shared

package
v0.1.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 1, 2026 License: AGPL-3.0 Imports: 24 Imported by: 0

Documentation

Overview

Package shared provides middleware and error helpers used by both the public and admin HTTP servers.

Index

Constants

View Source
const MaxIdentityLen = 254

MaxIdentityLen is the longest submitted identity the lockout will key on: 254 bytes, the RFC 5321 ceiling for an email address.

It exists because the identity is untrusted input that becomes a map key. The request body cap is 64 KB and nothing between the form and here shortens the field, so without this the entry cap would bound the number of entries while each one could hold 64 KB. Callers should reject an over-length identity outright; this is the backstop that keeps the map's memory bound true either way.

Variables

View Source
var ErrorPageTmpl = template.Must(template.New("error").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — Authplane</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
background:#f8fafc;display:flex;justify-content:center;align-items:center;
min-height:100vh;padding:24px;color:#0f172a}
.wrapper{width:100%;max-width:420px}
.logo{text-align:center;margin-bottom:32px}
.logo svg{width:40px;height:40px}
.logo-text{font-size:0.85em;font-weight:600;color:#64748b;letter-spacing:0.05em;
text-transform:uppercase;margin-top:8px}
.card{background:#fff;border-radius:16px;
box-shadow:0 1px 3px rgba(0,0,0,0.04),0 8px 24px rgba(0,0,0,0.06);
padding:40px 36px;border:1px solid #e2e8f0;text-align:center}
.icon{margin-bottom:20px}
.icon svg{width:48px;height:48px}
h1{font-size:1.35em;font-weight:700;margin-bottom:12px;color:#0f172a;letter-spacing:-0.01em}
p{color:#475569;font-size:0.92em;line-height:1.6}
.footer{text-align:center;margin-top:24px;font-size:0.8em;color:#94a3b8}
</style>
</head>
<body>
<div class="wrapper">
<div class="logo">
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="40" height="40" rx="10" fill="#4f46e5"/>
<path d="M12 20.5L17.5 26L28 15" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<div class="logo-text">Authplane</div>
</div>
<div class="card">
<div class="icon">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="20" fill="#fef2f2" stroke="#fecaca" stroke-width="1.5"/>
<path d="M24 16v10" stroke="#dc2626" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="24" cy="32" r="1.5" fill="#dc2626"/>
</svg>
</div>
<h1>{{.Title}}</h1>
<p>{{.Message}}</p>
</div>
<div class="footer">Secured by Authplane</div>
</div>
</body>
</html>`))

ErrorPageTmpl is the shared error page template.

Functions

func CORSMiddleware

func CORSMiddleware(provider output.CORSConfigProvider, logger *slog.Logger) func(http.Handler) http.Handler

CORSMiddleware returns middleware that handles CORS preflight and response headers. Only endpoints that need cross-origin access (token, DCR, discovery, revoke) get CORS headers; login/consent are same-origin only.

The allowed-origins allowlist is resolved from the provider per request rather than snapshotted at construction, so a deployment can vary origin policy per request. Resolution failures fail closed: no CORS headers are emitted for that request, never a fallback to a stale or process-wide list. The provider must be non-nil; api/public wires the static boot default when no alternative is supplied.

A non-nil logger is used to record provider resolution failures at Error — otherwise a persistently failing alternative provider would disable CORS with no signal. The error is never written to the response. A nil logger disables this logging (used by tests that don't assert on it).

func ClaimsFromContext

func ClaimsFromContext(ctx context.Context) (*crypto.AccessTokenClaims, bool)

ClaimsFromContext returns the validated JWT access token claims from the request context.

func ClientIP

func ClientIP(r *http.Request) string

ClientIP extracts the client IP from the request. Uses RemoteAddr only -- X-Forwarded-For is ignored to prevent spoofing.

func CookieMaxAgeSeconds added in v0.1.2

func CookieMaxAgeSeconds(d time.Duration) int

CookieMaxAgeSeconds converts a lifetime to the integer seconds an http.Cookie Max-Age attribute takes, rounding UP so a positive sub-second lifetime never truncates to 0. That matters because http.Cookie treats MaxAge 0 as "omit the attribute" — i.e. a session cookie the browser keeps indefinitely — which is the opposite of the near-immediate expiry the caller asked for.

func DeriveKey added in v0.1.2

func DeriveKey(secret []byte, purpose string) []byte

DeriveKey derives a purpose-specific key from a session secret via HMAC-SHA256. Exported so top-level wiring can derive purpose-bound keys (e.g., to construct a StateCodec from cfg.Session.Secret) before SessionMiddleware is instantiated.

func ExtractAuthToken

func ExtractAuthToken(r *http.Request) (token, scheme string)

ExtractAuthToken extracts the access token from the Authorization header. Supports both "Bearer <token>" and "DPoP <token>" schemes (RFC 9449 §7.1). Returns the raw token and the scheme name ("Bearer" or "DPoP"). Returns empty strings if the header is missing or uses an unsupported scheme.

func ExtractBearerToken

func ExtractBearerToken(r *http.Request) string

ExtractBearerToken returns the token value from an "Authorization: Bearer <token>" header. Returns empty string if the header is missing or malformed.

func ParseSameSite

func ParseSameSite(s string) http.SameSite

ParseSameSite converts a string to http.SameSite.

func RedirectInternal added in v0.1.2

func RedirectInternal(w http.ResponseWriter, r *http.Request, urls output.URLBuilder, path string, code int, logger *slog.Logger)

RedirectInternal issues an HTTP redirect to a path on the authorization server's own URL surface, resolved through urls so the redirect stays correct when the AS is served behind a reverse-proxy mount. At the root the path is unchanged — byte-identical to http.Redirect.

Use ONLY for the AS's own paths (e.g. "/login", "/consent"). External destinations — an OAuth client's redirect_uri, an upstream IdP authorize URL — MUST use http.Redirect directly so they are never resolved.

func RenderTemplate

func RenderTemplate(ctx context.Context, w http.ResponseWriter, status int, tmpl *template.Template, data any)

RenderTemplate executes a template into a buffer and writes the result to w. If the template fails, it writes a plain 500 error instead of partial HTML.

func RequestURL

func RequestURL(r *http.Request) string

RequestURL reconstructs the effective request URL (scheme + host + path) for DPoP htu validation (RFC 9449 §4.3). Respects X-Forwarded-Proto for reverse proxy deployments. Query string and fragment are stripped.

func ResolvePath added in v0.1.2

func ResolvePath(ctx context.Context, urls output.URLBuilder, path string, logger *slog.Logger) string

ResolvePath maps a root-relative authorserver path to the path the AS is served under, via urls.Resolve. It is the single place every consumer (internal redirects, cookies, template actions) resolves a mount path, so the join/slash rule lives in the URLBuilder, once. A nil urls or a resolution error falls back to the unresolved path (root behavior, byte-identical to pre-patch) and warn-logs when a logger is supplied.

func SafeRedirect

func SafeRedirect(path, fallback string) string

SafeRedirect confines a user-supplied redirect target to the authorization server's own origin. It returns path when path is a rooted, same-origin path, and fallback otherwise.

Callers pass values taken straight from a query parameter, a form field, or a decoded OIDC state, and use the result as an HTTP Location. Every call site is reached with a session cookie already set, so a target that escapes this origin hands a freshly authenticated user to whoever chose it.

The layers below are deliberately independent, because the string is interpreted by more than one grammar before it becomes a destination:

  • Control bytes. net/http emits a Location it cannot parse verbatim (url.Parse rejects control bytes, so the normalisation branch is skipped), and hexEscapeNonASCII only escapes bytes >= 0x80. The WHATWG URL parser browsers implement strips TAB, LF and CR before resolving — so a control byte here can shift the authority the browser resolves.
  • Backslash, in the path portion only. url.Parse follows RFC 3986, where "\" is an ordinary path character; the WHATWG URL parser treats it as "/". Delegating to url.Parse alone would accept "/\evil.com". The check stops at the first "?" or "#" because the authority is already resolved by then, and the query is not ours: the post-login target is the whole authorize URL (see the /oauth/authorize login-required branch), so it carries a client's query verbatim, backslashes and all. Rejecting those would strand a user at the fallback after a successful login, with the authorization silently abandoned.
  • A leading "//", whatever follows it. url.Parse reports Host "" for three or more leading slashes — the authority between the second and third is empty — so the parse layer below does not catch them, while WHATWG's special-authority-ignore-slashes state consumes the whole run and takes the next segment as the host. Same shape as the backslash rule: where the two parsers disagree, the explicit check is the one that holds.
  • url.Parse, whose error branch rejects a malformed percent escape in the path or fragment ("/50%off"; RawQuery is not escape-validated). That is this layer's real contribution, and it is a narrowing, so it is stated here rather than left to be discovered. Its IsAbs and Host tests are kept as belt and braces only: given the rejections above, an absolute or authority-bearing target is already caught by the rooted-prefix test below. Do not read them as the reason "https://evil.com" is refused.

Control bytes are checked over the whole string — they break the Location header itself, not just the origin — while the backslash rule is scoped to the region that can still change where the browser goes.

What SafeRedirect returns is not what the browser receives. http.Redirect splits the target at the first "?" and runs path.Clean over everything before it — a fragment included, since a fragment holds no "?". So "/foo://bar" is emitted as "/foo:/bar", "/a#x//y" as "/a#x/y", and "/a#..//..//evil" as "/evil", a fragment rewriting the path it rides on. A query string is preserved verbatim. Callers wanting a target to survive unchanged should keep "//" and dot segments out of its path and fragment; this function decides only whether a target stays on this origin, which path.Clean cannot change.

The layers do not lean on the caller to normalise the result. http.Redirect happens to run path.Clean over a relative Location, which would collapse "///evil.com" on its own, but only on the branch it takes when url.Parse succeeds — and a target this guard exists to stop is frequently one url.Parse rejects, which is the branch where the Location is emitted verbatim instead. A guard whose correctness depends on which branch its consumer happens to take is not one you can reason about.

func SecurityHeaders

func SecurityHeaders(secure bool) func(http.Handler) http.Handler

SecurityHeaders returns middleware that sets standard HTTP security headers.

func UserIDFromContext

func UserIDFromContext(ctx context.Context) (string, bool)

UserIDFromContext returns the authenticated user ID from the request context.

func WriteErrorPage

func WriteErrorPage(w http.ResponseWriter, r *http.Request, status int, title, message string)

WriteErrorPage renders a minimal HTML error page for non-redirectable errors (e.g., invalid client_id or redirect_uri where we must NOT redirect).

func WriteJSON

func WriteJSON(w http.ResponseWriter, status int, v any)

WriteJSON writes a JSON response.

func WriteOAuthError

func WriteOAuthError(w http.ResponseWriter, status int, errCode, description string)

WriteOAuthError writes an RFC 6749 / RFC 9457 hybrid error response. Includes both OAuth error fields and RFC 9457 Problem Details fields.

func WriteOAuthErrorWithConsent

func WriteOAuthErrorWithConsent(w http.ResponseWriter, status int, errCode, description, consentURL string)

WriteOAuthErrorWithConsent writes an OAuth error response that includes a `consent_url` field pointing to the AS consent page for a vault-backed upstream service. Used only by the token endpoint when it detects a *domain.ConsentRequiredError.

: this is now a thin wrapper around WriteOAuthErrorWithConsentAndCause that omits the `cause` field on the wire (caller did not supply one).

func WriteOAuthErrorWithConsentAndCause

func WriteOAuthErrorWithConsentAndCause(w http.ResponseWriter, status int, errCode, description, consentURL, cause string)

WriteOAuthErrorWithConsentAndCause writes an OAuth error response that includes a `consent_url` field and a `cause` sub-discriminator. The cause value is one of domain.CauseConsentMissing / domain.CauseScopeInsufficient; empty omits the field on the wire.

Types

type AuthLockout added in v0.1.2

type AuthLockout struct {
	// contains filtered or unexported fields
}

AuthLockout counts failed authentication attempts and locks the identity that failed them — never the transport address alone.

Keying on the address alone is the defect this replaces: behind the documented reverse-proxy deployment every request presents the proxy's address, so a single lockout key covers every user. The key here is identity + address, so behind a proxy it degrades to identity-only (still correct — one account's failures cannot lock another) and, exposed directly, an attacker cannot lock a victim out at the victim's own address.

This type is deliberately separate from RateLimiter. Throughput limiting and account lockout are different controls with different keys, different lifetimes and different scopes; sharing one middleware is what let a login failure take down JWKS. Keeping them apart makes that mistake unrepresentable.

The tracked set is BOUNDED (see maxTrackedIdentities). The key space belongs to the caller, so a flood of invented identities can fill the map — but at the bound the tracker evicts an unlocked entry rather than refusing the newcomer, so a flood cannot switch the control off for accounts it has not touched. What it can do is reset somebody's partial failure count, and only by refilling the whole map to do it again. Lockouts already in force are never evicted. If every tracked identity is locked there is nothing safe to drop, and the tracker refuses and warns once per sweep.

All methods are safe for concurrent use.

func NewAuthLockout added in v0.1.2

func NewAuthLockout(ctx context.Context, cfg config.RateLimitConfig, logger *slog.Logger) *AuthLockout

NewAuthLockout creates a lockout tracker. The context controls the lifetime of the background cleanup goroutine.

The logger is injected rather than taken from slog's package default: nothing in this repo calls slog.SetDefault, so a package-global slog.Warn lands on the default handler — text, on stderr — while the application logs JSON to stdout. An operator scraping stdout would never see the capacity warning. A nil logger falls back to slog.Default() so a caller that forgets one degrades to a misrouted log line instead of a nil-pointer panic on a security path.

func (*AuthLockout) LockedUntil added in v0.1.2

func (a *AuthLockout) LockedUntil(identity, ip string) (until time.Time, locked bool)

LockedUntil reports whether the identity is currently locked out, and until when. The deadline is returned rather than a bare bool so callers can send a truthful Retry-After instead of a fixed guess.

func (*AuthLockout) RecordFailure added in v0.1.2

func (a *AuthLockout) RecordFailure(identity, ip string) (until time.Time, engaged bool)

RecordFailure counts one failed authentication attempt.

It returns the lockout deadline and whether this call is the one that engaged the lockout. `engaged` is true exactly once per lockout — callers emit the audit event off it, and a lockout that reported itself on every subsequent blocked request would write one event per request for its whole duration.

A third outcome hides behind the same (zero, false) return: if the tracked set is at capacity and this identity is not already in it, the failure is NOT counted — it is dropped, and repeating it will never lock the identity while the flood lasts. A caller cannot tell that apart from "counted, still below the threshold", deliberately: there is nothing useful a request handler could do differently, and branching on it would leak the degraded state to the party causing it. The condition is reported to the operator on the logger instead.

func (*AuthLockout) Reset added in v0.1.2

func (a *AuthLockout) Reset(identity, ip string)

Reset clears accumulated failures after a successful authentication.

Without it a user who mistypes nine times and succeeds on the tenth carries nine failures forward and locks out on their next mistake.

It drops an ACTIVE lockout too, not just accumulated failures. That is only safe because callers check LockedUntil before attempting authentication, so a locked identity never reaches a success path. A caller that validated credentials first would hand an attacker a way to clear their own lockout.

type DPoPJWTConfig

type DPoPJWTConfig struct {
	ProofLifetime time.Duration // max |now - iat| for proof freshness
}

DPoPJWTConfig sets the DPoP proof-freshness window for the JWT middleware. It never enables or disables enforcement: a token carrying cnf.jkt is validated whatever this says, and so is any request using the DPoP authorization scheme.

The zero value means the 60-second default, through NewResourceJWTMiddleware and JWTMiddleware.WithDPoP alike: both ignore a non-positive ProofLifetime rather than storing it. They have to agree — a zero window stored verbatim rejects every proof while the challenge still advertises DPoP, and the same value meaning opposite things across two public entry points is the trap this type's documentation once set.

type DPoPProofStore

type DPoPProofStore interface {
	ConsumeJTI(ctx context.Context, jti string, expiry time.Time) error
}

DPoPProofStore records consumed DPoP proof JTIs for replay detection at the resource server. Mirrors output.DPoPNonceStore.ConsumeJTI so the existing SQLite/Postgres adapters can be passed in by the composition root.

type JWKSProvider

type JWKSProvider interface {
	BuildJWKS(ctx context.Context) (*jose.JSONWebKeySet, error)
}

JWKSProvider provides the JWKS key set for JWT verification.

type JWTMiddleware

type JWTMiddleware struct {
	// contains filtered or unexported fields
}

JWTMiddleware validates Bearer and DPoP JWT access tokens and injects the claims into the request context. DPoP is not opt-in: a token carrying cnf.jkt always requires a valid proof, and so does any request presented under the DPoP authorization scheme.

func NewJWTMiddleware deprecated

func NewJWTMiddleware(jwks JWKSProvider, issuerProvider output.IssuerProvider, obs *observability.Provider) *JWTMiddleware

NewJWTMiddleware creates a JWT validation middleware that verifies a token's signature against the issuer's JWKS, its `iss` claim and its expiry — and, for DPoP-bound tokens, the proof itself: the DPoP scheme, `htm`/`htu`/`ath`, freshness within the proof-lifetime window (60s unless WithDPoP sets it), and the `cnf.jkt` binding. It does not apply the two relational controls: whether the token was minted for this resource, and whether this proof has been seen before.

Reaching for this shorter signature is precisely the audience-confusion and DPoP-replay footgun that NewResourceJWTMiddleware exists to make impossible; it remains only for callers that have deliberately established both controls elsewhere.

Deprecated: NewJWTMiddleware provides NO audience isolation and NO DPoP proof-replay protection — it accepts any token the issuer signed, including one minted for a different resource server, and never consumes DPoP proof JTIs, leaving a captured proof replayable against the same method and URL for its full lifetime. Use NewResourceJWTMiddleware, which requires the resource URI (RFC 8707) and a proof store and panics at construction if either is missing.

func NewResourceJWTMiddleware

func NewResourceJWTMiddleware(
	jwks JWKSProvider,
	issuerProvider output.IssuerProvider,
	audience string,
	proofStore DPoPProofStore,
	dpopCfg DPoPJWTConfig,
	obs *observability.Provider,
) *JWTMiddleware

NewResourceJWTMiddleware creates a JWT validation middleware configured for a specific resource server. audience MUST be the resource URI (RFC 8707) and proofStore MUST be a replay store for DPoP proof JTIs — both are required because forgetting either re-opens the audience-confusion and DPoP-replay classes of bug, respectively. dpopCfg sets the proof-freshness window, and nothing else: the zero value leaves DPoP validation fully on at the 60s default, because a token carrying cnf.jkt is validated whatever dpopCfg says. DPoP enforcement is token-intrinsic and cannot be turned off from this constructor — see the crypto.IsDPoPBound call site in Wrap.

Every 401 advertises the schemes that would actually work — both, or DPoP alone once the token is known to be bound — regardless of dpopCfg, for the same reason: this resource always accepts, and for a bound token always requires, DPoP (RFC 9449 §7.1).

Panics if audience or proofStore is empty/nil — the failure mode of "I shipped a resource server with audience=\"\"" is precisely what this constructor exists to make impossible.

func (*JWTMiddleware) WithAudience

func (m *JWTMiddleware) WithAudience(aud string) *JWTMiddleware

WithAudience configures the expected audience (resource URI) for tokens accepted by this middleware. Once set, tokens whose `aud` claim does not contain `aud` are rejected with 401. Required for every resource-server deployment of this middleware (RFC 9068 §4, RFC 8707).

func (*JWTMiddleware) WithDPoP

func (m *JWTMiddleware) WithDPoP(cfg DPoPJWTConfig) *JWTMiddleware

WithDPoP sets the DPoP proof-freshness window. It does not switch DPoP validation on, and it does not change what a 401 advertises: a token carrying cnf.jkt is validated either way, and the challenge names DPoP either way.

A non-positive ProofLifetime means "use the 60s default", exactly as in NewResourceJWTMiddleware. The two must agree: storing a zero window verbatim would reject every proof while the challenge still advertised DPoP, leaving a compliant client that sends a perfectly fresh proof in a permanent 401 — and it would give the same zero value opposite meanings across the two public entry points third parties build on.

func (*JWTMiddleware) WithDPoPProofStore

func (m *JWTMiddleware) WithDPoPProofStore(s DPoPProofStore) *JWTMiddleware

WithDPoPProofStore enables DPoP proof-JTI replay detection at the resource server. Without a store the middleware verifies the proof's structure and binding but does not consume the JTI, leaving a replay window equal to ProofLifetime. Required for any resource exposed to network attackers.

func (*JWTMiddleware) Wrap

func (m *JWTMiddleware) Wrap(next http.Handler) http.Handler

Wrap returns an http.Handler that validates the Bearer or DPoP JWT. On success, injects *crypto.AccessTokenClaims into context. On failure, returns 401 with WWW-Authenticate header.

type OAuthErrorResponse

type OAuthErrorResponse struct {
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description"`
	Type             string `json:"type"`
	Title            string `json:"title"`
	Detail           string `json:"detail"`
	Status           int    `json:"status"`
	// ConsentURL is a URL to a consent page where the user can authorize the
	// AS to access an upstream provider. Populated only by
	// WriteOAuthErrorWithConsent / WriteOAuthErrorWithConsentAndCause; omitted
	// from all other error responses.
	ConsentURL string `json:"consent_url,omitempty"`
	// Cause is a sub-discriminator for consent_required errors.
	// Values currently in use: "consent_missing" (user never authorized this
	// agent for this resource), "scope_insufficient" (user authorized a
	// strict subset of the requested scopes). Empty for non-consent errors
	// and for legacy consent errors that predate the field. Wire format is
	// `cause` (lowercase, omitempty).
	Cause string `json:"cause,omitempty"`
}

OAuthErrorResponse is the combined OAuth + RFC 9457 error response.

type RateLimiter

type RateLimiter struct {
	// contains filtered or unexported fields
}

RateLimiter provides per-IP request throughput limiting.

It deliberately does NOT handle auth-failure lockout — see AuthLockout. Throughput is a property of the connection and applies to every public endpoint; a lockout is a property of an account and belongs on the authentication route only. Conflating them is what let ten failed logins return 429 from JWKS.

func NewRateLimiter

func NewRateLimiter(ctx context.Context, cfg config.RateLimitConfig) *RateLimiter

NewRateLimiter creates a new rate limiter from the given config. The provided context controls the lifetime of the background cleanup goroutine.

func (*RateLimiter) Middleware

func (rl *RateLimiter) Middleware(next http.Handler) http.Handler

Middleware wraps an HTTP handler with rate limiting.

type SessionMiddleware

type SessionMiddleware struct {
	CookieName string
	// contains filtered or unexported fields
}

SessionMiddleware manages HMAC-signed stateless session cookies. Cookie format: userID|expiryUnix|base64(hmac-sha256(userID|expiryUnix))

func NewSessionMiddleware

func NewSessionMiddleware(secretProvider output.SessionSecretProvider, cfgProvider output.SessionConfigProvider, cookieName string, secureFloor bool) *SessionMiddleware

NewSessionMiddleware creates a new session middleware. secretProvider supplies the HMAC secret per request; cfgProvider supplies the cookie policy (MaxAge/Secure/SameSite/FailClosed) per request. cookieName and secureFloor are boot-time. secureFloor is the deployment's Secure (HTTPS) posture: cookies are issued with Secure = provider.Secure || secureFloor, so an alternative provider can only *tighten* Secure, never downgrade an HTTPS/HSTS deployment. It is a constructor arg rather than a setter precisely because forgetting it would silently lose the floor — a security-relevant default.

func (*SessionMiddleware) CSRFToken

func (m *SessionMiddleware) CSRFToken(ctx context.Context, cookieValue string) (string, error)

CSRFToken generates a CSRF token from the session cookie value. Token = base64(hmac-sha256(cookieValue, secret+"csrf")). It returns an error only when the session secret cannot be resolved.

func (*SessionMiddleware) ClearSessionCookie

func (m *SessionMiddleware) ClearSessionCookie(ctx context.Context, w http.ResponseWriter)

ClearSessionCookie expires the session cookie. It resolves the policy best-effort: the delete's SameSite must match the set so the browser accepts it in a cross-site context (a same_site=none embedded deployment where logout is a cross-site fetch — a hardcoded Lax delete would be dropped), but a resolution failure must never wedge logout, so it falls back to a safe Lax.

func (*SessionMiddleware) CookiePolicy added in v0.1.2

func (m *SessionMiddleware) CookiePolicy(ctx context.Context) (output.SessionConfig, error)

CookiePolicy resolves the per-request session-cookie policy. The OIDC handler uses it so the state cookie inherits the same Secure/SameSite as the session cookie. Returns errConfigUnavailable-tagged errors on resolution failure.

func (*SessionMiddleware) MACFor added in v0.1.2

func (m *SessionMiddleware) MACFor(ctx context.Context, purpose, input string) (string, error)

MACFor returns base64(HMAC(DeriveKey(secret, purpose), input)) over the per-request session secret.

purpose domain-separates callers: a MAC minted under one purpose never verifies under another, and none of them collide with the CSRF-token namespace (secret||"csrf"). That matters for any caller that signs input it does not fully control — without separation, such a caller would be a signing oracle for every other consumer of the same key.

The error is non-nil only when the session secret cannot be resolved; callers MUST surface that as a 500 rather than treat it as a verification failure.

func (*SessionMiddleware) SecureFloor added in v0.1.2

func (m *SessionMiddleware) SecureFloor() bool

SecureFloor reports the boot Secure floor so other cookie writers (the OIDC state cookie) can apply the same "provider may only tighten" clamp.

func (*SessionMiddleware) SetLogger

func (m *SessionMiddleware) SetLogger(l *slog.Logger)

SetLogger installs a logger used to record rejected session cookies (see the reason constants above) and transient-error events. If unset, those events are silent.

func (*SessionMiddleware) SetSessionCookie

func (m *SessionMiddleware) SetSessionCookie(ctx context.Context, w http.ResponseWriter, userID string) error

SetSessionCookie creates and sets an HMAC-signed session cookie. It returns an error only when the session secret cannot be resolved; callers MUST surface that as a 500 rather than proceed without a signed cookie.

func (*SessionMiddleware) SetURLBuilder added in v0.1.2

func (m *SessionMiddleware) SetURLBuilder(u output.URLBuilder)

SetURLBuilder installs the URLBuilder used to scope the session cookie's Path to the mount the AS is served under. When nil (the default), the cookie Path is "/" — byte-identical to the pre-patch behavior.

func (*SessionMiddleware) SetUserStore

func (m *SessionMiddleware) SetUserStore(u output.UserStore)

SetUserStore installs the UserStore that Wrap consults to reject cookies naming a deleted or disabled user. Pass the cache-fronted store (storage.WithUserCache), or this becomes a DB query per request.

Required in production: /authorize and /consent perform no user check of their own, so this is the only thing that makes a disable take effect on the front channel. When nil (the default) Wrap accepts any cookie that passes HMAC and expiry, which some tests rely on.

func (*SessionMiddleware) ValidateCSRF

func (m *SessionMiddleware) ValidateCSRF(ctx context.Context, cookieValue, token string) (bool, error)

ValidateCSRF checks that the CSRF token matches the cookie value. The error is non-nil only when the session secret cannot be resolved; callers MUST treat that as a 500, distinct from a (false, nil) mismatch.

func (*SessionMiddleware) VerifyMAC added in v0.1.2

func (m *SessionMiddleware) VerifyMAC(ctx context.Context, purpose, input, mac string) (bool, error)

VerifyMAC recomputes MACFor and compares in constant time.

(false, nil) means the MAC does not match. A non-nil error means the secret could not be resolved and is FATAL. Never collapse the two: treating a resolution failure as a mismatch turns a provider outage into "this value is not ours" for every caller, silently and with no operator signal.

func (*SessionMiddleware) Wrap

func (m *SessionMiddleware) Wrap(next http.Handler) http.Handler

Wrap returns middleware that extracts the user ID from the session cookie.

With a UserStore installed it also checks that the user still resolves and is still active. A cookie naming a deleted or disabled user is cleared and the request continues anonymously, so downstream handlers behave as if no cookie were present. Without a store, neither check runs — see SetUserStore.

Two things to know before relying on this for revocation:

  • With the expected cache-fronted store it is bounded by the cache TTL, not immediate — on every instance, including the one serving the change, and for deletes as well as disables. See WrapUserStore in internal/adapters/storage for the two windows and why they differ. With a raw store the check is immediate and there is no TTL.

  • A transient store error is resolved by SessionConfig.FailClosed, which defaults to true: the cookie is cleared and the request continues anonymously, so a disabled user cannot ride out a DB outage. Set it to false to keep the session instead, when a blip logging everyone out is worse than a stale session surviving one.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL