Documentation
¶
Overview ¶
Package oidcauth implements a minimal OpenID Connect relying party for the cloop web dashboard: the authorization-code flow with PKCE for a confidential client, ID-token validation against the provider's JWKS, and in-memory cookie-backed sessions.
The package is intentionally dependency-free (stdlib only). Only the parts of OIDC that cloop needs are implemented: provider discovery, the code flow, RS256/ES256 signature verification, and iss/aud/exp/nonce claim validation.
Session lifecycle ¶
A session ends for one of four reasons, and the hub can tell them apart:
the user signed out session.revoked an operator terminated it session.revoked a clock ran out session.expired the IdP refused to renew it session.idp_revoked
Two clocks run concurrently. The absolute ceiling is set at login and cannot be extended; the idle clock is refreshed by authenticated requests and is what bounds an unattended browser. Both are enforced on the read path — so a session is dead the moment either lapses, whether or not the janitor has got to the row yet — and swept off it, so the table does not accumulate corpses.
The fourth reason is the one that needs a mechanism rather than a timer. If the identity provider disables a user, nothing about the cookie changes, so a hub that never asks again keeps honouring it until the ceiling lapses. Storing the refresh token (sealed, see SessionStore) and redeeming it on an interval turns "the IdP says no" into a session that ends in minutes rather than hours. That check runs in the background, never on the request path: an unreachable IdP must make the dashboard slow to *revoke*, not slow to serve.
Sessions are held by a SessionStore. The default is process-local and dies with the hub; pkg/sessionstore persists them so a restart or a rolling upgrade does not sign everyone out.
Index ¶
- Constants
- Variables
- func HashSessionID(sessionID string) string
- func RequestIP(r *http.Request) string
- type Authenticator
- func (a *Authenticator) BeginLogin(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) Enabled() bool
- func (a *Authenticator) EndSessionURL() string
- func (a *Authenticator) HandleCallback(w http.ResponseWriter, r *http.Request)
- func (a *Authenticator) IdentityFromRequest(r *http.Request) *Identity
- func (a *Authenticator) IdleTimeout() time.Duration
- func (a *Authenticator) IsAdmin(id *Identity) bool
- func (a *Authenticator) Issuer() string
- func (a *Authenticator) ListSessions() ([]SessionRecord, error)
- func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) *Identity
- func (a *Authenticator) LogoutAll(subject, keepID, actor string) (int, error)
- func (a *Authenticator) RevalidateDue(ctx context.Context) (int, int)
- func (a *Authenticator) RevokeSession(id, actor, reason string) (bool, error)
- func (a *Authenticator) RunJanitor(ctx context.Context)
- func (a *Authenticator) SessionCount() int
- func (a *Authenticator) SessionFromRequest(r *http.Request) (SessionRecord, bool)
- func (a *Authenticator) SessionTTL() time.Duration
- func (a *Authenticator) SweepExpired() int
- type Config
- type Identity
- type SessionAudit
- type SessionRecord
- type SessionStore
Constants ¶
const ( // SessionCookieName is the browser cookie that carries the opaque // session ID established after a successful IdP callback. SessionCookieName = "cloop_session" // DefaultSessionTTL is used when Config.SessionTTL is zero. DefaultSessionTTL = 24 * time.Hour // DefaultIdleTimeout is used when Config.IdleTimeout is zero. Eight hours // is one working day: a browser left open over lunch stays signed in, one // left open overnight does not. DefaultIdleTimeout = 8 * time.Hour // DefaultRefreshInterval is used when Config.RefreshInterval is zero. It // is the worst-case delay between the IdP disabling a user and their cloop // session ending, so it is short — but not so short that a hub with a // thousand sessions turns into a load generator against the token // endpoint. DefaultRefreshInterval = 15 * time.Minute )
const ( // AuditSessionCreated records a successful sign-in. AuditSessionCreated = "session.created" // AuditSessionExpired records a session the janitor removed because one of // its two clocks ran out. Distinct from a revocation because "nobody // intervened, it simply lapsed" is a different answer to "why is this // person signed out". AuditSessionExpired = "session.expired" // AuditSessionRevoked records a deliberate termination: the user signing // out, the user ending every session, or an operator terminating one. AuditSessionRevoked = "session.revoked" // AuditSessionIdPRevoked records a session terminated because the identity // provider refused to renew it — the user was disabled, consent was // withdrawn, or the IdP forced a sign-out. This is the event that proves // IdP-side revocation actually reached the hub. AuditSessionIdPRevoked = "session.idp_revoked" )
Session audit event types. These are the strings written into the hash-chained trail; they are wire values and must never be renamed.
Variables ¶
var ErrSessionNotFound = errors.New("oidcauth: session not found")
ErrSessionNotFound is what a store returns for an id it does not hold. It is an ordinary outcome on the authentication path: a forged cookie, a cookie that predates a restart, and a session an administrator just terminated are indistinguishable to the caller, and must stay that way.
Functions ¶
func HashSessionID ¶
HashSessionID maps a session cookie to the identifier used everywhere else.
SHA-256 with no salt and no stretching, deliberately. The preimage is 256 bits of CSPRNG output rather than a chosen secret, so there is no dictionary for a KDF to slow down, and an unsalted digest is what allows the lookup to be a single primary-key read on the authentication path.
func RequestIP ¶
RequestIP extracts the client address for the Active Sessions table.
X-Forwarded-For is honoured because a hosted hub sits behind a reverse proxy, and its leftmost entry is the only useful value there. It is attacker-controlled on a direct connection, which is why nothing security-relevant keys off this: it is a label an operator reads, never an input to an authorization decision.
Types ¶
type Authenticator ¶
type Authenticator struct {
// contains filtered or unexported fields
}
Authenticator is the OIDC relying party. The zero value is not usable; construct via New. A nil *Authenticator is valid and reports Enabled() == false, so callers can hold one optional field without nil checks.
func New ¶
func New(cfg Config) (*Authenticator, error)
New validates cfg and returns a ready Authenticator. It is an error to call New with Enabled=false — callers should simply not construct one. Validation is strict (fail closed): a dashboard that claims to require SSO must not silently start without it.
func (*Authenticator) BeginLogin ¶
func (a *Authenticator) BeginLogin(w http.ResponseWriter, r *http.Request)
BeginLogin starts the authorization-code flow: it records a one-shot state + nonce + PKCE verifier and redirects the browser to the IdP's authorization endpoint.
func (*Authenticator) Enabled ¶
func (a *Authenticator) Enabled() bool
Enabled reports whether OIDC authentication is active. Safe on nil.
func (*Authenticator) EndSessionURL ¶
func (a *Authenticator) EndSessionURL() string
EndSessionURL returns the provider's RP-initiated logout URL, or "" when the discovery document advertises none (or has not been fetched yet).
Clearing the cookie ends the session *here*; the browser's session at the provider survives it, so the next sign-in completes without a prompt and "log out" looks broken to anyone on a shared machine. Sending the browser on to end_session_endpoint is what makes the two agree.
The request carries client_id and post_logout_redirect_uri rather than id_token_hint. Both are permitted by RP-Initiated Logout 1.0, and the hint would mean retaining the ID token at rest for the life of the session — a second credential to protect in exchange for a marginally smoother prompt.
func (*Authenticator) HandleCallback ¶
func (a *Authenticator) HandleCallback(w http.ResponseWriter, r *http.Request)
HandleCallback completes the flow: validates state, exchanges the code, verifies the ID token, creates a session, and redirects to the dashboard.
func (*Authenticator) IdentityFromRequest ¶
func (a *Authenticator) IdentityFromRequest(r *http.Request) *Identity
IdentityFromRequest returns the authenticated user for r's session cookie, or nil when there is no valid session. Safe on nil receiver.
func (*Authenticator) IdleTimeout ¶
func (a *Authenticator) IdleTimeout() time.Duration
IdleTimeout returns the configured idle window (zero or less means the idle clock is off).
func (*Authenticator) IsAdmin ¶
func (a *Authenticator) IsAdmin(id *Identity) bool
IsAdmin reports whether id's email is on the configured admin list. Admins see (and may manage) every project regardless of owner.
func (*Authenticator) Issuer ¶
func (a *Authenticator) Issuer() string
Issuer returns the configured issuer URL ("" when disabled).
func (*Authenticator) ListSessions ¶
func (a *Authenticator) ListSessions() ([]SessionRecord, error)
ListSessions returns every live session, most recently active first, with the two clocks evaluated at now so the caller can render a status without re-deriving the policy.
func (*Authenticator) Logout ¶
func (a *Authenticator) Logout(w http.ResponseWriter, r *http.Request) *Identity
Logout ends the caller's session and clears the cookie. It returns the identity that was signed out, or nil when there was no session.
func (*Authenticator) LogoutAll ¶
func (a *Authenticator) LogoutAll(subject, keepID, actor string) (int, error)
LogoutAll ends every session belonging to subject and returns how many it ended. keepID (a hashed session id, typically the caller's own) is spared; pass "" to end all of them.
The self-service answer to a stolen laptop: one call from any device the user still holds invalidates the rest, without an operator in the loop.
func (*Authenticator) RevalidateDue ¶
func (a *Authenticator) RevalidateDue(ctx context.Context) (int, int)
RevalidateDue re-checks every session whose last IdP confirmation is older than the configured interval, terminating those the provider refuses. Returns (checked, terminated).
This is the mechanism that makes IdP-side revocation take effect. Without it, disabling a user at the provider changes nothing the hub can observe: the cookie is still valid, the claims in it were valid when issued, and the session runs to its ceiling.
func (*Authenticator) RevokeSession ¶
func (a *Authenticator) RevokeSession(id, actor, reason string) (bool, error)
RevokeSession ends one session by its hashed id, on behalf of actor. It reports false when no such session existed.
This is the operator's kill switch: a session terminated here stops authenticating within sessionCacheTTL even on a replica that has it cached, and immediately on the replica that served the call.
func (*Authenticator) RunJanitor ¶
func (a *Authenticator) RunJanitor(ctx context.Context)
RunJanitor sweeps expired sessions and revalidates them against the IdP until ctx is done. It is safe to call at most once per Authenticator; callers that do not want the background work simply do not start it, and then rely on the read-path checks alone.
func (*Authenticator) SessionCount ¶
func (a *Authenticator) SessionCount() int
SessionCount returns the number of stored sessions. Used by tests and /api/me diagnostics.
func (*Authenticator) SessionFromRequest ¶
func (a *Authenticator) SessionFromRequest(r *http.Request) (SessionRecord, bool)
SessionFromRequest resolves and validates r's session, refreshing the idle clock as a side effect. It reports false for a missing, unknown, expired, or idle session — deliberately without distinguishing them, since the caller must answer all four identically.
func (*Authenticator) SessionTTL ¶
func (a *Authenticator) SessionTTL() time.Duration
SessionTTL returns the absolute session ceiling.
func (*Authenticator) SweepExpired ¶
func (a *Authenticator) SweepExpired() int
SweepExpired removes sessions past either clock and audits each one. Returns how many it removed. Exported so tests and the janitor share one code path.
type Config ¶
type Config struct {
Enabled bool
Issuer string // e.g. https://auth.example.com/realms/main
ClientID string
ClientSecret string
RedirectURL string // e.g. https://cloop.example.com/auth/callback
Scopes []string // default: openid profile email
AdminEmails []string // users who see every project regardless of owner
SessionTTL time.Duration
CookieSecure string // "auto" (default), "always", "never"
// IdleTimeout ends a session that has gone unused for this long, even
// though its absolute ceiling has not been reached. Zero uses
// DefaultIdleTimeout; negative disables the idle clock entirely, which is
// only appropriate on a single-operator loopback deployment.
IdleTimeout time.Duration
// RefreshInterval is how often a session is revalidated against the IdP
// using its stored refresh token. Zero uses DefaultRefreshInterval;
// negative disables the check, which also disables IdP-side revocation —
// the two timeouts then become the only way a session ends.
RefreshInterval time.Duration
// Store persists sessions. Nil installs a process-local store, which is
// the pre-Task-20176 behaviour: a restart signs everyone out.
Store SessionStore
// Audit receives every session lifecycle event. Nil discards them.
//
// A callback rather than a direct write to the trail because this package
// is stdlib-only by design and the trail lives in SQLite. pkg/ui supplies
// the sink. Implementations must not block: they are called while a
// request or the janitor is waiting.
Audit func(SessionAudit)
// Clock supplies the current time. Nil means time.Now. It exists so the
// two expiry clocks can be tested without sleeping through them.
Clock func() time.Time
// SessionLimit reports how many concurrent sessions one identity may
// hold. Zero (and a nil hook) means unlimited. Supplied by pkg/ui from
// the per-identity quota policy; this package stays stdlib-only and
// holds no opinion about where the number comes from (Task 20182).
//
// The cap is enforced by evicting the identity's least recently used
// sessions, never by refusing the login. Refusing would leave the user
// with no session at all — and the self-service remedy, POST
// /api/session/logout-all, requires one, so a capped user could be
// locked out of their own account with no way back in. Eviction keeps
// the invariant the cap exists for (no identity holds more than N)
// without that failure mode.
SessionLimit func(identity string, groups, roles []string) int
}
Config holds the relying-party settings, typically mapped from config.OIDCConfig (ui.oidc.* in .cloop/config.yaml).
type Identity ¶
type Identity struct {
Sub string `json:"sub"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
// Groups and Roles carry the group/role claims the IdP released,
// flattened from whatever shape it used. They are the input to
// pkg/authz role mappings; an IdP that releases neither leaves both
// empty and every identity falls back to oidc.default_role.
Groups []string `json:"groups,omitempty"`
Roles []string `json:"roles,omitempty"`
}
Identity is the authenticated user extracted from a validated ID token.
func (*Identity) DisplayName ¶
DisplayName returns the friendliest non-empty label for the user.
type SessionAudit ¶
type SessionAudit struct {
Event string
SessionID string
Subject string
Email string
Actor string
Reason string
IP string
UserAgent string
At time.Time
}
SessionAudit describes one session lifecycle event.
It carries no credential material by construction: the session id here is the digest, and there is no field a refresh or ID token could be written to.
type SessionRecord ¶
type SessionRecord struct {
ID string
Identity Identity
IP string
UserAgent string
IssuedAt time.Time
LastSeen time.Time
ExpiresAt time.Time
RefreshToken string
RefreshCheckedAt time.Time
}
SessionRecord is one persisted session.
ID is a hex SHA-256 of the cookie value, never the cookie itself — see HashSessionID. It doubles as the session's public identifier in the admin API, which is safe for the same reason a username is: the digest does not yield the preimage a request would have to present.
RefreshToken is plaintext *in this struct only*. It crosses the SessionStore boundary in the clear and is sealed on the other side, so a store implementation is free to encrypt it at rest without this package holding a key. It is empty when the deployment has no encryption key configured, and nothing here may log it.
type SessionStore ¶
type SessionStore interface {
// Put writes a new session. It must not overwrite an existing id.
Put(SessionRecord) error
// Get returns the session with id, or an error wrapping
// ErrSessionNotFound.
Get(id string) (SessionRecord, error)
// List returns every session, most recently active first.
List() ([]SessionRecord, error)
// Touch records that a session was used at t. Called off the
// authentication path and throttled by the caller; failures are advisory
// and must not fail a request.
Touch(id string, t time.Time) error
// Delete removes one session and reports whether it existed.
Delete(id string) (bool, error)
// DeleteBySubject removes every session for subject except keepID (empty
// removes all of them), returning what it deleted so each can be audited.
DeleteBySubject(subject, keepID string) ([]SessionRecord, error)
// DeleteExpired removes sessions whose ExpiresAt is at or before
// absoluteCutoff, or whose LastSeen is at or before idleCutoff, returning
// what it deleted. Both cutoffs are absolute times: the store holds no
// opinion about how long a session should live.
DeleteExpired(absoluteCutoff, idleCutoff time.Time) ([]SessionRecord, error)
// DueForRefresh returns up to limit sessions holding a refresh token whose
// last IdP check is at or before cutoff, oldest first.
DueForRefresh(cutoff time.Time, limit int) ([]SessionRecord, error)
// SetRefresh stores a rotated refresh token and stamps the check time. An
// empty token clears the stored one, which is how a session that can no
// longer be revalidated stops being retried.
SetRefresh(id, refreshToken string, checkedAt time.Time) error
}
SessionStore persists sessions. Implementations must be safe for concurrent use: every authenticated request may read through one.
Every method takes and returns the session id already hashed. A store that receives a raw cookie value has been called wrongly.
func NewMemorySessionStore ¶
func NewMemorySessionStore(max int) SessionStore
NewMemorySessionStore returns a process-local store bounded at max entries (zero uses the built-in bound). Oldest-first eviction keeps a burst of logins from growing the process without limit.