Documentation
¶
Overview ¶
Package auth owns Rivolt's session-level authentication: the /api/auth/* endpoints, the cookie format, and the middleware that resolves an authenticated user into request context.
Issuers, one identity seam ¶
Middleware accepts three wire formats and collapses them onto the same user_id contract:
A trusted upstream proxy (oauth2-proxy, Keycloak gatekeeper, ingress with built-in auth, …) writes X-Forwarded-User / -Email. Rivolt believes the header only if the request's RemoteAddr falls inside a CIDR in Config.TrustedProxyCIDRs; an empty list disables header-based auth entirely.
An OIDC sign-in (mounted under /api/auth/oidc/*, see internal/oidc) terminates by calling Service.IssueSession, which mints the same opaque session cookie used by every other path.
A debug bypass: when Config.BypassUserID is set, Middleware injects that user when no other issuer resolves. Intended for local-dev only — main logs a loud warning at boot.
All three routes hand off to the same Middleware identity contract (UserFromContext(ctx) returns the user UUID); handlers don't know which issuer authenticated the request.
Index ¶
- Constants
- Variables
- func ImpersonatorFromContext(ctx context.Context) (uuid.UUID, bool)
- func IsImpersonating(ctx context.Context) bool
- func ParseTrustedCIDRs(list string) ([]*net.IPNet, error)
- func UserFromContext(ctx context.Context) (uuid.UUID, bool)
- func WithImpersonation(ctx context.Context, impersonatorID, targetID uuid.UUID) context.Context
- func WithUser(ctx context.Context, uid uuid.UUID) context.Context
- type Config
- type Service
- func (s *Service) Configured() bool
- func (s *Service) HasCredentials(r *http.Request) bool
- func (s *Service) IssueSession(w http.ResponseWriter, r *http.Request, uid uuid.UUID) error
- func (s *Service) Logout(w http.ResponseWriter, r *http.Request)
- func (s *Service) Me(w http.ResponseWriter, r *http.Request)
- func (s *Service) Middleware(next http.Handler) http.Handler
- func (s *Service) RequireUser(next http.HandlerFunc) http.HandlerFunc
- func (s *Service) WithSessionStore(store SessionStore) *Service
- type SessionCreateOpts
- type SessionInfo
- type SessionStore
Constants ¶
const AuthErrorHeader = "X-Rivolt-Auth-Error"
AuthErrorHeader carries a stable machine-readable reason on the 401 for an IdP-authenticated but not-provisioned identity.
Variables ¶
var ErrSignupClosed = errors.New("auth: sign-ups are closed for this identity")
ErrSignupClosed is what a Kratos resolver returns for a valid session whose identity has no Rivolt account while sign-ups are disabled. The middleware leaves the request unauthenticated and sets AuthErrorHeader.
Functions ¶
func ImpersonatorFromContext ¶ added in v0.26.0
ImpersonatorFromContext returns the real admin identity behind an impersonated request, and whether the request is impersonated at all. UserFromContext on the same context returns the *target's* id — this is the complementary "who's actually driving" lookup.
func IsImpersonating ¶ added in v0.26.0
IsImpersonating reports whether the request context was produced by WithImpersonation. Used as a defense-in-depth check (on top of the impersonation middleware's own path-based block) anywhere privilege escalation via impersonation must be refused outright, e.g. requireAdminMW.
func ParseTrustedCIDRs ¶
ParseTrustedCIDRs is a convenience for main: turn the comma- separated RIVOLT_TRUSTED_PROXY_CIDR env var into a slice of *net.IPNet suitable for Config.TrustedProxyCIDRs. Empty input returns a nil slice, which disables header-based auth.
func UserFromContext ¶
UserFromContext returns the authenticated user_id, if any, that Middleware stored on the request context. Handlers that require auth should check the ok flag and 401 when it's false.
func WithImpersonation ¶ added in v0.26.0
WithImpersonation swaps the request's active identity from impersonatorID (the real, already-authenticated admin) to targetID, so every downstream call that reads UserFromContext — stores, RLS's app.user_id, handlers — transparently renders the target's data. Building this on top of WithUser means the swap gets the same logging.WithUserID + span-attribute stamping for free; it additionally stamps both ids via logging.WithImpersonation (see that doc for why user_id alone isn't enough for an audit trail) and marks the context as impersonated for IsImpersonating/ImpersonatorFromContext callers (the /api/admin/* no-privilege-chaining guard in particular).
Callers (internal/api's impersonation middleware) are responsible for every authorization decision — admin-only, read-only, target-not-admin, feature flag — before calling this. It performs no checks of its own; it's pure context plumbing.
func WithUser ¶
WithUser returns a copy of ctx with uid stored under this package's private key. Exported for tests (including tests in sibling packages) that want to build an authenticated context without replaying the full cookie / header handshake. The context key itself remains unexported, so only this package's code paths can inject an identity.
Types ¶
type Config ¶
type Config struct {
// CookieSecret is the HMAC key used to sign sessions. Must be
// at least 32 bytes of entropy. If empty, New generates a
// process-local random key — fine for dev, wrong for prod
// because every restart invalidates every session.
CookieSecret []byte
// CookieName is the session cookie name. Defaults to
// "rivolt_session".
CookieName string
// CookieTTL is how long a session is valid. Defaults to 30
// days, short enough that a leaked cookie expires on its own
// but long enough that users don't re-auth weekly.
CookieTTL time.Duration
// SecureCookie marks the cookie Secure so it's only sent over
// HTTPS. Defaults to true; flip to false only for local http
// development.
SecureCookie bool
// TrustedProxyCIDRs is the list of subnets whose
// X-Forwarded-User / X-Forwarded-Email / X-Forwarded-Preferred-
// Username headers are believed. Empty (the default) disables
// header-based auth entirely, which is what self-hosted /
// docker-compose deployments want: the in-app cookie login is
// the only issuer.
//
// In a k8s deployment behind oauth2-proxy or Keycloak gatekeeper,
// set this to the pod-network CIDR (e.g.
// "10.0.0.0/8,127.0.0.1/32") — requests not coming from an
// allowed source are treated as if the header weren't there,
// so a direct client curl with a forged header falls back to
// the cookie path and fails open to "unauthenticated". The
// ingress is still expected to strip inbound X-Forwarded-*
// from the client as a belt-and-braces measure.
TrustedProxyCIDRs []*net.IPNet
// HeaderUser / HeaderEmail are the header names the
// upstream proxy writes the authenticated identity into.
// Defaults follow oauth2-proxy conventions. Most proxies can
// be configured to emit these exact names.
HeaderUser string
HeaderEmail string
// KratosCookieName is the name of the Ory Kratos session cookie
// (default "ory_kratos_session"). When KratosResolver is set, a
// request bearing this cookie is checked against Kratos's
// /sessions/whoami; on success the resolved Rivolt UUID is
// installed in the request context, identical to the cookie
// path. Empty disables the issuer.
KratosCookieName string
// KratosResolver, when non-nil, enables the Kratos session
// issuer. The function receives the cookie value (verbatim)
// and returns the Rivolt user UUID, an "ok" flag (false →
// no active session), and an error for transient failures.
// Wired by main to a closure that calls kratos.Whoami and
// runs the resulting identity through UserIDFor to produce
// the same UUID the cookie / header issuers would.
//
// The resolver is called with the request context so it
// inherits the request's deadline.
KratosResolver func(ctx context.Context, cookieHeader string) (uuid.UUID, bool, error)
// UserIDFor maps a username to the stable tenant UUID. main
// wires this to db.UserIDFor; the indirection keeps the auth
// package free of any direct dependency on the db package.
UserIDFor func(username string) uuid.UUID
// UsernameFor resolves a user UUID back to a display username
// for /api/auth/me. Optional — when nil, Me returns an empty
// username, which the SPA tolerates. Wired by main to a DB
// lookup so OIDC sessions surface the IdP's display name.
UsernameFor func(ctx context.Context, uid uuid.UUID) (string, error)
// RoleFor resolves a user UUID to its role string
// ("user" / "admin"). Optional — when nil, Me omits the
// `role` field, which the SPA treats as "user". main wires
// this to db.RoleFor so /api/admin/* visibility flips on
// without an extra round-trip.
RoleFor func(ctx context.Context, uid uuid.UUID) (string, error)
// DisabledFor reports whether a user has been admin-disabled.
// Optional — when nil the gate is open. Wired by main to
// db.IsDisabled so a flipped flag invalidates every in-flight
// session on the next request without needing to revoke each
// session row by hand. A non-nil error is treated as "fail
// closed": better to bounce the user to /login on a transient
// DB blip than to honor a session for someone who may have
// just been disabled.
DisabledFor func(ctx context.Context, uid uuid.UUID) (bool, error)
// BypassUserID, when non-zero, makes Middleware inject this
// user on every request that doesn't already have an identity
// resolved by header or cookie. Debug / local-dev only —
// effectively disables auth. main logs a loud warning at boot
// and refuses to set this when SecureCookie is true.
BypassUserID uuid.UUID
}
Config is the operator-supplied auth configuration. Built by main from env and handed to New. Keeping it a plain struct — rather than pulling os.Getenv here — makes this package trivially testable.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service wraps the cookie signer and produces the logout/me HTTP handlers plus the authenticating middleware. Sign-in itself lives in sibling packages (internal/oidc) which call IssueSession.
func (*Service) Configured ¶
Configured reports whether the auth Service is wired. Used by the API router to decide whether to mount the auth middleware. Enforcement (whether unauthenticated requests get 401'd) is computed by main from the set of configured issuers.
func (*Service) HasCredentials ¶ added in v0.33.0
HasCredentials reports whether the request presented a session credential (the Rivolt session cookie or the Kratos session cookie), whether or not it resolved to a user. A request with none is simply signed out; one with a credential that did not resolve carries an expired or invalid session.
It looks at the two session cookies only. In trusted-proxy mode a request authenticated by a proxy header that does not resolve to a user carries no cookie, so it reads as "no credential" and /api/auth/me answers 200 {"authenticated": false} instead of 401. The SPA treats both as signed out, so this is harmless; it is noted here so nobody relies on 401-vs-200 to distinguish the two.
func (*Service) IssueSession ¶
IssueSession plants the session cookie for an already-resolved user_id. Used by the OIDC callback (the only sign-in surface in OIDC-only mode); kept as its own method so future issuers (magic-link, dev-shortcut) can reuse the exact same cookie path without duplicating the opaque-session/HMAC-fallback fork.
Wire shape:
- With a sessionStore: a row is created (random token + HMACed row key) and the cookie carries only the raw token.
- Without one: a self-describing HMAC-signed token is signed into the cookie. This branch keeps the legacy cookie-only binaries (and the auth tests that don't spin up a sessions store) working.
Note that we deliberately do not write the response body here — that's the caller's responsibility because the OIDC callback wants to send a 302 redirect.
func (*Service) Logout ¶
func (s *Service) Logout(w http.ResponseWriter, r *http.Request)
Logout clears the session cookie. No-op when there isn't one.
func (*Service) Me ¶
func (s *Service) Me(w http.ResponseWriter, r *http.Request)
Me returns the active user for a session. Handy both for frontend bootstrap (decide whether to render the login page or the app) and for debugging.
func (*Service) Middleware ¶
Middleware resolves the active user from (in order) a trusted proxy header, a signed session cookie, or the debug bypass, and injects the user_id into request context. Requests with none of those fall through with no user set — the individual handler (or RequireUser) decides what to do, so /api/auth/oidc/* (the only sign-in surface in OIDC-only mode) stays reachable to the unauthenticated.
Resolution order is fixed: header wins if and only if the request's RemoteAddr is inside a TrustedProxyCIDR *and* the header is present. This is deliberate:
- Single-tenant install has an empty CIDR list → header check is a no-op, cookie-only, zero footgun.
- K8s install behind oauth2-proxy sets the pod-network CIDR → header wins, and a forged header from outside the allowed network falls back to the cookie path (which fails open to "unauthenticated" for a client without a session).
A header-authenticated request that also carries a cookie honours the header. A mismatched cookie in that case is harmless — we just don't read it.
func (*Service) RequireUser ¶
func (s *Service) RequireUser(next http.HandlerFunc) http.HandlerFunc
RequireUser wraps a handler so only authenticated requests reach it; everything else gets a 401.
func (*Service) WithSessionStore ¶
func (s *Service) WithSessionStore(store SessionStore) *Service
WithSessionStore installs the opaque-session backend. Chainable from New for compact wiring. When unset, auth falls back to the HMAC-signed cookie issued inline.
type SessionCreateOpts ¶
SessionCreateOpts / SessionInfo mirror the sessions package's types so callers don't need to import both. The sessions package's Store satisfies auth.SessionStore via a thin adapter wired in main.go.
type SessionInfo ¶
SessionInfo is the projection auth needs — just the identity fields. Extra metadata (last_seen_at, device list) is out of scope for the auth seam.
type SessionStore ¶
type SessionStore interface {
Create(ctx context.Context, userID uuid.UUID, ttl time.Duration, opts SessionCreateOpts) (SessionInfo, string, error)
Lookup(ctx context.Context, rawTokenCookie string) (SessionInfo, error)
RevokeByToken(ctx context.Context, rawTokenCookie string) error
}
SessionStore is the slice of sessions.Store that auth actually uses, exposed as an interface so tests can stub without a live DB. The concrete implementation lives in internal/sessions.