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
- Variables
- func GenerateStateAndPKCE() (state, verifier, challenge string, err error)
- func SafeGenericClient(strict bool) *http.Client
- type Connector
- type ExternalUser
- type GenericConnector
- func (c *GenericConnector) AuthorizeURL(ctx context.Context, redirectURI, state, codeVerifier string) (string, error)
- func (c *GenericConnector) Display() string
- func (c *GenericConnector) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)
- func (c *GenericConnector) Name() string
- func (c *GenericConnector) SupportsPKCE() bool
- type GitHubConnector
- func (g *GitHubConnector) AuthorizeURL(_ context.Context, redirectURI, state, codeVerifier string) (string, error)
- func (g *GitHubConnector) Display() string
- func (g *GitHubConnector) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)
- func (g *GitHubConnector) Name() string
- func (g *GitHubConnector) SupportsPKCE() bool
- type GoogleConnector
- func (g *GoogleConnector) AuthorizeURL(_ context.Context, redirectURI, state, codeVerifier string) (string, error)
- func (g *GoogleConnector) Display() string
- func (g *GoogleConnector) ExchangeCode(ctx context.Context, code, redirectURI, codeVerifier string) (ExternalUser, error)
- func (g *GoogleConnector) Name() string
- func (g *GoogleConnector) SupportsPKCE() bool
- type MemoryStateStore
- type MongoStateStore
- type PendingAuth
- type Registry
- type StateStore
Constants ¶
const StatesCollectionName = "oidc_states"
StatesCollectionName is the Mongo collection backing PendingAuth records.
Variables ¶
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 ¶
GenerateStateAndPKCE returns a random state value (~32 bytes base64url) and a PKCE pair (verifier + S256 challenge).
func SafeGenericClient ¶
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 (*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 (*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 (*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 (s *MemoryStateStore) Put(_ context.Context, p PendingAuth) error
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 ¶
func (s *MongoStateStore) Put(ctx context.Context, p PendingAuth) error
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.
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.