Documentation
¶
Overview ¶
Package oidc adds an OIDC sign-in path alongside the static operator credential 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 ¶
- Variables
- func LinkHash(raw string) []byte
- type AppleLinkIntent
- type AppleLinkIntents
- type AppleLinks
- type Config
- type ProviderConfig
- type ProviderListing
- type Service
- func (s *Service) AppleLinkAvailable() bool
- func (s *Service) AppleRevoke(ctx context.Context, refreshToken string) error
- func (s *Service) BeginAppleLink(w http.ResponseWriter, r *http.Request, uid, sessionID uuid.UUID) (string, error)
- func (s *Service) Mount(r chi.Router)
- func (s *Service) Providers() []ProviderListing
Constants ¶
This section is empty.
Variables ¶
var ErrAppleLinkConflict = errors.New("oidc: Apple identity linked to another account")
ErrAppleLinkConflict is returned by AppleLinks.Link when the identity or the account is already linked elsewhere.
var ErrAppleNotConfigured = errors.New("oidc: no Apple provider ready")
ErrAppleNotConfigured is AppleRevoke's answer when no Apple provider is ready (not configured, or discovery still retrying): the caller queues the revoke for later.
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 ErrSignupDisabled = errors.New("oidc: signups disabled")
ErrSignupDisabled is the sentinel CapGuard returns when RIVOLT_SIGNUP_DISABLED is on and the identity has no account (and isn't the bootstrap admin or the first user of an empty install). The callback redirects to the login page with a clear message.
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 ¶
Types ¶
type AppleLinkIntent ¶ added in v0.40.19
AppleLinkIntent is a spent link request: the account that started it and sha256 of the id of the session it started from.
type AppleLinkIntents ¶ added in v0.40.19
type AppleLinkIntents interface {
Create(ctx context.Context, uid uuid.UUID, stateHash, sessionHash []byte, now time.Time) error
// Consume takes the request for stateHash once; ok=false when there is none or it has expired.
Consume(ctx context.Context, stateHash []byte, now time.Time) (AppleLinkIntent, bool, error)
}
AppleLinkIntents keeps link requests (internal/appleid's table).
type AppleLinks ¶ added in v0.39.3
type AppleLinks interface {
// UsernameBySub returns the account key of the account linked to sub, or ok=false.
UsernameBySub(ctx context.Context, sub string) (username string, ok bool, err error)
// UserBySub returns the account linked to sub, or ok=false (the link flow compares it with the signed-in user).
UserBySub(ctx context.Context, sub string) (uid uuid.UUID, ok bool, err error)
// Link records that uid signs in with sub and keeps refreshToken (may be "") for revocation. A conflict is
// ErrAppleLinkConflict.
Link(ctx context.Context, uid uuid.UUID, sub, refreshToken string) error
}
AppleLinks is how the callback finds and records the account an Apple identity belongs to (internal/appleid).
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, emailVerified bool) (uuid.UUID, error)
// UserExists reports whether an account is keyed by username. Used
// to refuse a sign-in that would take over (or orphan) an account
// keyed by an email the IdP did not verify. nil fails closed.
UserExists func(ctx context.Context, username string) (bool, 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, emailVerified bool) 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
// AppleLinks finds and records Apple identities' accounts. An
// "apple" provider refuses every sign-in without it.
AppleLinks AppleLinks
// AppleLinkIntents and SessionLive turn on "Link Apple to my account"
// (BeginAppleLink and the callback's link mode). Without them, linking
// is not available and every Apple callback is a sign-in.
AppleLinkIntents AppleLinkIntents
SessionLive func(ctx context.Context, uid uuid.UUID, sessionHash []byte) (bool, error)
// LinkObserve, when set, is told each link outcome (the metric's vocabulary).
LinkObserve func(outcome 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
// UsernameKey lets this provider's bare preferred_username (no
// "@") be an account key when two or more providers are
// configured (RIVOLT_OIDC_<NAME>_USERNAME_KEY=true). With a single
// provider it is always allowed, as it has always been.
UsernameKey bool
// 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
// AppleTeamID, AppleKeyID and ApplePrivateKey (the .p8 PEM) sign
// the client secret for Kind="apple" (see apple.go). ClientID is
// the Services ID; ClientSecret is unused.
AppleTeamID string
AppleKeyID string
ApplePrivateKey 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 RIVOLT_OIDC_APPLE_KIND=apple # Sign in with Apple (apple.go) RIVOLT_OIDC_APPLE_CLIENT_ID=... # the Services ID RIVOLT_OIDC_APPLE_TEAM_ID=... RIVOLT_OIDC_APPLE_KEY_ID=... RIVOLT_OIDC_APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----... # the .p8
KIND defaults to "oidc". Set KIND=oauth2-github for GitHub (which doesn't issue ID tokens); ISSUER is ignored in that case. KIND=apple defaults ISSUER to https://appleid.apple.com and needs no CLIENT_SECRET (it is minted from the key).
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) AppleLinkAvailable ¶ added in v0.40.20
AppleLinkAvailable reports whether linking is on here: an Apple provider is ready and the link plumbing (the request store, the session check, the identity store) is wired. The Settings card is shown only when it is.
func (*Service) AppleRevoke ¶ added in v0.39.3
AppleRevoke revokes a Sign in with Apple refresh token (and with it the user's authorization of Rivolt) at Apple's revocation endpoint, with a freshly minted client secret. Apple answering invalid_grant (the token is already revoked or expired) counts as done. Errors never include the token.
func (*Service) BeginAppleLink ¶ added in v0.40.19
func (s *Service) BeginAppleLink(w http.ResponseWriter, r *http.Request, uid, sessionID uuid.UUID) (string, error)
BeginAppleLink starts linking an Apple identity to uid, who is signed in with the session sessionID: it records the intent, sets the Apple state cookie and returns the URL to send the browser to. ErrAppleNotConfigured when linking is not available here.
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.