oidc

package
v1.11.0 Latest Latest
Warning

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

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Package oidc owns the SSO connectors: Google, GitHub, and a generic OIDC discovery-based provider. Each connector exposes the same Connector interface so the server's auth_routes can iterate uniformly.

State + PKCE storage is intentionally minimal — we hold a small in-memory map keyed by the random state token; the timeout is short (10 minutes) and the store is per-process. Multi-replica deployments can swap in a Mongo-backed store later.

Index

Constants

View Source
const StatesCollectionName = "oidc_states"

StatesCollectionName is the Mongo collection backing PendingAuth records.

Variables

View Source
var (
	ErrUnknownProvider  = errors.New("oidc: unknown provider")
	ErrProviderDisabled = errors.New("oidc: provider is disabled")
	ErrStateNotFound    = errors.New("oidc: state expired or unknown")
	ErrEmailMissing     = errors.New("oidc: provider returned no email")
	ErrEmailNotVerified = errors.New("oidc: provider returned an unverified email")
)

Sentinel errors raised by connectors. The HTTP layer maps them.

Functions

func GenerateStateAndPKCE

func GenerateStateAndPKCE() (state, verifier, challenge string, err error)

GenerateStateAndPKCE returns a random state value (~32 bytes base64url) and a PKCE pair (verifier + S256 challenge).

func SafeGenericClient

func SafeGenericClient(strict bool) *http.Client

SafeGenericClient builds the SSRF-guarded HTTP client a per-org connector should use. Exposed so the server can construct the client at resolve time without importing the transport details.

Types

type Connector

type Connector interface {
	// Name returns the URL slug for the provider (e.g. "google").
	Name() string

	// Display returns the name shown on the SPA login button.
	Display() string

	// AuthorizeURL builds the URL the user is redirected to. The
	// returned state value MUST be passed back in the callback;
	// callers persist it for verification.
	AuthorizeURL(ctx context.Context, redirectURI, state, codeVerifier string) (string, error)

	// ExchangeCode trades the authorization code for an access
	// token (and ID token where applicable), then fetches the
	// external user profile. Returns the canonical ExternalUser.
	ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)

	// SupportsPKCE reports whether the provider's authorize call
	// must include a PKCE challenge. Modern providers all do; we
	// gate it so a stub provider in tests can opt out.
	SupportsPKCE() bool
}

Connector is the per-provider façade. The server obtains a Connector for each enabled provider at startup and routes /auth/oidc/<name>/start and /auth/oidc/<name>/callback through it.

type ExternalUser

type ExternalUser struct {
	Provider string
	Subject  string
	Email    string
	Name     string
	// Groups is the provider-side group/team membership of the user, used by
	// the per-org grant logic. GitHub populates lowercased "<org>/*" (one per
	// org the user belongs to) + "<org>/<team-slug>" (one per team). Empty for
	// providers that don't surface groups (Google). A future OIDC group-claim
	// mapping can populate the same field.
	Groups []string
}

ExternalUser is the post-exchange identity returned by every connector. The Subject is the provider's stable per-account identifier; we never use Email as a key because providers let users change their email.

type GenericConnector

type GenericConnector struct {
	// contains filtered or unexported fields
}

GenericConnector implements Connector against any OpenID Connect- compliant provider via the discovery document. It fetches the .well-known/openid-configuration on first use and caches it for discoveryTTL (so a per-flow connector pays discovery once, and a long-lived deployment connector refreshes endpoints/keys instead of caching them forever across an IdP rotation).

We do not validate the ID token signature here: the userinfo endpoint is authenticated with the just-minted access token, and the token endpoint already authenticated the request via client_secret_basic. JWKS-backed ID-token verification is a planned hardening (the prerequisite for safe auto-link) — see docs/cloud-admin.md.

SSRF: when constructed for a per-org (org-admin-supplied) issuer, the connector is handed an httpdial.SafeClient whose transport pins every dial to a validated public-unicast IP — so discovery AND the token/userinfo endpoints discovered from the doc are all guarded (second-order SSRF closed). When strict, the discovered endpoints are additionally required to be https and the doc's issuer must match the configured issuer.

func NewGenericConnector

func NewGenericConnector(issuerURL, clientID, clientSecret, display string, scopes []string) *GenericConnector

NewGenericConnector returns a discovery-based connector under the fixed slug "sso" using a plain HTTP client (back-compat: the deployment-global, operator-configured generic provider). Per-org Keycloak rows use NewGenericConnectorWithSlug with an SSRF-guarded client.

func NewGenericConnectorWithSlug

func NewGenericConnectorWithSlug(slug, issuerURL, clientID, clientSecret, display string, scopes []string, client *http.Client, strict bool) *GenericConnector

NewGenericConnectorWithSlug returns a discovery-based connector under the supplied slug. client, when non-nil, overrides the default HTTP client — the server passes httpdial.SafeClient(strict, …) for per-org issuers so every dial is SSRF-guarded. strict additionally requires discovered endpoints to be https (the issuer-match check runs regardless of strict).

func (*GenericConnector) AuthorizeURL

func (c *GenericConnector) AuthorizeURL(ctx context.Context, redirectURI, state, codeVerifier string) (string, error)

func (*GenericConnector) Display

func (c *GenericConnector) Display() string

func (*GenericConnector) ExchangeCode

func (c *GenericConnector) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)

func (*GenericConnector) Name

func (c *GenericConnector) Name() string

func (*GenericConnector) SupportsPKCE

func (c *GenericConnector) SupportsPKCE() bool

type GitHubConnector

type GitHubConnector struct {
	// contains filtered or unexported fields
}

GitHubConnector implements Connector against GitHub's OAuth2 (NOT OIDC) endpoints. We retrieve the user profile from the v3 API and resolve the verified primary email separately.

func NewGitHubConnector

func NewGitHubConnector(clientID, clientSecret, display string) *GitHubConnector

func (*GitHubConnector) AuthorizeURL

func (g *GitHubConnector) AuthorizeURL(_ context.Context, redirectURI, state, codeVerifier string) (string, error)

func (*GitHubConnector) Display

func (g *GitHubConnector) Display() string

func (*GitHubConnector) ExchangeCode

func (g *GitHubConnector) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)

func (*GitHubConnector) Name

func (g *GitHubConnector) Name() string

func (*GitHubConnector) SupportsPKCE

func (g *GitHubConnector) SupportsPKCE() bool

type GoogleConnector

type GoogleConnector struct {
	// contains filtered or unexported fields
}

GoogleConnector implements Connector against Google's OIDC endpoints. We hardcode the canonical URLs (no .well-known fetch at every login) — Google has not changed these in years.

func NewGoogleConnector

func NewGoogleConnector(clientID, clientSecret, display string) *GoogleConnector

NewGoogleConnector returns a Google OIDC connector. clientID and clientSecret are issued by the Google Cloud Console (OAuth 2.0 client of type "Web application").

func (*GoogleConnector) AuthorizeURL

func (g *GoogleConnector) AuthorizeURL(_ context.Context, redirectURI, state, codeVerifier string) (string, error)

func (*GoogleConnector) Display

func (g *GoogleConnector) Display() string

func (*GoogleConnector) ExchangeCode

func (g *GoogleConnector) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)

func (*GoogleConnector) Name

func (g *GoogleConnector) Name() string

func (*GoogleConnector) SupportsPKCE

func (g *GoogleConnector) SupportsPKCE() bool

type MemoryStateStore

type MemoryStateStore struct {
	// contains filtered or unexported fields
}

MemoryStateStore is the default StateStore.

func NewMemoryStateStore

func NewMemoryStateStore(ttl time.Duration) *MemoryStateStore

NewMemoryStateStore returns a store with a per-entry TTL (entries older than ttl are evicted on access).

func (*MemoryStateStore) Put

func (*MemoryStateStore) StartSweeper

func (s *MemoryStateStore) StartSweeper(ctx context.Context, interval time.Duration)

StartSweeper runs Sweep on a fixed cadence until ctx is cancelled. Blocks; callers typically launch it in a goroutine. Recommended interval is the store's TTL so even an attacker spamming Put never keeps more than ~2× TTL worth of entries in memory.

func (*MemoryStateStore) Sweep

func (s *MemoryStateStore) Sweep() int

Sweep evicts every PendingAuth older than the configured TTL. Take already discards expired entries lazily, but a user who clicked "Sign in with Google" then closed the tab never returns — without this the entry sits in memory until process restart. Returns the number of entries evicted so a caller can wire it into a metric.

Safe to call concurrently with Put/Take.

func (*MemoryStateStore) Take

func (s *MemoryStateStore) Take(_ context.Context, state string) (PendingAuth, error)

type MongoStateStore

type MongoStateStore struct {
	// contains filtered or unexported fields
}

MongoStateStore is a Mongo-backed StateStore. The in-memory store is per-process, so an OIDC /start on replica A and /callback on replica B would fail "state expired or invalid" — common once per-org Keycloak flows (with slower IdP MFA prompts) run behind an autoscaled control plane. This store shares PendingAuth across replicas; expired rows are reaped by a Mongo TTL index AND re-checked on Take (TTL deletion is lazy, ~60s).

func NewMongoStateStore

func NewMongoStateStore(db *mongo.Database, ttl time.Duration) *MongoStateStore

NewMongoStateStore wires a Mongo-backed state store with a per-entry TTL.

func (*MongoStateStore) EnsureSchema

func (s *MongoStateStore) EnsureSchema(ctx context.Context) error

EnsureSchema creates the TTL index on expires_at (Mongo evicts a row once expires_at is in the past).

func (*MongoStateStore) Put

Put persists a PendingAuth keyed by its state (upsert — a regenerated state collision, astronomically unlikely, just overwrites).

func (*MongoStateStore) Take

func (s *MongoStateStore) Take(ctx context.Context, state string) (PendingAuth, error)

Take atomically fetches and deletes the PendingAuth for state (single-use), re-checking the TTL in case Mongo hasn't reaped the row yet.

type PendingAuth

type PendingAuth struct {
	Provider     string
	State        string
	CodeVerifier string
	RedirectURI  string
	NextURL      string // post-login redirect target (sanitized to relative paths)
	IssuedAt     time.Time
	// AgentBinding is a per-flow random token the HTTP layer sets as
	// an HttpOnly cookie at /start and verifies at /callback. RFC 9700
	// (OAuth 2.0 Security BCP) §4.7.1 mandates a CSRF mechanism beyond
	// `state`; the state parameter alone proves freshness/uniqueness
	// but does not bind the flow to the user agent that initiated it.
	// Without this binding, an attacker who completes /start in their
	// browser and lures a victim into hitting /callback with that
	// state pins the victim into the attacker's account on iterion
	// (the classic login-CSRF / session-fixation against OAuth).
	// Empty string for non-browser callers (CLI / SDK) where the
	// transport guarantees agent binding by other means.
	AgentBinding string

	// TenantID + OrgProviderID are set when the flow was initiated against a
	// per-org provider (a tenant's own Keycloak). The callback resolves the
	// tenant's policy (membership grant, default role, auto-link) from these,
	// read SERVER-SIDE via the state lookup — never from the URL or a cookie —
	// so a per-org indirection cannot enable provider/tenant confusion (start
	// org A's Keycloak, complete the callback resolved as org B). Empty for
	// global providers (github / google / the deployment "sso").
	TenantID      string
	OrgProviderID string

	// LinkUserID is set when the flow was initiated by an already-authenticated
	// user explicitly connecting this SSO identity to their account (the
	// /api/auth/oidc/<provider>/link/start path). The callback then attaches the
	// resolved external identity to this user instead of running the normal
	// login/signup logic. Empty for an ordinary sign-in flow.
	LinkUserID string

	// Desktop marks a flow initiated by the DESKTOP app. Instead of setting
	// browser cookies and redirecting to an SPA path, the callback mints a
	// single-use exchange ticket and redirects to DesktopRedirect (a loopback
	// URL the desktop listens on); the desktop then redeems the ticket for
	// tokens at /api/auth/desktop/exchange over its native client. Keeps the
	// IdP redirect_uri on the stable cloud origin (no IdP reconfiguration) and
	// keeps the refresh token out of any URL. Empty for browser flows.
	Desktop         bool
	DesktopRedirect string
}

PendingAuth captures the per-flow state held server-side between /start and /callback. Stored in a StateStore (memory by default).

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry maps provider slugs to Connectors. Used by the HTTP layer to dispatch start/callback.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry.

func (*Registry) Enabled

func (r *Registry) Enabled() []Connector

Enabled returns the registered connectors in declaration order.

func (*Registry) Get

func (r *Registry) Get(name string) (Connector, error)

Get looks up a connector by name.

func (*Registry) Register

func (r *Registry) Register(c Connector)

Register attaches a connector. Idempotent overwrite.

type StateStore

type StateStore interface {
	Put(ctx context.Context, p PendingAuth) error
	Take(ctx context.Context, state string) (PendingAuth, error)
}

StateStore is the persistence interface for PendingAuth records.

Jump to

Keyboard shortcuts

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