Documentation
¶
Overview ¶
Package auth handles every authentication concern for the v1.3 serve-mode daemon: bcrypt password hashing, DB-backed sessions, double-submit-cookie CSRF protection, and the chi middleware that gates non-public routes.
Phase 3 lands local-auth (email + password) + sessions + CSRF. Phase 4 adds OIDC subjects on top of the same users table. Phase 5 adds bearer-token auth for the REST API.
Index ¶
- Constants
- Variables
- func HashPassword(password string) (string, error)
- func InjectTestSession(ctx context.Context, userID string) context.Context
- func LoginHandler(users *Users, sessions *Sessions) http.HandlerFunc
- func LogoutHandler(sessions *Sessions) http.HandlerFunc
- func MeHandler(users *Users) http.HandlerFunc
- func Mount(r chi.Router, users *Users, sessions *Sessions)
- func RequireScope(needed Scope, next http.Handler) http.Handler
- func VerifyPassword(storedHash, candidate string) error
- type IssueResult
- type LoginRequest
- type LoginResponse
- type OIDC
- type OIDCConfig
- type OIDCProvider
- type OIDCProviderButton
- type SAML
- type SAMLConfig
- type SAMLProviderButton
- type Scope
- type Session
- type Sessions
- func (s *Sessions) ClearCookies(w http.ResponseWriter)
- func (s *Sessions) CookieName() string
- func (s *Sessions) Create(ctx context.Context, userID, userAgent, ip string) (*Session, error)
- func (s *Sessions) Destroy(ctx context.Context, sid string) error
- func (s *Sessions) DestroyForUser(ctx context.Context, userID string) error
- func (s *Sessions) ListActiveForUser(ctx context.Context, userID string) ([]*Session, error)
- func (s *Sessions) ListAllActive(ctx context.Context) ([]*Session, error)
- func (s *Sessions) Load(ctx context.Context, sid string) (*Session, error)
- func (s *Sessions) RequireAuth(next http.Handler) http.Handler
- func (s *Sessions) RequireCSRF(next http.Handler) http.Handler
- func (s *Sessions) SetCookies(w http.ResponseWriter, sess *Session)
- type Token
- type Tokens
- func (t *Tokens) Issue(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (*IssueResult, error)
- func (t *Tokens) List(ctx context.Context, userID string) ([]*Token, error)
- func (t *Tokens) RequireToken(next http.Handler) http.Handler
- func (t *Tokens) Revoke(ctx context.Context, tokenID string) error
- func (t *Tokens) Verify(ctx context.Context, presented string) (*Token, error)
- type User
- type Users
- func (u *Users) All(ctx context.Context) ([]*User, error)
- func (u *Users) ByEmail(ctx context.Context, email string) (*User, error)
- func (u *Users) ByID(ctx context.Context, id string) (*User, error)
- func (u *Users) Create(ctx context.Context, email, displayName, password string, isAdmin bool) (*User, error)
- func (u *Users) TouchLastLogin(ctx context.Context, id string) error
Constants ¶
const ( SessionCookieName = "__Host-ck_session" InsecureSessionCookieName = "ck_session" )
SessionCookieName is the cookie carrying the opaque session ID when the daemon serves under TLS (the production default). Prefixed __Host- to opt into the strictest browser cookie policy: HTTPS-only, no Domain attribute, Path=/. Modern browsers refuse to honor __Host- prefixed cookies that violate the rules — fail-loud is the right default for an auth cookie.
InsecureSessionCookieName is used when the daemon serves plain HTTP (dev / localhost / behind a TLS-terminating proxy on a private network). __Host- prefix mandates Secure, so we have to use a different name; clients pick which to read via Sessions.CookieName.
const CSRFCookieName = "ck_csrf"
CSRFCookieName is the readable companion cookie for the double- submit CSRF pattern. Not prefixed __Host- because JS needs to read it for the form/header value submission.
const CSRFHeaderName = "X-CSRF-Token"
CSRFHeaderName is the request header the middleware checks for the CSRF token on state-mutating requests. The JS side reads CSRFCookieName and sets the header on every fetch.
const TokenPrefix = "ck_"
TokenPrefix is the human-recognizable tag every issued token carries. Operators see "ck_<32 hex chars>" and know it's a compliancekit token even if they don't recall the source.
Variables ¶
var ErrEmailAlreadyTaken = errors.New("email already taken")
ErrEmailAlreadyTaken is returned by Create when a row already exists for the given email.
var ErrInvalidCredentials = errors.New("invalid email or password")
ErrInvalidCredentials is the single error returned by VerifyPassword for both "wrong user" and "wrong password" — never leak which one it was.
var ErrPasswordTooShort = fmt.Errorf("password must be at least %d characters", minPasswordLength)
ErrPasswordTooShort is returned when a password fails the length check. The handler should surface this as a field-level validation error, not a 500.
var ErrSessionExpired = errors.New("session expired")
ErrSessionExpired is returned by LoadSession when the cookie's session ID resolves to a row that's past its expires_at.
var ErrSessionNotFound = errors.New("session not found")
ErrSessionNotFound is returned by LoadSession when there's no row for the cookie's session ID (revoked, expired-and-cleaned, or forged cookie).
var ErrTokenExpired = errors.New("api token expired")
ErrTokenExpired is returned by Verify when the row's expires_at has elapsed.
var ErrTokenNotFound = errors.New("api token not recognized")
ErrTokenNotFound is returned by Verify when the bearer doesn't match any row. The middleware treats this as 401.
var ErrTokenRevoked = errors.New("api token revoked")
ErrTokenRevoked is returned by Verify when the row's revoked_at is non-null.
var ErrUserNotFound = errors.New("user not found")
ErrUserNotFound is returned by ByEmail / ByID when no row matches. Distinct from a query error so handlers can produce the right HTTP status without leaking the difference between "wrong email" + DB glitch.
Functions ¶
func HashPassword ¶
HashPassword produces a bcrypt hash suitable for storage in users.password_hash. Returns ErrPasswordTooShort when the input is below the minimum.
func InjectTestSession ¶ added in v1.12.0
InjectTestSession returns a context carrying a minimal Session for userID. Exported as a test helper so package-external tests (e.g. the scopeGate RBAC tests) can simulate a logged-in user without reaching for the cookie + Load round-trip.
func LoginHandler ¶
func LoginHandler(users *Users, sessions *Sessions) http.HandlerFunc
LoginHandler builds the POST /api/auth/login handler. Validates the credentials via Users + VerifyPassword, issues a Session via Sessions.Create, sets the session + CSRF cookies, and responds with JSON (or a 303 to next= for browser POSTs).
func LogoutHandler ¶
func LogoutHandler(sessions *Sessions) http.HandlerFunc
LogoutHandler builds the POST /api/auth/logout handler. Destroys the current session + clears cookies. Browser flows redirect to /login; JSON callers get 204.
func MeHandler ¶
func MeHandler(users *Users) http.HandlerFunc
MeHandler builds the GET /api/auth/me handler. Returns the current session's user info; should be behind RequireAuth.
func Mount ¶ added in v1.3.1
Mount installs the auth HTTP routes onto r:
POST /api/auth/login → LoginHandler (form + JSON) POST /api/auth/logout → LogoutHandler (destroy session, clear cookies) GET /api/auth/me → MeHandler (returns current session's user)
/me sits behind RequireAuth; login + logout are intentionally unauthenticated. This helper was missing in v1.3.0 — the individual handlers shipped in phase 3 but never got wired onto the daemon's router; the bug surfaced as a 404 from the UI login form's POST. v1.3.1 closes the gap.
func RequireScope ¶
RequireScope wraps next in a 403-on-missing-scope check. Pull the token from context (RequireToken must have run first); fail loud when the token's grants don't cover the route's requirement.
func VerifyPassword ¶
VerifyPassword constant-time-compares the candidate against the stored bcrypt hash. Returns nil on match, ErrInvalidCredentials on mismatch (also returned when the stored hash is empty — accounts provisioned via OIDC only).
Types ¶
type IssueResult ¶
type IssueResult struct {
Token *Token
Plaintext string // "ck_<32 hex chars>" — show to operator once, never re-displayable
}
IssueResult is what Issue returns to the caller — including the plaintext token, which is shown to the operator ONCE and never stored unhashed.
type LoginRequest ¶
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Next string `json:"next,omitempty"` // post-login redirect path
}
LoginRequest is the JSON body POST /api/auth/login accepts. The HTML login form (phase 11) submits the same fields via application/x-www-form-urlencoded — the handler accepts both.
type LoginResponse ¶
type LoginResponse struct {
UserID string `json:"user_id"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
DisplayName string `json:"display_name,omitempty"`
}
LoginResponse is the JSON body on a successful POST. Browser flows receive a 303 redirect to LoginRequest.Next instead.
type OIDC ¶
type OIDC struct {
// contains filtered or unexported fields
}
OIDC encapsulates the runtime state for one configured upstream. Build via NewOIDC; mount its handlers on the chi router.
func NewOIDC ¶
func NewOIDC(ctx context.Context, cfg OIDCConfig, users *Users, sessions *Sessions, st *store.Store) (*OIDC, error)
NewOIDC discovers the provider's metadata and returns a ready-to- mount handler set. GitHub takes a short-circuit path (no OIDC discovery, only OAuth2 + a userinfo HTTP fetch).
func (*OIDC) CallbackHandler ¶
func (o *OIDC) CallbackHandler() http.HandlerFunc
CallbackHandler completes the flow: verifies state, exchanges the code for tokens, resolves the user identity (from id_token claims or upstream /user endpoint depending on provider), find-or-creates a row in users, issues a session, sets cookies, redirects to "/".
func (*OIDC) Config ¶
func (o *OIDC) Config() OIDCConfig
Config returns the OIDCConfig the handler was constructed with; useful for the v1.4 settings page to surface what's configured.
func (*OIDC) LoginHandler ¶
func (o *OIDC) LoginHandler() http.HandlerFunc
LoginHandler kicks off the authorization-code flow: generates a random state, sets it in a short-lived cookie, redirects the user to the upstream's authorize URL.
func (*OIDC) Mount ¶ added in v1.5.1
Mount wires the per-provider routes under /oidc/{id}/login and /oidc/{id}/callback. v1.5.1 F15 — `auth.NewOIDC` + every handler shipped in v1.3 with unit tests, but the routes were never mounted onto the daemon's chi router. The login template even advertised OIDC; the corresponding paths returned 404 in production.
type OIDCConfig ¶
type OIDCConfig struct {
// ID is the URL-safe identifier ("google", "okta-corp",
// "github-enterprise") that namespaces the callback URL.
ID string
// Provider drives the discovery + userinfo strategy.
Provider OIDCProvider
// IssuerURL is the OIDC issuer (for Google, Okta, generic).
// Ignored when Provider == OIDCProviderGitHub.
IssuerURL string
// ClientID + ClientSecret are the OAuth2 app credentials issued
// by the upstream provider.
ClientID string
ClientSecret string
// RedirectURL is the absolute callback URL the upstream invokes
// after the user grants consent. Must match what the operator
// registered with the provider exactly (scheme + host + path).
RedirectURL string
// Scopes default to the standard OIDC set ("openid", "profile",
// "email") for OIDC providers and {"user:email"} for GitHub. Set
// to override.
Scopes []string
}
OIDCConfig is one configured OIDC integration. Operators register one of these per upstream they accept logins from; the daemon supports many simultaneously (the routes are /oidc/{provider-id}/login and /oidc/{provider-id}/callback).
type OIDCProvider ¶
type OIDCProvider string
OIDCProvider names the upstream identity service. Google + Okta + "custom" use the standard OIDC discovery flow against an issuer URL; GitHub uses a separate OAuth2-only path (it doesn't ship OpenID Connect, only OAuth2 + a /user endpoint we read separately).
const ( OIDCProviderGoogle OIDCProvider = "google" OIDCProviderOkta OIDCProvider = "okta" OIDCProviderGitHub OIDCProvider = "github" OIDCProviderCustom OIDCProvider = "custom" )
type OIDCProviderButton ¶ added in v1.5.1
type OIDCProviderButton struct {
ID string // url-safe id; matches the path segment in /oidc/{id}/login
Label string // human display label, e.g. "Sign in with Google"
}
OIDCProviderButton is the display-side projection of an OIDC config for rendering on the /login page. v1.5.1 F15 — populated by the daemon at boot for every configured provider and passed to the UI via UI.SetOIDCProviders.
type SAML ¶ added in v1.12.0
type SAML struct {
// contains filtered or unexported fields
}
SAML encapsulates the runtime state for one configured SAML IdP. Build via NewSAML; mount its handlers on the chi router with Mount.
func NewSAML ¶ added in v1.12.0
func NewSAML(ctx context.Context, cfg SAMLConfig, users *Users, sessions *Sessions, st *store.Store) (*SAML, error)
NewSAML parses the IdP cert + the SP keypair and assembles the crewjam/saml middleware. Returns a ready-to-mount SAML handler.
func (*SAML) Button ¶ added in v1.12.0
func (s *SAML) Button() SAMLProviderButton
Button returns the /login-page projection of this connection.
func (*SAML) Config ¶ added in v1.12.0
func (s *SAML) Config() SAMLConfig
Config returns the SAMLConfig the handler was constructed with; useful for /settings to surface what's configured.
type SAMLConfig ¶ added in v1.12.0
type SAMLConfig struct {
// ID is the URL-safe identifier ("okta-corp", "azure-prod") that
// namespaces every route + the SP metadata's EntityID.
ID string
// Label is the human display label, e.g. "Sign in with Okta".
Label string
// EntryPoint is the IdP's SingleSignOnService URL — the SP redirects
// the browser here on /saml/{id}/login. Operators copy it from the
// IdP metadata.
EntryPoint string
// IDPMetadataXML is the entire IdP metadata document. Used to
// recover the IdP's signing certificate so the SP can verify
// assertions. Mutually exclusive with IDPCertPEM.
IDPMetadataXML string
// IDPCertPEM is the IdP's signing cert in PEM. When the operator
// can't / won't paste the whole metadata blob, this is the smallest
// thing the SP needs.
IDPCertPEM string
// EntityID is the SP's own EntityID. Defaults to
// {RootURL}/saml/{id}/metadata when empty.
EntityID string
// RootURL is the daemon's externally-visible URL ("https://compliancekit.example.com").
// Used to build the ACS and metadata URLs.
RootURL string
// SPCertPEM + SPKeyPEM are the SP's signing keypair. Generated by
// the operator (a short-lived self-signed cert works; the IdP only
// uses it to verify SP-side message signatures).
SPCertPEM string
SPKeyPEM string
// AllowIDPInitiated permits unsolicited assertions. Defaults true
// because every major IdP exposes "launch app" tiles that produce
// IdP-initiated flows; disable when defense-in-depth matters more
// than the convenience.
AllowIDPInitiated bool
// SignRequests asks the SP to sign outbound AuthnRequests. Defaults
// off because not every IdP enforces signature verification on the
// SP side; turn on when the IdP requires it.
SignRequests bool
}
SAMLConfig is one configured SAML SSO connection. Operators register one per IdP they accept logins from; the daemon supports many.
type SAMLProviderButton ¶ added in v1.12.0
type SAMLProviderButton struct {
ID string // url-safe id; matches the /saml/{id}/login path
Label string // human display label
}
SAMLProviderButton is the display-side projection of a SAMLConfig for rendering on the /login page alongside OIDCProviderButton.
type Scope ¶
type Scope string
Scope identifies a granular permission an API token can carry. Scopes are namespaced "<resource>:<verb>" with the convention that "*" wildcard at any segment grants every value at that level. Granted scopes are checked at the route handler via RequireScope.
const ( // ScopeScansRead lists / reads scans + their findings + resources. ScopeScansRead Scope = "scans:read" // ScopeScansWrite triggers new scans (POST /api/v1/scans). ScopeScansWrite Scope = "scans:write" // ScopeFindingsRead reads findings standalone (also covered by // scans:read on the per-scan path; this scope is for the global // filtered explorer endpoints v1.5 ships). ScopeFindingsRead Scope = "findings:read" // ScopeWaiversRead lists waivers. ScopeWaiversRead Scope = "waivers:read" // ScopeWaiversWrite mutates the waivers set (add / edit / expire). ScopeWaiversWrite Scope = "waivers:write" // ScopeSettingsRead reads provider + check + framework + schedule // config. ScopeSettingsRead Scope = "settings:read" // ScopeSettingsWrite mutates the same. ScopeSettingsWrite Scope = "settings:write" // ScopeAdmin is the operator-superpower bit: covers everything, // plus user + token + webhook management. Tokens issued by the // v1.4 settings page top out at non-admin unless the operator // explicitly grants this. ScopeAdmin Scope = "*" )
func (Scope) ScopeRBAC ¶ added in v1.12.0
ScopeRBAC is the (resource, action) tuple a Scope corresponds to in the v1.12 RBAC grid. Returns ok=false for ScopeAdmin (it's the universal wildcard and doesn't map to a single tuple) and for any unknown scope so callers can fall back to the legacy check.
Strings are deliberate over pubrbac.Resource / pubrbac.Action so the auth package stays import-free of pkg/compliancekit/rbac (avoiding a dependency cycle through internal/server/rbac → auth).
type Session ¶
type Session struct {
ID string
UserID string
CSRFToken string
CreatedAt time.Time
LastSeenAt time.Time
ExpiresAt time.Time
UserAgent string
IP string
}
Session is the in-memory shape returned by LoadSession + companion methods. Field set matches the sessions table.
func FromContext ¶
FromContext retrieves the *Session installed by RequireAuth. Returns nil when the route is unauthenticated.
type Sessions ¶
type Sessions struct {
// SecureCookies controls whether the session + CSRF cookies carry
// the Secure attribute + __Host- prefix. Default is true (production
// safe). Flip to false via `compliancekit serve --insecure-cookies`
// for plain-HTTP dev / internal-network deploys where Secure cookies
// get silently dropped by browsers.
SecureCookies bool
// contains filtered or unexported fields
}
Sessions is the persistence layer for the session table. Both the http middleware and the login/logout handlers go through this type.
func NewSessions ¶
NewSessions returns a Sessions handle bound to st with secure cookies enabled (production default). Callers can flip SecureCookies = false for dev / plain-HTTP usage.
func (*Sessions) ClearCookies ¶ added in v1.5.1
func (s *Sessions) ClearCookies(w http.ResponseWriter)
ClearCookies tells the browser to drop both cookies. Used by logout + by middleware when a stored session is missing / expired.
func (*Sessions) CookieName ¶ added in v1.5.1
CookieName returns the session cookie name appropriate to the current SecureCookies setting. Use this from any handler that needs to read the cookie off the request.
func (*Sessions) Create ¶
Create issues a new session row for userID. Returns the session + the plaintext cookie value the caller must set on the response. Both the session ID and the CSRF token are 256-bit hex strings.
func (*Sessions) DestroyForUser ¶
DestroyForUser deletes every session for userID — used by password change + the "log me out everywhere" affordance the v1.4 settings page will eventually expose.
func (*Sessions) ListActiveForUser ¶ added in v1.12.0
ListActiveForUser returns every non-expired session owned by userID ordered by most-recent first. Powers the v1.12 phase 5 admin surface ("active sessions for X").
func (*Sessions) ListAllActive ¶ added in v1.12.0
ListAllActive returns every non-expired session across every user. Powers the org-wide "all signed-in users" view. Bounded at 500 rows so a runaway loop can't OOM the daemon.
func (*Sessions) Load ¶
Load fetches the session row by ID. Touches last_seen_at on success so an active session doesn't drift toward expiry. Returns ErrSessionNotFound / ErrSessionExpired distinct from other errors so the middleware can clear the cookie cleanly.
func (*Sessions) RequireAuth ¶
RequireAuth is the chi middleware factory that gates a route on a valid session cookie. On missing / expired session the cookies are cleared and the response is 401 (for /api routes) or a 303 redirect to /login (for everything else). The decision is driven by the request's Accept header + path prefix — pure-JSON callers get the machine-friendly status; browser callers get the human redirect.
func (*Sessions) RequireCSRF ¶
RequireCSRF gates state-mutating requests on the double-submit cookie check. Reads the X-CSRF-Token header (set by client JS from the readable ck_csrf cookie) and compares it constant-time against the session's csrf_token. The session must already be installed by RequireAuth — chain RequireAuth before RequireCSRF.
Safe methods (GET / HEAD / OPTIONS) pass through unchecked; the browser doesn't trigger CSRF on those.
Token-auth callers (Authorization: Bearer ck_…) skip the CSRF check entirely — bearer tokens are not browser-resident credentials so cross-site requests can't smuggle them; CSRF protects only against cookie-based session hijacks.
func (*Sessions) SetCookies ¶ added in v1.5.1
func (s *Sessions) SetCookies(w http.ResponseWriter, sess *Session)
SetCookies writes the session + CSRF cookies onto w. In secure mode the session cookie is named __Host-ck_session and carries Secure + HttpOnly + SameSite=Lax + Path=/ (the __Host- prefix mandates the absence of Domain). In insecure mode the cookie is ck_session and drops Secure so plain-HTTP browsers will keep it. ck_csrf is the readable companion (not HttpOnly) so client-side JS can mirror it into the X-CSRF-Token header on every state-mutating request.
type Token ¶
type Token struct {
ID string
UserID string
Name string
Prefix string
Scopes []Scope
CreatedAt time.Time
LastUsedAt time.Time
ExpiresAt time.Time
RevokedAt time.Time
}
Token is the in-memory shape returned by Tokens.Verify (the row minus token_hash, which never leaves the package). Scopes are already parsed.
func TokenFromContext ¶
TokenFromContext retrieves the *Token installed by RequireToken. Returns nil when the route is unauthenticated or session-auth was used instead.
type Tokens ¶
type Tokens struct {
// contains filtered or unexported fields
}
Tokens is the persistence layer for the api_tokens table.
func (*Tokens) Issue ¶
func (t *Tokens) Issue(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (*IssueResult, error)
Issue creates a new token for userID with the given scopes. The plaintext is returned exactly once via IssueResult; the daemon stores only a SHA-256 hash (fast + deterministic — bcrypt's per-request cost is wrong for tokens since they're presented every API call; the secrecy comes from the 128-bit random body).
func (*Tokens) List ¶
List returns every (non-revoked) token issued for userID. Doesn't include the hash — operators have no use for it post-issue.
func (*Tokens) RequireToken ¶
RequireToken is the middleware that gates a route on a valid bearer token. Reads Authorization: Bearer <token>, verifies via Tokens.Verify, stashes the token + the owning user in context.
If both RequireAuth and RequireToken are mounted on the same route, either path of authentication satisfies it — the handler should resolve "who am I" by first checking TokenFromContext, falling back to FromContext for session auth.
func (*Tokens) Revoke ¶
Revoke marks tokenID as revoked. Idempotent — already-revoked returns nil. Subsequent Verify of the same plaintext returns ErrTokenRevoked.
func (*Tokens) Verify ¶
Verify resolves a presented bearer token to a Token + the owning user ID. Hashes the input + compares to token_hash (UNIQUE in the schema). Updates last_used_at best-effort. Returns the typed sentinels ErrTokenNotFound / ErrTokenExpired / ErrTokenRevoked so the middleware picks the right HTTP status.
type User ¶
type User struct {
ID string
Email string
DisplayName string
PasswordHash string // empty for OIDC-only accounts
OIDCSubject string // empty for local-only accounts
OIDCProvider string // empty for local-only accounts
IsAdmin bool
CreatedAt time.Time
LastLoginAt time.Time
}
User is the in-memory shape returned by Users.ByEmail + ByID.
type Users ¶
type Users struct {
// contains filtered or unexported fields
}
Users is the persistence layer for the users table. Kept tiny in phase 3 — phase 11 (UI shell) + v1.4 settings page add the full CRUD surface.
func (*Users) All ¶ added in v1.8.0
All returns every user in the directory, ordered by display_name then email. v1.8 phase 2 — drives the assignee dropdown + the resource-owner picker. Cap at 500 rows; daemon admin uses a more targeted query if the directory ever grows past that.
func (*Users) Create ¶
func (u *Users) Create(ctx context.Context, email, displayName, password string, isAdmin bool) (*User, error)
Create inserts a new local-auth user with the given email + plain password (the password is hashed via HashPassword before storage). Returns the persisted User. isAdmin grants admin rights.