oidcauth

package
v0.0.5 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 29 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 (
	// DeviceCodeLifetime is how long a code may wait for approval. RFC 8628
	// leaves it to the server; ten minutes is enough to switch to a browser and
	// sign in, and short enough that a phished code goes stale before most
	// victims open the message carrying it.
	DeviceCodeLifetime = 10 * time.Minute

	// DefaultDevicePollInterval is how often a terminal may poll.
	DefaultDevicePollInterval = 5 * time.Second

	// DefaultDeviceApprovalMaxAge is used when Config.DeviceApprovalMaxAge is
	// zero: a browser session signed in longer ago than this must sign in
	// again at the identity provider to approve a terminal. Two hours, which
	// is about the "sudo mode" window forges use for minting credentials: a
	// developer approving the code their own terminal just printed is rarely
	// asked, and a browser left signed in since yesterday always is.
	DefaultDeviceApprovalMaxAge = 2 * time.Hour

	// UserCodeLength is how many letters a user code has, drawn from
	// userCodeAlphabet: 20^10 ≈ 2^43 codes, past the 40 bits the grant asks
	// for, and still short enough to read off one screen and type into
	// another. Shown as two groups of five.
	UserCodeLength = 10

	// DevicePath is the approval page. The hub serves it; this package names
	// it because the approval returns there.
	DevicePath = "/device"

	// DeviceGrantType is the grant_type the token endpoint takes (RFC 8628
	// §3.4).
	DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"

	// CLICredentialPrefix begins every terminal session credential, so the
	// hub can tell one from an API token or the static token by its shape and
	// send it to the session store without trying the others.
	CLICredentialPrefix = "cloop_cli_"
)
View Source
const (
	DeviceErrPending      = "authorization_pending"
	DeviceErrSlowDown     = "slow_down"
	DeviceErrExpired      = "expired_token"
	DeviceErrDenied       = "access_denied"
	DeviceErrInvalidGrant = "invalid_grant"
)

The errors the token endpoint answers with (RFC 8628 §3.5; invalid_grant from RFC 6749 §5.2 for a code it does not know or has already exchanged). They are wire values.

View Source
const (
	// AuditDeviceCodeIssued records a terminal asking for a code.
	AuditDeviceCodeIssued = auditaction.ActionDeviceCodeIssued
	// AuditDeviceApproved records a code approved, and the session it became.
	AuditDeviceApproved = auditaction.ActionDeviceApproved
	// AuditDeviceDenied records a code denied, by its person or by the hub.
	AuditDeviceDenied = auditaction.ActionDeviceDenied
)

The device authorization audit events (Task 20412). Wire values.

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 (
	LoginPath  = "/auth/login"
	LogoutPath = "/auth/logout"
	RenewPath  = "/auth/renew"
)

LoginPath, LogoutPath and RenewPath are the hub's other routes under /auth/, with ScriptPath (script.go). They are named here, beside the callback rule, because the callback shares their subtree: the hub registers GET LoginPath, GET RenewPath and GET ScriptPath as routes, and the router panics at startup on a second registration of any of them rather than serving them. (LogoutPath is a POST, which a GET callback does not collide with.)

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

	// AuditSessionRenewalMismatch records a silent renewal the identity
	// provider answered for a different subject than the session belongs to
	// (Task 20359). The session is left exactly as it was. See
	// noteRenewalMismatch for why this is worth a row.
	AuditSessionRenewalMismatch = auditaction.ActionSessionRenewalMismatch
)

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 (
	// ReasonAbsoluteTTL: the session reached the ceiling set at sign-in.
	ReasonAbsoluteTTL = "absolute_ttl"
	// ReasonIdleTimeout: the session went unused past the idle timeout.
	ReasonIdleTimeout = "idle_timeout"
	// ReasonUserLogout: the user signed out of this session.
	ReasonUserLogout = "user_logout"
	// ReasonLogoutAll: the user ended every other session of theirs.
	ReasonLogoutAll = "logout_all"
	// ReasonSessionQuota: the identity's session quota made room for a new
	// sign-in by ending its least recently used session.
	ReasonSessionQuota = "session_quota_exceeded"
	// ReasonAdminRevoked: an operator terminated the session and gave no
	// reason of their own.
	ReasonAdminRevoked = "admin_revoked"
)

The Reason a session.expired or session.revoked event carries for each way a session ends that is not the IdP's doing (a session.idp_revoked event names the provider's own error instead). Exported so a consumer of the audit sink — the hub's metrics — can tell them apart by constant rather than by a literal that would drift the day one of them is renamed here.

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.

View Source
const ReasonDeviceUncollected = "device_uncollected"

ReasonDeviceUncollected is the Reason a session.revoked event carries for a terminal session whose approval the terminal never collected.

View Source
const ScriptPath = "/auth/sign-in.js"

ScriptPath is where the sign-in documents' script is served. The hub registers it as a route beside LoginPath and RenewPath, and the callback rule refuses it for the same reason it refuses them.

Variables

View Source
var (
	// ErrDeviceLoginDisabled: this hub does not sign terminals in.
	ErrDeviceLoginDisabled = errors.New("oidcauth: terminal sign-in is not enabled on this hub")

	// ErrNeedsBrowserSession: approving, denying or even looking up a code
	// takes a signed-in browser session. A terminal session approving
	// another terminal would be a credential that copies itself, and an API
	// token approving one would be minting a session with no person behind it.
	ErrNeedsBrowserSession = errors.New("oidcauth: approving a terminal sign-in takes a browser signed in to this hub")

	// ErrDeviceExpired: the code is past its lifetime.
	ErrDeviceExpired = errors.New("oidcauth: this code has expired — start `cloop login` again")

	// ErrDeviceDecided: the code was already approved or denied.
	ErrDeviceDecided = errors.New("oidcauth: this code has already been used")

	// ErrDeviceRequestInvalid: the terminal's request is malformed.
	ErrDeviceRequestInvalid = errors.New("oidcauth: invalid device authorization request")

	// ErrDeviceBusy: the hub-wide cap on codes in flight is reached.
	ErrDeviceBusy = errors.New("oidcauth: too many terminal sign-ins are in progress on this hub — try again in a few minutes")
)

Errors the approval side answers with.

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 ErrDeviceNotFound = errors.New("oidcauth: device authorization not found")

ErrDeviceNotFound is what a store returns for a device code or user code it does not hold: mistyped, never issued, or expired and swept. All three are answered alike.

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 CLISessionID added in v0.0.5

func CLISessionID(credentialHash string) string

CLISessionID is the id of the session a terminal holds, from the digest of its credential that it committed to when it asked for a code. Not the commitment itself: see "No credential is ever handed out" above.

func CookieSecureFollowsRequest added in v0.0.5

func CookieSecureFollowsRequest(mode string) bool

CookieSecureFollowsRequest reports whether a cookie_secure mode takes the session cookie's Secure flag from the request — its own TLS, or a proxy's X-Forwarded-Proto: https — rather than fixing it: "auto", and unset. Behind a TLS-terminating proxy that header is then what keeps the flag on, which `cloop hub doctor` states only where it is true (Task 20387).

func DeviceID added in v0.0.5

func DeviceID(deviceHash string) string

DeviceID is the public name of an authorization in the audit trail and the hub's log: a prefix of its device code's digest. Like a session id it names the row without yielding anything to present.

func FormatUserCode added in v0.0.5

func FormatUserCode(code string) string

FormatUserCode renders a canonical code the way people read it: two groups of five.

func ForwardedProtoHTTPS added in v0.0.5

func ForwardedProtoHTTPS(h http.Header) bool

ForwardedProtoHTTPS reads the client-facing hop from X-Forwarded-Proto: a proxy chain may append ("https, http"), and the first entry is the one that decided the browser's view. The cookie used to compare the whole header, so a chain that sends "https, http" got HSTS and a session cookie without Secure. Whether the header is believed at all is the caller's question — see Config.RequestIsTLS.

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 IsCLICredential added in v0.0.5

func IsCLICredential(s string) bool

IsCLICredential reports whether s has the shape of a terminal session credential: the prefix and 32 bytes of unpadded base64url.

func NewCLICredential added in v0.0.5

func NewCLICredential() (credential, digest string, err error)

NewCLICredential returns a fresh terminal session credential and the digest a terminal sends when it asks for a code. The credential never leaves the terminal except as its own bearer.

func NormalizeUserCode added in v0.0.5

func NormalizeUserCode(s string) string

NormalizeUserCode turns what a person typed into the canonical code, or "" when it cannot be one. Case, spaces and dashes are forgiven, as RFC 8628 §6.1 asks; any other character is refused rather than dropped, so a pasted paragraph does not happen to normalise into somebody's code.

func PresentsCLICredential added in v0.0.5

func PresentsCLICredential(r *http.Request) bool

PresentsCLICredential reports whether r carries a terminal credential in its Authorization header — live, ended or malformed. The auth gate asks it to answer an ended terminal session itself, rather than let the bearer fall through to the API-token and static-token checks it was never meant for.

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.

func ServeScript added in v0.0.5

func ServeScript(w http.ResponseWriter, r *http.Request)

ServeScript serves the sign-in documents' script. It needs no authenticator — the script is the same on every hub — so it works, and is harmless, on a hub without single sign-on too.

func StateOwner added in v0.0.5

func StateOwner(state string) string

StateOwner returns the StatePrefix a login's state parameter carries, or "" when it carries none.

func ValidateRedirectURL added in v0.0.5

func ValidateRedirectURL(raw string) (*url.URL, error)

ValidateRedirectURL is New's rule for redirect_url, and the parsed URL when it holds: present, parseable, and with a path under /auth/ that the hub can serve. CallbackPath serves exactly that path, so any path that passes here is a working callback — /auth/callback is only the default, and an Entra SPA registration is commonly /auth/oidc.

Exported so that a diagnostic asks this rule rather than restating it. `cloop hub doctor` once compared the path against /auth/callback and failed a working hub registered at /auth/oidc (Task 20387); a copy of a rule drifts the first time one side changes, and the doctor's failure mode is a confident verdict the hub does not share.

The value is checked as given, without trimming whitespace, because New receives it untrimmed: a check that cleaned it first would approve a string the hub then refuses.

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) BeginDeviceApproval added in v0.0.5

func (a *Authenticator) BeginDeviceApproval(ctx context.Context, r *http.Request, userCode string) (DeviceApproval, error)

BeginDeviceApproval starts approving a code: it checks the code can still be approved by this browser session, and returns the authorization request that will complete it.

The code is not marked approved here. It is marked when the identity provider has answered, on the callback, so a browser that is sent to the provider and never comes back leaves the code pending — to be approved again or to expire — rather than approved with nothing behind it.

func (*Authenticator) BeginDeviceAuthorization added in v0.0.5

func (a *Authenticator) BeginDeviceAuthorization(req DeviceRequest) (DeviceGrant, error)

BeginDeviceAuthorization issues a device code and a user code.

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.

A `return` query parameter names the dashboard path to land on afterwards, so a session that lapsed while somebody was deep in a project does not also cost them their place. safeReturnPath narrows it to a path on this origin; anything else is dropped, which is what keeps a public login route from being an open redirector.

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

func (*Authenticator) BeginRenew added in v0.0.5

BeginRenew starts a silent re-assertion of the caller's session's claims.

Every response is a document that reports its verdict to the parent frame, failures included: a frame cannot surface an HTTP status, so a 401 here would be a renewal that hangs until the dashboard's own timeout rather than one that fails in milliseconds.

func (*Authenticator) BrowserRenewIn added in v0.0.5

func (a *Authenticator) BrowserRenewIn(id string) (time.Duration, bool)

BrowserRenewIn reports how long the browser holding session id should wait before re-asserting its claims through /auth/renew, and whether it should on a timer at all.

It should not when the freshness bound is off — the claims never go stale — or when the hub holds a refresh token for the session. The server-side refresh is the first layer: EnsureFreshClaims redeems the token on the request that needs current claims, no browser involved. A timer on top of it would cost an IdP round trip per open tab per window and, wherever a browser blocks third-party cookies, raise a sign-in banner over a session the hub could renew by itself. The dashboard still renews reactively when that layer fails; see ClaimFreshnessError.Renewable.

The stored row is read rather than the cached one: the cache holds no refresh token by design, and may be a moment behind a renewal another hub process just applied.

func (*Authenticator) CLIIdleTimeout added in v0.0.5

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

CLIIdleTimeout is a terminal session's idle window (zero or less: off).

func (*Authenticator) CLISessionTTL added in v0.0.5

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

CLISessionTTL is a terminal session's absolute lifetime.

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) CheckSession added in v0.0.5

func (a *Authenticator) CheckSession(id string) (SessionRecord, SessionState)

CheckSession reports whether the session with hashed id would authenticate a request now, without counting the question as the user being active.

For a caller that holds a session past the request that presented it — a WebSocket or SSE stream, an open sandbox terminal — and has to ask again on a timer whether it still may (Task 20398). SessionFromRequest is the wrong call there: it advances the idle clock, so a stream that re-checked through it every thirty seconds would keep an unattended tab's session alive for ever, which is the one thing the idle clock exists to prevent.

It is the request path's verdict otherwise — the same cache, the same two clocks — so a stream is closed exactly when a request carrying its cookie would be refused. Like the request path it ends a session it finds past either clock, so the expiry is audited at the moment it is noticed.

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) DenyDevice added in v0.0.5

func (a *Authenticator) DenyDevice(r *http.Request, userCode string) error

DenyDevice refuses a code on behalf of the signed-in browser.

func (*Authenticator) DeviceApprovalMaxAge added in v0.0.5

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

DeviceApprovalMaxAge is how old a browser session may be and still approve a terminal without signing in again (zero or less: never asked).

func (*Authenticator) DeviceLoginEnabled added in v0.0.5

func (a *Authenticator) DeviceLoginEnabled() bool

DeviceLoginEnabled reports whether this hub signs terminals in. Safe on nil.

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) EvictAllCachedSessions added in v0.0.5

func (a *Authenticator) EvictAllCachedSessions()

EvictAllCachedSessions drops every cached session — for when the change another process made cannot be narrowed to one (a bulk logout).

func (*Authenticator) EvictCachedSession added in v0.0.5

func (a *Authenticator) EvictCachedSession(sessionID string)

EvictCachedSession drops this process's cached copy of a session another process changed, without telling anyone (Task 20354). Compare invalidateCache, which is for changes made here.

func (*Authenticator) ExchangeDeviceCode added in v0.0.5

func (a *Authenticator) ExchangeDeviceCode(deviceCode string) (DeviceSession, error)

ExchangeDeviceCode is one poll: it answers with the session once the code is approved, and otherwise with a *DeviceTokenError saying why not.

func (*Authenticator) FrameOrigins added in v0.0.5

func (a *Authenticator) FrameOrigins() []string

FrameOrigins returns the identity provider origins a renewal frame navigates to, for the hub's Content-Security-Policy frame-src.

A frame's navigations, redirects included, are checked against the *framing* document's frame-src, with hosts compared exactly — so under a bare frame-src 'self' the browser blocks the hop from /auth/renew to the provider and the renewal fails in a way that looks like the provider timing out.

The issuer and the authorization endpoint are both reported, since a provider may serve them from different hosts. The latter is known once discovery has run, which the hub does at startup. Lock-free: the hub reads this for every response it sends.

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.

A silent renewal returns here too, since the provider knows one redirect URI for this client, and is told apart by its state alone. The state is therefore claimed before anything else is decided, including whether the provider reported an error: it says how the response must be rendered, and a renewal has to answer its hidden frame with a message rather than with an error page nobody will see.

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) LiveSessionCount added in v0.0.5

func (a *Authenticator) LiveSessionCount() (int, error)

LiveSessionCount returns how many sessions are valid now: neither past their absolute ceiling nor idle beyond the timeout, as the store records them. The store's last-seen clock is written at most once a minute, so a session's idle time can read up to that much long. Zero when sign-on is off.

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) LookupDevice added in v0.0.5

func (a *Authenticator) LookupDevice(r *http.Request, userCode string) (DeviceView, error)

LookupDevice resolves a code for the approval page. It takes the same browser session approving does, and answers ErrDeviceNotFound for a code that does not exist.

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) PeekIdentity added in v0.0.5

func (a *Authenticator) PeekIdentity(r *http.Request) *Identity

PeekIdentity is IdentityFromRequest for a request a page makes on its own behalf — a browser's Content-Security-Policy report is one: it names the session's user without counting the request as that user being active, as peekSession explains.

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) RedirectURL added in v0.0.5

func (a *Authenticator) RedirectURL() string

RedirectURL returns the configured redirect_url: the callback browsers are sent back to, and so the origin they sign in at ("" when disabled). Safe on nil.

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) SessionClock added in v0.0.5

func (a *Authenticator) SessionClock(rec SessionRecord) SessionClock

SessionClock reports rec's clocks as of now, on this authenticator's clock.

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) SweepDevices added in v0.0.5

func (a *Authenticator) SweepDevices() int

SweepDevices removes authorizations past their expiry and the grace after it, and ends the session behind any that was approved and never collected: a session nobody took is a credential with nobody to revoke it. A pending one is looked at too, for the session a hub that stopped between writing it and recording the approval left behind. Returns how many authorizations it removed.

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) Renewable added in v0.0.5

func (e *ClaimFreshnessError) Renewable() bool

Renewable reports whether a silent renewal from the user's browser could clear this refusal (Task 20359).

True for the two reasons that are about the hub's inability to *ask*: it holds no refresh token, or the provider could not be reached from the server. The user is very likely still signed in at the provider, and a prompt=none round trip from their browser re-asserts the claims without them seeing anything.

False for claims_rejected, where the provider looked at this session and declined to vouch for it. Asking again from the browser does not change that answer at the next server-side refresh, so retrying on it would only loop. Decided here, not by the caller comparing reasons, so a reason added later cannot default to "retry".

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)

	// StatePrefix, when set, begins every login's state parameter, followed
	// by a dot (Task 20354). The state is the one value the identity
	// provider hands back unchanged on the callback, and a login's nonce and
	// PKCE verifier live only in the memory of the process that began it —
	// so on a control plane served by several hub processes the state has to
	// say which one that was, and the process the callback reaches forwards
	// it there. See StateOwner. It must not contain a dot.
	StatePrefix string

	// OnCacheInvalidate, when set, is told each time a session's cached copy
	// is dropped because the session changed — revoked, logged out, expired,
	// or its claims refreshed (Task 20354). Other hub processes sharing the
	// session store keep their own caches, and without this they would go on
	// honouring a revoked session until their copy aged out. It must not
	// block.
	//
	// It is called once the change is in the store, so a listener that
	// re-reads the session on the strength of it — CheckSession, to decide
	// whether a stream opened with it may stay open (Task 20398) — reads the
	// new row, or its absence. It names the session, not the change: a claim
	// refresh is announced exactly as a revocation is, so a listener must
	// re-check rather than conclude.
	OnCacheInvalidate func(sessionID string)

	// RefreshLock, when set, serialises refresh-token redemptions for one
	// session across every process sharing the session store (Task 20354).
	// It returns once the caller holds the right to redeem, and the release
	// to call afterwards. A single process collapses concurrent refreshes of
	// one session into one flight; several processes cannot see each other's
	// flights, and a provider that rotates refresh tokens answers the second
	// redemption of one token with invalid_grant — which ends the session. A
	// dashboard's page load fans out across processes, so without this the
	// first privileged page load after the claims went stale would sign the
	// user out.
	RefreshLock func(ctx context.Context, sessionID string) (release func(), err error)

	// RenewObserver receives the verdict of every silent renewal that reached
	// one (Task 20359). Nil discards them.
	//
	// A callback for the same reason Audit is one: the outcome belongs in
	// hubmetrics, which is not stdlib. It is also the only way the verdict
	// leaves this package, because a renewal completes inside HandleCallback,
	// whose return value describes sign-ins — overloading that would put a
	// background re-check into the ratio an operator reads as "can people sign
	// in". Called on verdicts only, never on the redirect to the provider.
	// Implementations must not block.
	RenewObserver func(RenewOutcome)

	// DeviceLogin turns on the device authorization grant that signs a
	// terminal in (Task 20412, device.go): `cloop login` against this hub.
	DeviceLogin bool

	// CLISessionTTL and CLIIdleTimeout are a terminal session's two clocks.
	// Zero uses the browser's; anything longer than the browser's is held
	// down to it, because a terminal session — the credential that sits in a
	// file — is never longer-lived than a browser one. A negative
	// CLIIdleTimeout turns the terminal's idle clock off only where the
	// browser's is off too.
	CLISessionTTL  time.Duration
	CLIIdleTimeout time.Duration

	// DeviceApprovalMaxAge is how long ago a browser session may have signed
	// in and still approve a terminal without signing in again at the
	// identity provider. Zero uses DefaultDeviceApprovalMaxAge; negative
	// never asks.
	DeviceApprovalMaxAge time.Duration

	// Devices persists device authorizations. Nil uses Store when it is also
	// a DeviceStore (pkg/sessionstore is), and process memory otherwise.
	Devices DeviceStore

	// SessionGate is asked before a terminal session is written for an
	// approved code, with the identity the provider just asserted; an error
	// refuses it and denies the code. pkg/ui supplies a check of the runtime
	// deny bindings, so an account being offboarded cannot start a terminal
	// session on the way out. Nil admits everyone the provider vouches for.
	SessionGate func(Identity) error

	// RequestIsTLS reports whether a request reached the hub over TLS as the
	// browser sees it: the hub's own listener, or a proxy the hub trusts that
	// said so in X-Forwarded-Proto (Task 20394). cookie_secure "auto"
	// follows it. Nil believes X-Forwarded-Proto from a loopback peer only —
	// the hub's rule with no ui.trusted_proxies — because the header is
	// anybody's to send, and a session cookie's attributes are not theirs to
	// choose.
	RequestIsTLS func(*http.Request) bool
}

Config holds the relying-party settings, typically mapped from config.OIDCConfig (ui.oidc.* in .cloop/config.yaml).

type ConfigError added in v0.0.5

type ConfigError struct {
	Field string
	Err   error
}

ConfigError is New's refusal of one setting: Field names it as a ui.oidc block does — "issuer", "client_id", "redirect_url", "cookie_secure", "max_claim_age", "clock_skew" — and the message is New's own. It exists so a caller can say which setting to fix without matching on the message, and so `cloop hub doctor` can set one refusal aside to find the next: New stops at the first, and a bad issuer used to hide an empty client id (Task 20387).

func (*ConfigError) Error added in v0.0.5

func (e *ConfigError) Error() string

func (*ConfigError) Unwrap added in v0.0.5

func (e *ConfigError) Unwrap() error

type DeviceApproval added in v0.0.5

type DeviceApproval struct {
	// RedirectURL is the identity provider's authorization endpoint. The
	// approval completes when the provider sends the browser back.
	RedirectURL string
	// Reauth reports that the provider was asked to have the person sign in
	// again (prompt=login, max_age=0).
	Reauth bool
}

DeviceApproval is where the approval page sends the browser next.

type DeviceAuditDetail added in v0.0.5

type DeviceAuditDetail struct {
	ID              string
	ClientIP        string
	ClientUserAgent string
	ClientHostname  string
	ExpiresAt       time.Time
	// Reauth reports that approving it demanded a fresh sign-in at the
	// identity provider.
	Reauth bool
}

DeviceAuditDetail is what a device.* event says about the authorization. Nothing in it is a credential: ID is a prefix of the device code's digest.

type DeviceGrant added in v0.0.5

type DeviceGrant struct {
	DeviceCode string
	// UserCode is formatted for reading: two groups of five.
	UserCode  string
	ExpiresIn time.Duration
	Interval  time.Duration
}

DeviceGrant is what the terminal is told: RFC 8628 §3.2's response, less the verification URIs, which the hub knows and this package does not.

type DeviceRecord added in v0.0.5

type DeviceRecord struct {
	DeviceHash     string
	UserCodeHash   string
	CredentialHash string
	Status         DeviceStatus

	// ClientIP, ClientUserAgent and ClientHostname describe who asked: the
	// first two as the hub saw the request, the last as the terminal reported
	// it. Labels for the person deciding, never inputs to the decision.
	ClientIP        string
	ClientUserAgent string
	ClientHostname  string

	CreatedAt time.Time
	ExpiresAt time.Time

	// Interval is how often the terminal may poll, and LastPolledAt when it
	// last did. Polling faster earns slow_down and a longer interval.
	Interval     time.Duration
	LastPolledAt time.Time

	// Approver is who decided (an owner key, or "hub" when the hub denied it
	// on someone's behalf), ApproverSession the hashed id of the browser
	// session that did, Subject their IdP subject, and Reason why a denial
	// happened.
	Approver        string
	ApproverSession string
	Subject         string
	DecidedAt       time.Time
	Reason          string
}

DeviceRecord is one device authorization as stored.

It holds no credential. DeviceHash and UserCodeHash are digests of the two codes, and CredentialHash is the digest of the session credential the terminal generated and kept — the approval writes the session under CLISessionID of it, so nothing here can be presented as one. It is cleared once the session is collected.

type DeviceRequest added in v0.0.5

type DeviceRequest struct {
	// CredentialHash is HashSessionID of the credential the terminal generated
	// and will present once approved: 64 lower-case hex digits.
	CredentialHash string
	// Hostname is what the terminal calls the machine it runs on.
	Hostname string
	// IP and UserAgent are the request's, as the hub saw it.
	IP        string
	UserAgent string
}

DeviceRequest is what a terminal sends when it asks for a code.

type DeviceSession added in v0.0.5

type DeviceSession struct {
	// SessionID is the session's public id — the digest the terminal
	// committed to — as the Active Sessions table lists it.
	SessionID string
	Identity  Identity
	ExpiresAt time.Time
	// ExpiresIn is what is left of the session's absolute lifetime, on the
	// authenticator's clock.
	ExpiresIn time.Duration
	// IdleTimeout is the idle window, zero when the clock is off.
	IdleTimeout time.Duration
	// Revalidated reports that the hub keeps a refresh grant for the session,
	// so the identity provider is asked about it again: a demotion or a
	// disabled account reaches it, and privileged actions can re-check their
	// claims. False on a hub with no CLOOP_SECRET_KEY, or a provider that
	// issues no refresh token.
	Revalidated bool
}

DeviceSession is what the token endpoint tells a terminal whose code was approved. It carries no credential: the terminal has held its own since it asked for the code.

type DeviceStatus added in v0.0.5

type DeviceStatus string

DeviceStatus is where a device authorization is in its life.

const (
	// DeviceStatusPending: issued, and nobody has approved or denied it yet.
	DeviceStatusPending DeviceStatus = "pending"
	// DeviceStatusApproved: a signed-in browser approved it, and the session
	// it names exists; the terminal has not collected the approval yet.
	DeviceStatusApproved DeviceStatus = "approved"
	// DeviceStatusDenied: refused — by the person it was shown to, or by the
	// hub on their behalf (they were offboarded, or signed out everywhere).
	DeviceStatusDenied DeviceStatus = "denied"
	// DeviceStatusConsumed: approved and collected. A second exchange of the
	// same device code is refused: a code is single-use.
	DeviceStatusConsumed DeviceStatus = "consumed"
)

type DeviceStore added in v0.0.5

type DeviceStore interface {
	// PutDevice records a new authorization. It must not overwrite one.
	PutDevice(DeviceRecord) error

	// GetDevice returns the authorization for a device code digest, or an
	// error wrapping ErrDeviceNotFound.
	GetDevice(deviceHash string) (DeviceRecord, error)

	// GetDeviceByUserCode returns the newest authorization for a user code
	// digest, or an error wrapping ErrDeviceNotFound.
	GetDeviceByUserCode(userCodeHash string) (DeviceRecord, error)

	// UpdateDevice reads the authorization for deviceHash, applies change
	// to it, and writes it back if change reports a change — atomically. It
	// returns the record as stored afterwards. change must not call the
	// store.
	UpdateDevice(deviceHash string, change func(*DeviceRecord) (bool, error)) (DeviceRecord, error)

	// DeleteDevicesBefore removes the authorizations whose ExpiresAt is at or
	// before cutoff, returning them.
	DeleteDevicesBefore(cutoff time.Time) ([]DeviceRecord, error)

	// CountDevicesSince counts the authorizations issued after since: the
	// hub-wide issuing rate.
	CountDevicesSince(since time.Time) (int, error)
}

DeviceStore persists device authorizations. Implementations must be safe for concurrent use, and — on a control plane several hub processes share — must serialise UpdateDevice across all of them: an approval racing a denial, or two polls racing the exchange, has to see one winner.

func NewMemoryDeviceStore added in v0.0.5

func NewMemoryDeviceStore() DeviceStore

NewMemoryDeviceStore returns a process-local device store.

type DeviceTokenError added in v0.0.5

type DeviceTokenError struct {
	// Code is one of the DeviceErr* values.
	Code string
	// Description says it in words, for the terminal to print.
	Description string
	// Interval is the polling interval from now on; set with slow_down.
	Interval time.Duration
}

DeviceTokenError is the token endpoint's answer to a poll that did not yield a session.

func (*DeviceTokenError) Error added in v0.0.5

func (e *DeviceTokenError) Error() string

type DeviceView added in v0.0.5

type DeviceView struct {
	// UserCode is the code, formatted.
	UserCode string
	Status   DeviceStatus
	Expired  bool

	// Who is asking, as the hub saw them and as the terminal said. Empty
	// once somebody else decided the code: what another person's terminal
	// is called is none of the caller's business.
	ClientIP        string
	ClientUserAgent string
	ClientHostname  string
	CreatedAt       time.Time
	ExpiresAt       time.Time
	// ExpiresIn is what is left of the code's life, on the authenticator's
	// clock; zero once it has run out.
	ExpiresIn time.Duration

	// DecidedByCaller reports that the person asking decided the code.
	DecidedByCaller bool
	DecidedAt       time.Time

	// Reauth reports that approving will ask the caller to sign in again at
	// the identity provider, and SignedInAt when their browser session did.
	Reauth     bool
	SignedInAt time.Time

	// Identity is who approving would sign the terminal in as — the caller.
	Identity Identity
}

DeviceView is what the approval page shows about a code.

type ExpiryCutoffs added in v0.0.5

type ExpiryCutoffs struct {
	Absolute time.Time
	Idle     time.Time
	CLIIdle  time.Time
}

ExpiryCutoffs are the absolute times a store compares sessions against to decide which have ended: Absolute against ExpiresAt for every kind, and the two idle cutoffs against LastSeen — Idle for browser sessions, CLIIdle for terminal ones, whose idle clock is their own (Task 20412). A zero idle cutoff matches no session with a recorded LastSeen, which is how a disabled idle clock is expressed.

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

	// Username is the preferred_username claim, and OID Microsoft Entra
	// ID's oid claim — the account's login and directory object id at the
	// provider (Task 20415). They are what a SCIM directory is matched
	// against (ui.oidc.scim.match_claim), and what a deny binding written
	// when the provider deactivates the account names. Empty when the
	// provider released neither.
	Username string `json:"username,omitempty"`
	OID      string `json:"oid,omitempty"`

	// AuthTime is the id_token's auth_time: when the person last actually
	// authenticated at the provider, as opposed to when the token was minted.
	// Read once, by an approval that demanded a fresh sign-in (device.go), and
	// never stored or serialised.
	AuthTime time.Time `json:"-"`
}

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 LiveCounter added in v0.0.5

type LiveCounter interface {
	CountLive(c ExpiryCutoffs) (int, error)
}

LiveCounter is a SessionStore that can count valid sessions itself, under the same cutoffs DeleteExpired takes. Optional: LiveSessionCount falls back to listing the rows, but a store that can answer with a count should — a listing of the durable store unseals every refresh token in it, which is no thing to do for the sake of a number.

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"

	// LoginRenewal means the callback completed a silent renewal rather than a
	// sign-in (Task 20359). It shares the callback route because the provider
	// knows one redirect URI for this client, and it is not a sign-in verdict:
	// counting it would put a background re-check into the ratio an operator
	// reads as "can people sign in". Its own verdict goes to
	// Config.RenewObserver.
	LoginRenewal LoginOutcome = "renewal"

	// LoginDevice means the callback completed the approval of a terminal's
	// code rather than a sign-in (Task 20412). Not a sign-in verdict either:
	// it shares the callback route for the reason a renewal does, and its
	// outcome is in the device.* audit events.
	LoginDevice LoginOutcome = "device_approval"
)

func (LoginOutcome) Recorded added in v0.0.2

func (o LoginOutcome) Recorded() bool

Recorded reports whether o is a verdict worth counting. Neither a pending login, a silent renewal nor a terminal's approval is a sign-in verdict.

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 RenewOutcome added in v0.0.5

type RenewOutcome string

RenewOutcome is the verdict of one silent renewal: the metric label, and the value the dashboard switches on. A closed set.

const (
	// RenewPending is the zero value: the frame was sent to the provider and
	// there is no verdict yet. Returned by BeginRenew on the redirect, the way
	// BeginLogin returns LoginPending.
	RenewPending RenewOutcome = ""

	// RenewOK: the provider re-asserted the claims and the session holds them.
	RenewOK RenewOutcome = "ok"

	// RenewNoSession: there is no live session to renew — the request carried
	// no valid cookie, or the session ended during the round trip.
	RenewNoSession RenewOutcome = "no_session"

	// RenewDisabled: this hub does not use single sign-on.
	RenewDisabled RenewOutcome = "not_enabled"

	// RenewInteractionRequired: the provider will not answer without showing
	// the user something. It is what prompt=none is *for*, not a fault — and
	// it is also what a browser that blocks the provider's cookies in a frame
	// produces, since the provider then cannot see its own session.
	RenewInteractionRequired RenewOutcome = "interaction_required"

	// RenewIdPError: the provider refused for some other reason it named.
	RenewIdPError RenewOutcome = "idp_error"

	// RenewDiscoveryFailed: the issuer could not be resolved.
	RenewDiscoveryFailed RenewOutcome = "discovery_failed"

	// RenewStateError: the CSPRNG failed. Enumerated so no branch is unaccounted for.
	RenewStateError RenewOutcome = "state_error"

	// RenewInvalidState: the provider came back without an authorization code
	// or an error.
	RenewInvalidState RenewOutcome = "invalid_state"

	// RenewExchangeFailed: the token endpoint refused the code.
	RenewExchangeFailed RenewOutcome = "exchange_failed"

	// RenewTokenInvalid: the returned id_token failed validation.
	RenewTokenInvalid RenewOutcome = "token_invalid"

	// RenewSubjectMismatch: the provider answered for a different user than the
	// session belongs to. The session is left exactly as it was — adopting the
	// other subject's claims would hand it somebody else's authority.
	RenewSubjectMismatch RenewOutcome = "subject_mismatch"

	// RenewStoreError: the re-asserted claims could not be written.
	RenewStoreError RenewOutcome = "store_error"
)

func (RenewOutcome) Recorded added in v0.0.5

func (o RenewOutcome) Recorded() bool

Recorded reports whether o is a verdict worth counting.

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

	// Kind and Client describe the session the event is about (Task 20412):
	// browser or cli, and for a terminal the host name it reported. Empty
	// Kind means the emitter did not know, which the sink leaves out.
	Kind   SessionKind
	Client string

	// Device is set on the device.* events and names the authorization; the
	// sink files those under it rather than under SessionID.
	Device *DeviceAuditDetail

	// Via names the path an event arrived by when it was not the hub's own
	// doing: "browser_renewal" for claims a silent renewal re-asserted from
	// the user's browser (Task 20359). Empty for the server-side refresh.
	Via 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 SessionClock added in v0.0.5

type SessionClock struct {
	// ClaimAge is how old the session's claims are, and MaxClaimAge the bound
	// they are held to; zero MaxClaimAge means the bound is off.
	ClaimAge    time.Duration
	MaxClaimAge time.Duration

	// Renew reports whether the browser should re-assert the claims through
	// /auth/renew on a timer, and RenewIn when. See BrowserRenewIn.
	Renew   bool
	RenewIn time.Duration

	// ExpiresIn is what is left of the absolute lifetime, which no renewal
	// lifts. Meaningful when HasExpiry.
	ExpiresIn time.Duration
	HasExpiry bool
}

SessionClock is what a browser is told about the clocks on its session, so it can act before they run out rather than meet them as a refused click.

type SessionKind added in v0.0.5

type SessionKind string

SessionKind is what a session authenticates (Task 20412).

const (
	// SessionKindBrowser authenticates by the session cookie.
	SessionKindBrowser SessionKind = "browser"
	// SessionKindCLI authenticates by `Authorization: Bearer cloop_cli_…`.
	SessionKindCLI SessionKind = "cli"
)

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

	// Kind is what the session authenticates (Task 20412): a browser, by its
	// cookie, or a terminal, by `Authorization: Bearer cloop_cli_…`. Neither
	// is accepted the other way. Empty reads as a browser session — see
	// IsCLI — and Client is the host name a terminal reported, a label like
	// IP and UserAgent.
	Kind   SessionKind
	Client string
}

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.

func (SessionRecord) IsCLI added in v0.0.5

func (s SessionRecord) IsCLI() bool

IsCLI reports whether rec is a terminal session. A record with no kind is a browser session: it predates the column, when that was the only kind.

func (SessionRecord) KindName added in v0.0.5

func (s SessionRecord) KindName() string

KindName is rec's kind as a word, for listings and the audit trail.

type SessionState added in v0.0.5

type SessionState string

SessionState is what CheckSession found: whether a session would still authenticate a request, and if not, why.

const (
	// SessionLive: the session would authenticate a request now.
	SessionLive SessionState = "live"
	// SessionGone: there is no such session — signed out, revoked, swept,
	// never issued, or unreadable. The cases are deliberately one value, for
	// the reason SessionFromRequest does not distinguish them.
	SessionGone SessionState = "gone"
	// SessionExpired: the session reached its absolute lifetime.
	SessionExpired SessionState = "expired"
	// SessionIdle: the session went unused for longer than the idle timeout.
	SessionIdle SessionState = "idle"
)

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 past one of c's cutoffs, returning what
	// it deleted. The cutoffs are absolute times: the store holds no opinion
	// about how long a session should live.
	DeleteExpired(c ExpiryCutoffs) ([]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