Help us shape the future of Go! Take the 2026 Go Developer Survey

auth

package
v0.40.7 Latest Latest
Warning

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

Go to latest
Published: Oct 28, 2026 License: MIT Imports: 18 Imported by: 0

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:

  1. 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.

  2. 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.

  3. 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

View Source
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

View Source
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.

View Source
var ErrTrustedProxyTooBroad = errors.New("RIVOLT_TRUSTED_PROXY_CIDR is network-wide")

ErrTrustedProxyTooBroad is returned by CheckTrustedProxies.

Functions

func BroadTrustedCIDRs added in v0.36.18

func BroadTrustedCIDRs(nets []*net.IPNet) []string

BroadTrustedCIDRs returns the entries of nets that are too wide to be "the proxy": a network of 65,536 or more IPv4 addresses (a prefix of /16 or shorter; /48 or shorter for IPv6), or one that contains an address of this host (so it contains the pod's own network, load balancer and every neighbour). Header SSO believes a user header from any peer inside the list, so such an entry lets anything that can reach the app through that network sign in as anyone. The list should hold the exact proxy address.

func CheckTrustedProxies added in v0.36.18

func CheckTrustedProxies(nets []*net.IPNet, oidcConfigured bool) (broad []string, err error)

CheckTrustedProxies decides whether the server may start with this trusted proxy list. Header SSO believes the user header from every peer in the list, so on an install that also has OIDC sign-in (a public site, reached through an ingress) a network-wide entry means anyone can sign in as any user. Then it refuses: the error names the entries. Without OIDC a broad entry is only worth a warning (the caller logs it); self-hosters who really use header SSO list the exact proxy address.

func ImpersonatorFromContext added in v0.26.0

func ImpersonatorFromContext(ctx context.Context) (uuid.UUID, bool)

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

func IsImpersonating(ctx context.Context) bool

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

func ParseTrustedCIDRs(list string) ([]*net.IPNet, error)

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

func UserFromContext(ctx context.Context) (uuid.UUID, bool)

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 ViaBearer added in v0.38.11

func ViaBearer(ctx context.Context) bool

ViaBearer reports whether the request's user came from an iPhone app bearer token. The admin gate, impersonation and the app sign-in pages refuse such a caller.

func WithImpersonation added in v0.26.0

func WithImpersonation(ctx context.Context, impersonatorID, targetID uuid.UUID) context.Context

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 WithSession added in v0.38.11

func WithSession(ctx context.Context, ref SessionRef) context.Context

WithSession records the request's session row.

func WithUser

func WithUser(ctx context.Context, uid uuid.UUID) context.Context

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

	// OnAuth, when set, is told the method of every authenticated request
	// (cookie, kratos, header, bypass): a metrics hook. No user data.
	OnAuth func(method string)

	// 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

	// KratosHost, when set, restricts the Kratos session issuer to
	// requests whose Host header names it (hostname, case-insensitive,
	// port ignored). It must be the host where Kratos cookies are
	// legitimately issued (RIVOLT_KRATOS_COOKIE_HOST, e.g. rivolt.dev),
	// the SAME value on every install that shares that Kratos, NOT the
	// install's own host: a Kratos cookie scoped to a parent domain is
	// also sent to sibling hosts (preview, demo), and keying on their own
	// host would let them accept it. Empty keeps the old behaviour.
	KratosHost 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 New

func New(cfg Config) (*Service, error)

New builds a Service from the supplied Config.

func (*Service) AcceptBearer added in v0.38.11

func (s *Service) AcceptBearer(on bool) *Service

AcceptBearer turns the iPhone app's bearer tokens on or off (off by default; main turns it on except on the admin deployment). Chainable.

func (*Service) ClearKratosCookie added in v0.39.8

func (s *Service) ClearKratosCookie(w http.ResponseWriter, r *http.Request)

ClearKratosCookie expires the Kratos session cookie (host-only, like Kratos sets it) when the request carries one. The Kratos session behind it is ended separately; this stops the browser sending a dead cookie, and is what a person expects of "Sign out".

func (*Service) Configured

func (s *Service) Configured() bool

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

func (s *Service) HasCredentials(r *http.Request) bool

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

func (s *Service) IssueSession(w http.ResponseWriter, r *http.Request, uid uuid.UUID) error

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

func (s *Service) Middleware(next http.Handler) http.Handler

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

type SessionCreateOpts struct {
	UserAgent   string
	IPAddress   string
	DeviceLabel string
}

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

type SessionInfo struct {
	ID     uuid.UUID
	UserID uuid.UUID
}

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 SessionRef added in v0.38.11

type SessionRef struct {
	ID   uuid.UUID
	Kind string // "web" | "app" (sessions.KindWeb / KindApp)
}

SessionRef names the sessions row behind a request: set for the opaque cookie (Kind "web") and the iPhone app's bearer token (Kind "app"); absent for header, Kratos, bypass and legacy signed-cookie auth, which have no row. The sessions list uses it to mark "current" and to keep the caller's own session on "sign out other devices".

func SessionFromContext added in v0.38.11

func SessionFromContext(ctx context.Context) (SessionRef, bool)

SessionFromContext returns the request's session row, if it has one.

type SessionStore

type SessionStore interface {
	Create(ctx context.Context, userID uuid.UUID, ttl time.Duration, opts SessionCreateOpts) (SessionInfo, string, error)
	// Lookup resolves a browser session cookie (kind web only).
	Lookup(ctx context.Context, rawTokenCookie string) (SessionInfo, error)
	// LookupBearer resolves an iPhone app token (kind app only).
	LookupBearer(ctx context.Context, rawToken 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.

Jump to

Keyboard shortcuts

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