oidc

package
v0.40.4 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: MIT Imports: 25 Imported by: 0

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

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

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

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

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

View Source
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 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)
	// 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
}

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

func New(ctx context.Context, cfg Config) (*Service, error)

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) AppleRevoke added in v0.39.3

func (s *Service) AppleRevoke(ctx context.Context, refreshToken string) error

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

func (s *Service) Mount(r chi.Router)

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.

Jump to

Keyboard shortcuts

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