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

oidc

package
v0.40.21 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 25 Imported by: 0

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

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

func LinkHash added in v0.40.19

func LinkHash(raw string) []byte

LinkHash is the sha256 the intent table keeps of an OAuth state or a session id (internal/appleid.Hash is the same function).

Types

type AppleLinkIntent added in v0.40.19

type AppleLinkIntent struct {
	UserID      uuid.UUID
	SessionHash []byte
}

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

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) AppleLinkAvailable added in v0.40.20

func (s *Service) AppleLinkAvailable() bool

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

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

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