auth

package
v0.18.52 Latest Latest
Warning

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

Go to latest
Published: May 14, 2026 License: MIT Imports: 15 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

This section is empty.

Variables

This section is empty.

Functions

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

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

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

New builds a Service from the supplied Config.

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

Jump to

Keyboard shortcuts

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