Documentation
¶
Overview ¶
Package oidc adds an OIDC sign-in path alongside the static operator credential and the trusted-proxy header in internal/auth.
Why a separate package ¶
Auth's job is "given a request, produce a user_id and plant a session cookie". That contract is upheld by Login (static creds) and Middleware (header). Adding a third issuer in the same package would couple every code path to the OIDC dependency chain (go-oidc, go-jose, golang.org/x/oauth2). Keeping OIDC out here means a self-host build that doesn't enable OIDC pays zero runtime cost and zero binary-size cost beyond the imports themselves.
The flow ¶
GET /api/auth/oidc/{provider}/start
→ 302 to provider.AuthURL(state) with a state+nonce cookie
GET /api/auth/oidc/{provider}/callback?code&state
→ verify state cookie matches query param
→ exchange code for tokens
→ verify ID token signature + nonce + audience
→ resolve identity → stable UUID via auth's UserIDFor
→ upsert users row → mint Rivolt session cookie
→ 302 back into the SPA (configurable post-login URL)
Identity resolution ¶
We use the verified email claim as the auth username when present (Google + most enterprise IdPs always emit it for sign- in scopes). Falling back to `iss + ":" + sub` keeps the deterministic-UUID contract working even for IdPs that withhold email. The same UUIDv5 namespace is shared with cookie / header auth, so a user who signs in once by password and once by OIDC (same email) lands on the same tenant key — no data fork.
Security checklist (RFC 6749 §10 + OpenID Connect Core §3.1.2) RFC 6749 §10 + OpenID Connect Core §3.1.2)" aria-label="Go to Security checklist (RFC 6749 §10 + OpenID Connect Core §3.1.2)">¶
- state: random 32 bytes, set in an HttpOnly+Secure cookie, compared with constant time at callback. CSRF-class defense.
- nonce: same 32-byte token reused as the OIDC nonce; verified by go-oidc against the ID token claim.
- PKCE: S256, verifier stored in the same state cookie alongside the state value. Defends against authcode interception on hostile networks.
- Cookie scope: the state cookie is per-callback only, SameSite=Lax (Strict would block the cross-site redirect itself), Path=/api/auth/oidc, MaxAge=10m.
Multi-provider ¶
One Service hosts a registry of providers keyed by short name (e.g. "google", "authentik"). The router mounts every provider under /api/auth/oidc/{name}/{start|callback}; a provider that fails to bootstrap (DNS, bad issuer URL) is logged and skipped rather than crashing the whole server.
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrCapExceeded = errors.New("oidc: signup cap exceeded")
ErrCapExceeded is the sentinel CapGuard returns when the signup cap would be exceeded by creating a new user. The callback maps it to a redirect to CapOverflowURL with the email pre-filled.
var ErrUserForbidden = errors.New("oidc: user forbidden")
ErrUserForbidden is the sentinel main returns from EnsureUser when the resolved user row exists but has been admin-disabled. The callback handler maps it to a 403 with a user-facing message; any other error becomes a generic 500.
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
IssueSession func(w http.ResponseWriter, r *http.Request, uid uuid.UUID) error
EnsureUser func(ctx context.Context, username, email, displayName string) (uuid.UUID, error)
UserIDFor func(string) uuid.UUID
PostLoginURL string
SecureCookie bool
Logger *slog.Logger
Providers []ProviderConfig
// CapGuard, when non-nil, gates new-account creation in the
// OIDC callback. Wired to a flags.Store + db.UserExists check
// in main. nil disables the cap (every sign-in proceeds).
CapGuard func(ctx context.Context, username, email string) error
// CapOverflowURL is the SPA path the callback redirects to
// when CapGuard returns ErrCapExceeded. Defaults to
// "/signup/full".
CapOverflowURL string
// ErrorURL is the SPA path the callback redirects to on any
// sign-in failure. Defaults to "/login", which renders the
// error banner inline above the provider buttons.
ErrorURL string
}
Config bundles the wiring main hands to New. Splitting it from ProviderConfig keeps the per-IdP knobs separate from the service-wide identity / cookie wiring.
type ProviderConfig ¶
type ProviderConfig struct {
// Name is the short URL slug — e.g. "google". Lowercased and
// alphanum-only is recommended; we don't enforce it because
// the operator owns their own URL space.
Name string
// Kind selects the wire protocol. "" or "oidc" is the default:
// standard OIDC discovery + ID-token verification. "oauth2-github"
// is GitHub's non-OIDC OAuth2 — no ID token, identity comes from
// the REST /user + /user/emails endpoints. New kinds go behind a
// single switch in buildProvider/handleCallback so the shared
// PKCE, state cookie, and cap-guard plumbing keeps applying.
Kind string
// DisplayName is what the SPA puts on the button. "Google",
// "Authentik", "GitHub". Falls back to Name if empty.
DisplayName string
// IssuerURL is the OIDC discovery root (without
// /.well-known/openid-configuration; go-oidc appends it).
// Examples: "https://accounts.google.com",
// "https://auth.example.com". Ignored when Kind="oauth2-github".
IssuerURL string
// ClientID / ClientSecret are the OAuth2 application
// credentials. ClientSecret may be empty for public clients
// (PKCE-only), though most server-side flows ship one.
ClientID string
ClientSecret string
// RedirectURL is the absolute URL of /api/auth/oidc/{name}/
// callback as the IdP will redirect the browser there. We
// build it once at config time so a misconfigured base URL
// fails fast.
RedirectURL string
// Scopes overrides the default scope set. Most IdPs are happy
// with the default; some (Authentik) need an extra scope to
// emit groups.
Scopes []string
}
ProviderConfig is one IdP's static configuration, supplied by the operator via env. All fields are required except Scopes (which defaults to {"openid","email","profile"}).
func ParseProvidersFromEnv ¶
func ParseProvidersFromEnv(getenv func(string) string, baseURL string) ([]ProviderConfig, error)
ParseProvidersFromEnv reads the operator's env soup and produces a slice of ProviderConfig. Format:
RIVOLT_OIDC_PROVIDERS=google,github,authentik RIVOLT_OIDC_GOOGLE_ISSUER=https://accounts.google.com RIVOLT_OIDC_GOOGLE_CLIENT_ID=... RIVOLT_OIDC_GOOGLE_CLIENT_SECRET=... RIVOLT_OIDC_GOOGLE_DISPLAY_NAME=Google # optional RIVOLT_OIDC_GOOGLE_SCOPES=openid,email,profile # optional RIVOLT_OIDC_GITHUB_KIND=oauth2-github # required for GitHub RIVOLT_OIDC_GITHUB_CLIENT_ID=... RIVOLT_OIDC_GITHUB_CLIENT_SECRET=... RIVOLT_OIDC_GITHUB_DISPLAY_NAME=GitHub # optional
KIND defaults to "oidc". Set KIND=oauth2-github for GitHub (which doesn't issue ID tokens); ISSUER is ignored in that case.
baseURL is the public origin Rivolt is reachable at; we build the redirect URL automatically as {baseURL}/api/auth/oidc/{name}/callback.
Missing or empty RIVOLT_OIDC_PROVIDERS yields an empty slice (and OIDC stays disabled). Invalid per-provider config returns an error so a deploy fails fast rather than silently dropping a provider.
type ProviderListing ¶
type ProviderListing struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
StartURL string `json:"start_url"`
}
ProviderListing is the public shape of a registered IdP.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the OIDC handler set, registered under /api/auth/oidc by api.go. Multiple providers live in one Service.
func New ¶
New builds a Service. Providers that fail to bootstrap (DNS failure, transient discovery error, invalid issuer URL) are logged at WARN and retried in the background with exponential backoff — the rest still come up. That's the right posture for a self-hosted deploy where Rivolt and its IdP (e.g. Hydra) might race at boot: the provider becomes available without an operator restart once discovery succeeds.
func (*Service) Mount ¶
Mount installs the OIDC routes onto r under /oidc:
GET /oidc/{provider}/start
GET /oidc/{provider}/callback
Caller is api.go, which mounts this whole group under /api/auth.
func (*Service) Providers ¶
func (s *Service) Providers() []ProviderListing
Providers returns the registered providers in a JSON-friendly shape — used by the SPA to decide whether to render any social- login buttons. Returning the start URL pre-built keeps the frontend free of routing knowledge.