oidcauth

package
v0.0.4 Latest Latest
Warning

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

Go to latest
Published: Sep 27, 2026 License: MIT Imports: 24 Imported by: 0

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

View Source
const (
	// DefaultMaxClaimAge is used when Config.MaxClaimAge is zero.
	//
	// Five minutes, chosen against what it costs rather than what sounds
	// strict. The cost is one token-endpoint round trip per privileged
	// operation per five minutes per administrator — single-flighted, so a
	// dashboard that fires six admin calls when a panel opens pays for one.
	// The benefit is that the interval in "we removed them at the IdP and they
	// still had admin for N minutes" is five rather than fifteen, and is
	// bounded at all on a hub that disabled background revalidation.
	DefaultMaxClaimAge = 5 * time.Minute

	// MaxMaxClaimAge caps the configured bound. Past an hour the setting has
	// stopped being a freshness guarantee and become a second, longer session
	// TTL that happens to be checked on privileged calls — and an operator who
	// wrote a day meant to disable this, which is what -1 is for.
	MaxMaxClaimAge = time.Hour
)
View Source
const (
	// SessionCookieName is the browser cookie that carries the opaque
	// session ID established after a successful IdP callback.
	SessionCookieName = "cloop_session"

	// DefaultClockSkew is the leeway applied to exp/iat validation so a
	// modest clock drift between cloop and the IdP does not reject valid
	// tokens. Used when Config.ClockSkew is zero.
	DefaultClockSkew = 5 * time.Minute

	// MaxClockSkew is the largest leeway a deployment may configure.
	//
	// Ten minutes is already generous for hosts that both run NTP; past it
	// the setting stops compensating for drift and starts extending the life
	// of every expired token by the same amount, which is a different and
	// much worse trade than the one the knob exists to make. Exceeding it is
	// a startup error rather than a silent clamp: an operator who wrote an
	// hour believes they got an hour, and quietly giving them ten minutes
	// would leave them debugging the wrong thing.
	MaxClockSkew = 10 * time.Minute

	// 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
)
View Source
const (
	StageDiscovery = "discovery"
	StageJWKS      = "jwks"
)

Preflight stages — which of the two round trips failed.

View Source
const (
	// PreflightUnreachable covers everything that is not an answer: DNS,
	// connection refused, TLS, timeout, and any non-200 status.
	PreflightUnreachable = "unreachable"

	// PreflightMalformed means the endpoint answered but not with the
	// document it is supposed to serve.
	PreflightMalformed = "malformed"

	// PreflightIssuerMismatch means the discovery document describes a
	// provider at a different origin than the one configured — the endpoint
	// answered, but with somebody else's metadata. A same-origin difference
	// (Entra's domain-vs-GUID tenant forms) is an alias and is accepted; see
	// sameOriginIssuer.
	PreflightIssuerMismatch = "issuer_mismatch"

	// PreflightNoJWKSURI means the discovery document advertises no signing
	// keys — usually a bare OAuth 2 server rather than an OIDC provider.
	PreflightNoJWKSURI = "no_jwks_uri"

	// PreflightNoKeys means the key set held nothing cloop can verify with
	// (RS256/ES256). The provider works; this hub can never validate a
	// token from it.
	PreflightNoKeys = "no_keys"

	// PreflightNotConfigured means there is no issuer to contact.
	PreflightNotConfigured = "not_configured"

	// PreflightUnverified means nothing has tried yet. It is the fail-closed
	// initial state: "not attempted" and "succeeded" must not read the same
	// to a readiness probe, or a hub that skipped its preflight would report
	// the identity path as healthy on no evidence at all.
	PreflightUnverified = "unverified"
)

Preflight failure reasons. A closed set: every PreflightError carries one of these, so a caller can branch on the cause without matching on prose.

View Source
const (
	// AuditSessionCreated records a successful sign-in.
	AuditSessionCreated = auditaction.ActionSessionCreated

	// 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 = auditaction.ActionSessionExpired

	// AuditSessionRevoked records a deliberate termination: the user signing
	// out, the user ending every session, or an operator terminating one.
	AuditSessionRevoked = auditaction.ActionSessionRevoked

	// 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 = auditaction.ActionSessionIdPRevoked

	// AuditSessionRoleNarrowed records a live session losing authority because
	// the IdP stopped releasing a group or role it released at sign-in.
	//
	// Deprivileging is the one claim change that must never be silent. Every
	// other outcome of a revalidation leaves a trace an operator can reason
	// about — the session ends, or it visibly continues — but authority
	// quietly draining out of a session that stays signed in is invisible
	// unless it is written down. It is also the event an auditor asks for by
	// name after an incident: "show me that removing them from the admin group
	// actually took effect, and when."
	AuditSessionRoleNarrowed = auditaction.ActionSessionRoleNarrowed

	// AuditSessionClaimsUnverified records that a renewal could re-assert the
	// session's claims from neither source: the provider returned no id_token,
	// and the userinfo endpoint did not answer either (it is unadvertised,
	// unreachable, or refused the access token).
	//
	// This is not a failure of the renewal: many providers only issue an
	// id_token on the initial code exchange, and the refresh grant succeeding
	// still proves the grant is alive, which is what IdP-side revocation
	// depends on. What it does mean is that the session's groups and roles are
	// still the ones captured at sign-in and will stay that way until it ends —
	// so a group removal at the IdP does not take effect on this hub. An
	// operator reading the trail has to be able to tell such a deployment from
	// one where claims genuinely are re-checked every interval, otherwise
	// "cloop re-authorizes every 15 minutes" is believed where it does not
	// happen. Emitted once per process rather than once per session per
	// interval: it describes the provider, not the user, so every session on
	// such a hub would otherwise write the same fact forever.
	//
	// Before Task 20261 this was the outcome for every provider that omits the
	// id_token, which is most of them. It is now the exception.
	AuditSessionClaimsUnverified = auditaction.ActionSessionClaimsUnverified

	// AuditSessionClaimsRejected records the identity provider refusing to
	// vouch for a session's claims: the userinfo endpoint answered 401, or
	// named invalid_token, for an access token the very same refresh grant had
	// minted seconds earlier (Task 20273).
	//
	// Before this event that response was read and discarded, on the reasoning
	// that a provider which does not accept its own access token at userinfo,
	// or which wants a scope this deployment did not request, is a
	// misconfiguration rather than a revocation — and signing the fleet out
	// over it would turn a claim-freshness feature into a logout bug. That
	// reasoning still holds, and the conclusion drawn from it did not: the
	// session survives, and its claims stop being usable for anything above
	// operator until a later read succeeds. So the ambiguous case costs
	// privileged actions rather than everybody's session, and — unlike before
	// — it costs something, which is what gets it noticed and fixed.
	AuditSessionClaimsRejected = auditaction.ActionSessionClaimsRejected

	// AuditSessionClaimsStale records a privileged operation refused because
	// the session's claims were older than max_claim_age and could not be
	// refreshed (Task 20273).
	//
	// This is the event that proves the bound is load-bearing. Without it an
	// operator sees a 403 and nothing else, and cannot distinguish "my role
	// binding is wrong" — which they will go and change, incorrectly — from
	// "the hub could not reach the IdP", which is a different fix entirely.
	AuditSessionClaimsStale = auditaction.ActionSessionClaimsStale
)

Session audit event types. These are the strings written into the hash-chained trail; they are wire values and must never be renamed.

View Source
const DefaultCallbackPath = "/auth/callback"

DefaultCallbackPath is where cloop serves the OIDC redirect when redirect_url does not say otherwise. It is only a default: the IdP decides this path, and on a registration somebody else already created it is routinely something else (Entra's SPA platform, for instance, is commonly registered as /auth/oidc).

View Source
const DefaultPreflightTimeout = 10 * time.Second

DefaultPreflightTimeout bounds an entire preflight — discovery and the JWKS fetch together, not each.

Short on purpose. This runs before the listener binds, so every second of it is a second the hub is not serving; and the thing it is waiting for is a metadata document that a healthy IdP returns in milliseconds. A slow answer is diagnostically the same as no answer, and the lazy path will retry either way.

Variables

View Source
var ErrClaimsRepudiated = errors.New("oidcauth: the identity provider refused to confirm this session's claims")

ErrClaimsRepudiated marks a userinfo response in which the provider actively declined to vouch for the session — as opposed to one it simply could not answer (Task 20273).

The distinction is the whole reason it exists. "The endpoint timed out" and "the endpoint said your token is not valid" arrive at the same call site and mean opposite things: the first is the hub's problem and must not change what a session may do, the second is the provider's verdict on this session's authorization and must.

View Source
var ErrClaimsUnverifiable = errors.New("oidcauth: this session's claims are stale and the identity provider could not confirm them")

ErrClaimsUnverifiable means a session's claims are older than the policy allows and the identity provider could not be asked for current ones.

It is deliberately not a session termination. The realistic causes are an IdP outage, a network partition, and a hub with no CLOOP_SECRET_KEY (which retains no refresh token to revalidate with) — none of which is evidence about the user, and all of which would sign out the entire fleet if they ended sessions. What it does do is refuse the privileged operation, so a hub that cannot establish current authority does not act on stale authority.

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

func HashSessionID(sessionID string) string

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

func RequestIP(r *http.Request) string

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

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.

It returns LoginPending on the redirect. Any other value means the response is already an error page and the attempt is over.

func (*Authenticator) CallbackPath added in v0.0.3

func (a *Authenticator) CallbackPath() string

CallbackPath is the path component of the configured redirect_url — the route the hub must serve for the authorization-code flow to complete. Safe on nil and on a disabled authenticator, both of which report the default so the route table has a stable shape either way.

func (*Authenticator) ClaimsRefused added in v0.0.2

func (a *Authenticator) ClaimsRefused() uint64

ClaimsRefused reports how many privileged operations this process has refused because the session's claims could not be brought within the bound.

A hub where this climbs is not one under attack; it is one whose IdP cannot be re-asked, and its administrators are being locked out of exactly the operations they most need. It belongs next to RefreshClaimStats for the same reason: the honest answer to "is claim freshness working here" is a pair of numbers, not a boolean.

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) EnsureFreshClaims added in v0.0.2

func (a *Authenticator) EnsureFreshClaims(ctx context.Context, sessionID string) (SessionRecord, error)

EnsureFreshClaims returns the session with claims no older than the configured bound, re-asserting them against the identity provider if needed.

It is the gate a permission above operator passes through, and it is deliberately the *only* place in this package that contacts the IdP on a request goroutine. Callers must not put it on a read path: see the package note on why an IdP outage may make the hub slow to revoke but never slow to serve.

Three outcomes:

  • claims are within the bound → the stored session, no IdP contact, no lock held beyond a map read. This is the overwhelmingly common case and the one the benchmark covers.
  • claims are stale and the IdP answers → the session with its claims replaced, which may be narrower than a moment ago. That narrowing is what the caller must then re-evaluate its decision against.
  • claims are stale and the IdP cannot vouch → an error wrapping ErrClaimsUnverifiable. Never a termination; see that variable.

A session the IdP has revoked is terminated here exactly as it would be by the background pass, and reported as ErrSessionNotFound: from the caller's side there is no longer a session to authorise anything.

Single flight

Concurrent calls for one session share one round trip. That is not only an optimisation. A dashboard opening its admin panel fires several privileged requests at once; without collapsing them an administrator being demoted at that instant would race N revalidations, and the one that landed last would decide what the others had already been allowed to do. Sharing the flight means every one of those requests sees the same answer, and it is the newest one — a demotion cannot be outrun by sending more requests.

func (*Authenticator) HandleCallback

func (a *Authenticator) HandleCallback(w http.ResponseWriter, r *http.Request) LoginOutcome

HandleCallback completes the flow: validates state, exchanges the code, verifies the ID token, creates a session, and redirects to the dashboard. The returned outcome is always a verdict — this is where a sign-in ends.

func (*Authenticator) IdPReady added in v0.0.2

func (a *Authenticator) IdPReady() error

IdPReady reports whether this hub has ever successfully resolved its issuer — discovery *and* a usable key set.

It returns the last failure, not a generic one, because the caller is /readyz and the audience for a readiness body is an operator who has just watched a rollout stall. Once a resolution has succeeded it keeps returning nil even if the IdP later goes away: existing sessions still authenticate, so flapping the whole hub out of its Service on the IdP's availability would convert a login outage into a total one.

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

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) MaxClaimAge added in v0.0.2

func (a *Authenticator) MaxClaimAge() time.Duration

MaxClaimAge returns the configured freshness bound (non-positive means the check is off). Safe on a nil receiver.

func (*Authenticator) Preflight added in v0.0.2

func (a *Authenticator) Preflight(ctx context.Context) (*PreflightResult, error)

Preflight resolves this authenticator's issuer and warms its caches, so the first real sign-in does not pay for discovery.

A failure is recorded (see IdPReady) but changes nothing else: the lazy path stays exactly as it was and will retry on the next login. That is the whole design — preflight makes a misconfiguration loud, it does not make a transient outage permanent.

func (*Authenticator) RefreshClaimStats added in v0.0.2

func (a *Authenticator) RefreshClaimStats() (asserted, unverified uint64)

RefreshClaimStats reports how many IdP revalidations since startup returned an id_token whose claims were re-applied, and how many returned none and so left the session's authority as captured at sign-in.

A hub whose unverified count climbs while asserted stays at zero is one where deprivileging at the IdP will not reach live sessions before they expire, however short the refresh interval is set.

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 ClaimFreshnessError added in v0.0.2

type ClaimFreshnessError struct {
	// Age is how stale the claims were, and Limit the configured bound.
	Age   time.Duration
	Limit time.Duration

	// Reason is the machine-readable cause, also used as the audit reason:
	// no_refresh_token, idp_unreachable, or claims_rejected.
	Reason string

	// Err is the underlying failure, if there was one. Nil for
	// no_refresh_token, where nothing was attempted because nothing could be.
	Err error
}

ClaimFreshnessError carries the actionable half of ErrClaimsUnverifiable: what the hub tried, and what an operator can do about it.

The message reaches an administrator in a 403 body at the moment their action was refused, which is the one moment they will read it. "Forbidden" there sends them to look at their role bindings, which are fine; naming the cause is the difference between a five-minute fix and an afternoon.

func (*ClaimFreshnessError) Error added in v0.0.2

func (e *ClaimFreshnessError) Error() string

func (*ClaimFreshnessError) Unwrap added in v0.0.2

func (e *ClaimFreshnessError) Unwrap() error

type Config

type Config struct {
	Enabled  bool
	Issuer   string // e.g. https://auth.example.com/realms/main
	ClientID string

	// ClientSecret is optional. Empty means cloop registers as a public
	// client and presents no credential at the token endpoint, relying on
	// the PKCE S256 binding that every authorization request carries
	// regardless. That is the default the Terraform module provisions,
	// because a credential that must be rotated, distributed and revoked is
	// the most expensive part of running this and PKCE removes the need for
	// one. Set it to add client authentication on top.
	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

	// MaxClaimAge bounds how stale a session's group and role claims may be at
	// the moment it exercises a permission above operator — granting a
	// credential, minting a token, rewriting role bindings (Task 20273). Past
	// it, EnsureFreshClaims re-asserts them against the IdP synchronously and
	// the operation is refused if the provider cannot be asked.
	//
	// Zero uses DefaultMaxClaimAge; negative disables the check, which is the
	// documented opt-out for a deployment that cannot retain refresh tokens.
	// Anything above MaxMaxClaimAge is rejected by New — past an hour this has
	// stopped being a freshness bound.
	//
	// It is deliberately independent of RefreshInterval. That knob sets a
	// background cadence and can be switched off entirely; this one is a
	// property the privileged path must hold whatever the background pass is
	// doing, so "never re-check" must not be expressible for it by accident.
	MaxClaimAge 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)

	// ClockSkew is the leeway applied to an ID token's exp and iat claims.
	// Zero uses DefaultClockSkew; negative means no leeway at all (the
	// strictest setting, appropriate where both clocks are known-good).
	// Anything above MaxClockSkew is rejected by New.
	//
	// It is a knob rather than a constant because the deployments that need
	// it are the ones that cannot fix the underlying problem: an on-premise
	// IdP on a host whose NTP is blocked by the same firewall that makes it
	// on-premise. The bound is what keeps it from being used as a way to
	// accept tokens that expired ten minutes ago.
	ClockSkew time.Duration

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

	// EffectiveRole reports the role a set of claims resolves to, and its
	// rank on the role ladder (higher is more authority). It exists so a
	// revalidation that narrows somebody's authority can say *what* they
	// lost — "admin → viewer" rather than "claims changed".
	//
	// Supplied by pkg/ui from the pkg/authz resolver, for the same reason
	// SessionLimit is: this package stays stdlib-only and holds no opinion
	// about how claims become roles. Nil is fully supported — narrowing is
	// then reported by the claims that disappeared, which is the fact this
	// package can establish on its own.
	//
	// It is consulted only on the revalidation path, never per request, so
	// its cost is paid once per session per refresh interval.
	EffectiveRole func(identity string, groups, roles []string) (role string, rank 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

func (id *Identity) DisplayName() string

DisplayName returns the friendliest non-empty label for the user.

func (*Identity) OwnerKey

func (id *Identity) OwnerKey() string

OwnerKey returns the stable string used to record project ownership: the lowercased email when present, otherwise the issuer subject. A nil identity yields "" (no owner / shared).

type LoginOutcome added in v0.0.2

type LoginOutcome string

LoginOutcome is the verdict of one sign-in attempt.

It is returned rather than recorded internally because this package is stdlib-only by design and the hub's metric registry is not — the same split that makes Config.Audit a callback. The values are a closed set, which is what lets them be a metric label: see hubmetrics.OIDCLogins.

const (
	// LoginPending is the zero value: the attempt has not reached a verdict.
	// BeginLogin returns it on the redirect to the IdP, which is a sign-in
	// still in progress and must not be counted as a completed one.
	LoginPending LoginOutcome = ""

	// LoginSuccess is a session established.
	LoginSuccess LoginOutcome = "success"

	// LoginDiscoveryFailed means the issuer could not be resolved, so the
	// flow never started. This is the misconfiguration outcome.
	LoginDiscoveryFailed LoginOutcome = "discovery_failed"

	// LoginIdPError means the provider itself refused, redirecting back with
	// an error parameter — a disabled account, a consent denial.
	LoginIdPError LoginOutcome = "idp_error"

	// LoginInvalidRequest means the callback arrived without state or code.
	LoginInvalidRequest LoginOutcome = "invalid_request"

	// LoginInvalidState means the state is unknown, expired, or replayed.
	// A sustained rate of these against a hub with working logins is someone
	// replaying callbacks.
	LoginInvalidState LoginOutcome = "invalid_state"

	// LoginExchangeFailed means the token endpoint refused the code —
	// usually a wrong client secret or an unregistered redirect URI.
	LoginExchangeFailed LoginOutcome = "exchange_failed"

	// LoginTokenInvalid means the ID token failed validation: signature,
	// issuer, audience, nonce, or expiry.
	LoginTokenInvalid LoginOutcome = "token_invalid"

	// LoginSessionError means everything about the identity checked out and
	// the session could not be stored.
	LoginSessionError LoginOutcome = "session_error"

	// LoginStateError means the CSPRNG failed. It has never been observed;
	// it is enumerated so no branch of the flow is unaccounted for.
	LoginStateError LoginOutcome = "state_error"
)

func (LoginOutcome) Recorded added in v0.0.2

func (o LoginOutcome) Recorded() bool

Recorded reports whether o is a verdict worth counting. A pending login is not an outcome.

type PreflightError added in v0.0.2

type PreflightError struct {
	// Issuer is the configured issuer, always populated — it is the value
	// an operator most likely needs to correct.
	Issuer string

	// Stage is StageDiscovery or StageJWKS.
	Stage string

	// URL is the endpoint that failed.
	URL string

	// Status is the HTTP status when the endpoint answered, 0 when it did
	// not answer at all (DNS, refused connection, TLS, timeout).
	Status int

	// Reason is one of the Preflight* constants.
	Reason string

	// Detail is the human-readable specifics.
	Detail string

	// Resolved carries what the preflight did establish before it failed,
	// which for a StageJWKS failure is the whole discovery document. A
	// diagnostic reports the two stages separately — "the issuer resolved,
	// its keys did not" is a different problem with a different fix — and
	// without this it would have to re-fetch the document to say so.
	// Nil for a StageDiscovery failure, where nothing was established.
	Resolved *PreflightResult

	// Err is the underlying error, if any.
	Err error
}

PreflightError is a failure to resolve an issuer.

It is a type rather than a formatted string because three callers need to branch on it: the doctor turns the reason into a remediation, /readyz turns it into a probe response, and the hub turns it into a startup refusal when the IdP is declared mandatory. Matching prose for any of those would break the first time the wording changed.

func (*PreflightError) Error added in v0.0.2

func (e *PreflightError) Error() string

func (*PreflightError) Remediation added in v0.0.2

func (e *PreflightError) Remediation() string

Remediation is the one-line fix, chosen from the reason. It is phrased for an operator reading `kubectl describe pod` or a doctor report, so it names the setting to change rather than the code path that failed.

func (*PreflightError) Unwrap added in v0.0.2

func (e *PreflightError) Unwrap() error

type PreflightOptions added in v0.0.2

type PreflightOptions struct {
	// Timeout bounds the whole preflight. Zero uses DefaultPreflightTimeout.
	Timeout time.Duration

	// Client performs the requests. Nil builds one bounded by Timeout. A
	// caller supplying one (the doctor, tests) owns its transport and its
	// own timeout.
	Client *http.Client
}

PreflightOptions tunes a standalone preflight. The zero value is usable.

type PreflightResult added in v0.0.2

type PreflightResult struct {
	Issuer                string `json:"issuer"`
	AuthorizationEndpoint string `json:"authorization_endpoint"`
	TokenEndpoint         string `json:"token_endpoint"`
	JWKSURI               string `json:"jwks_uri"`
	EndSessionEndpoint    string `json:"end_session_endpoint,omitempty"`

	// SigningKeys is how many keys in the set cloop can actually verify
	// with, which is the number that decides whether a token validates —
	// not how many the provider published.
	SigningKeys int `json:"signing_keys"`
}

PreflightResult is what a successful preflight resolved: the endpoints a sign-in will actually use, reported so an operator can see them rather than infer them from the issuer.

func Preflight added in v0.0.2

func Preflight(ctx context.Context, issuer string, opts PreflightOptions) (*PreflightResult, error)

Preflight resolves an issuer's endpoints and signing keys without needing an Authenticator — so a diagnostic can run it against an issuer whose client credentials are missing, which is precisely the config a diagnostic exists to report on.

It performs exactly what a sign-in performs before it can begin: fetch the discovery document, check the issuer agrees on its own name, fetch the key set, and count the keys cloop can verify with.

type RefreshResult added in v0.0.2

type RefreshResult struct {
	// RefreshToken is the token to present next time: the provider's
	// replacement when it rotated, otherwise the one just redeemed. Empty
	// clears the stored token, which is how a session that can no longer be
	// revalidated stops being retried.
	RefreshToken string

	// CheckedAt stamps when the IdP last answered for this session.
	CheckedAt time.Time

	// ClaimsAsserted reports whether the refresh response carried an id_token
	// that verified. Only then are Groups and Roles meaningful; when it is
	// false the stored claims must be left exactly as they are, because
	// "the IdP did not say" is not "the IdP said nothing applies".
	ClaimsAsserted bool
	Groups         []string
	Roles          []string

	// ClaimsAsOf and ClaimsExpireAt update the session's claim-freshness clocks
	// (Task 20273). A zero value leaves the stored one alone, which is what
	// makes the three outcomes of a revalidation expressible:
	//
	//	claims re-asserted   both set — the assertion, and how long the IdP
	//	                     stands behind it (from the access token's
	//	                     expires_in, or zero when it named none)
	//	claims repudiated    ClaimsExpireAt set to the instant of the refusal,
	//	                     ClaimsAsOf untouched — the row still records when
	//	                     the claims were last true, and now also records
	//	                     that they have stopped being so
	//	nothing learned      both zero — the grant is alive, the claims are as
	//	                     stale as they were, and nothing may pretend
	//	                     otherwise
	//
	// They are part of RefreshResult, and therefore of its single atomic write,
	// for the reason in the type comment: a stamp written without the claims it
	// stands for is a session that looks re-authorized while holding authority
	// the provider has withdrawn.
	ClaimsAsOf     time.Time
	ClaimsExpireAt time.Time
}

RefreshResult is what one IdP revalidation learned about a session.

It is applied as a unit rather than field by field, and that is a security property rather than an optimisation. The stamp is what marks a session re-authorized until the next interval; the claims are the authority it is re-authorized *with*. A store that wrote the stamp and then failed to write the claims would leave a session recorded as freshly checked while still carrying the roles it held before the IdP narrowed them — the exact retention window this machinery exists to close, now hidden behind a successful-looking check. Written together, a failure leaves the session unstamped and the next pass simply retries it.

type SessionAudit

type SessionAudit struct {
	Event     auditaction.Action
	SessionID string
	Subject   string
	Email     string
	Actor     string
	Reason    string
	IP        string
	UserAgent string
	At        time.Time

	// PriorRole and Role name the effective role before and after a claim
	// refresh, and are set only by AuditSessionRoleNarrowed. They are empty
	// when the deployment supplies no EffectiveRole hook, in which case
	// DroppedClaims alone describes the narrowing.
	PriorRole string
	Role      string

	// DroppedClaims lists the group and role values the IdP released at
	// sign-in and no longer releases. Naming them is the difference between
	// an event an auditor can act on and one that only says something changed.
	DroppedClaims []string
}

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

	// ClaimsAsOf is when the identity provider last asserted Identity's groups
	// and roles, and ClaimsExpireAt is the provider's own deadline on that
	// assertion (Task 20273).
	//
	// They are separate from RefreshCheckedAt because the two answer different
	// questions and routinely disagree. RefreshCheckedAt is stamped on every
	// revalidation attempt including the ones that learned nothing, so it means
	// "the grant was still alive at this instant" — which is what bounds
	// IdP-initiated revocation. These two mean "the claims in this row were
	// true at this instant", which is what bounds authorization staleness. A
	// provider that renews the grant without restating claims advances the
	// first and not the second, and collapsing them would report such a hub as
	// re-authorizing every fifteen minutes while its roles never move.
	//
	// See claimfresh.go for how they are read; ClaimsAssertedAt handles the
	// zero value, which is a session created before these columns existed.
	ClaimsAsOf     time.Time
	ClaimsExpireAt 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.

func (SessionRecord) ClaimAge added in v0.0.2

func (s SessionRecord) ClaimAge(now time.Time) time.Duration

ClaimAge is how long ago the IdP last vouched for this session's claims. Negative ages (a clock that moved backwards) are reported as zero.

func (SessionRecord) ClaimsAssertedAt added in v0.0.2

func (s SessionRecord) ClaimsAssertedAt() time.Time

ClaimsAssertedAt reports when this session's groups and roles were last asserted by the identity provider.

It falls back to IssuedAt when the field is unset, which is both true and what makes the upgrade survivable: a session created before this column existed had its claims asserted exactly once, by the id_token at sign-in, and reporting that as "never" would make every live session on a hub instantly unable to perform a privileged action the moment the binary rolls.

func (SessionRecord) ClaimsStale added in v0.0.2

func (s SessionRecord) ClaimsStale(now time.Time, maxAge time.Duration) bool

ClaimsStale reports whether the session's claims are too old to authorise an operation above operator, under a bound of maxAge.

A non-positive maxAge disables the check entirely — the documented opt-out for a deployment that cannot retain refresh tokens and has accepted the consequence.

Two independent deadlines, whichever comes first:

  • maxAge after the claims were asserted. cloop's own bound.
  • ClaimsExpireAt, the provider's. Set from the access token's expires_in when claims were last read, and set to the instant of the refusal when the provider repudiated them — which is what makes a rejected claim read stale immediately and stay that way until a later read succeeds.

func (SessionRecord) Expired

func (s SessionRecord) Expired(now time.Time) bool

Expired reports whether the session is past its absolute ceiling.

func (SessionRecord) Idle

func (s SessionRecord) Idle(now time.Time, timeout time.Duration) bool

Idle reports whether the session has gone unused for longer than timeout. A non-positive timeout disables the idle clock.

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)

	// ApplyRefresh records the outcome of one IdP revalidation. It must apply
	// every field of res in a single atomic write — see RefreshResult for why
	// splitting it is a privilege-retention bug.
	ApplyRefresh(id string, res RefreshResult) 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.

Jump to

Keyboard shortcuts

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