auth

package
v0.108.0 Latest Latest
Warning

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

Go to latest
Published: Oct 2, 2026 License: AGPL-3.0 Imports: 16 Imported by: 0

Documentation

Overview

Package auth is passkey (WebAuthn) registration and login: no password ever stored or transmitted, a session cookie once a ceremony succeeds, and the store both ceremonies and sessions persist to (SQLite).

Index

Constants

View Source
const CookieName = "forge_dashboard_session"

CookieName is the session cookie forge-dashboard sets and reads. Its value is an opaque token — Store.UserForSession is what resolves it to a user, never anything decoded client-side.

View Source
const SessionTTL = 30 * 24 * time.Hour

SessionTTL is how long an issued session stays valid.

Variables

View Source
var ErrAlreadyRegistered = errors.New("auth: username already registered")

ErrAlreadyRegistered is returned by BeginRegistration when the username is already taken.

View Source
var ErrInvalidInvite = errors.New("auth: invalid or expired invite")

ErrInvalidInvite is returned by BeginRegistration when an invite is required (any account already exists) and the presented token is missing, unknown, expired, already consumed, or issued for a different username than the one being registered — deliberately one sentinel for every reason rather than one each, so a caller can't fingerprint which specific check failed.

View Source
var ErrLastCredential = errors.New("auth: can't remove the account's last remaining passkey")

ErrLastCredential is returned by RemoveCredential when credID is userID's only remaining passkey — this app is WebAuthn-only with no password fallback, so deleting it would lock the account out entirely (#355).

View Source
var ErrNotAdmin = errors.New("auth: admin access required")

ErrNotAdmin is returned by an admin-only Service method when the requester isn't the designated admin.

View Source
var ErrNotFound = errors.New("auth: not found")

ErrNotFound is returned by a lookup that found nothing — a sentinel rather than a typed error, since every caller only ever needs to know which kind, not reach anything out of it.

Functions

func BearerToken added in v0.64.0

func BearerToken(r *http.Request) (string, bool)

BearerToken reads an "Authorization: Bearer <token>" header from r, if any — a personal API token, checked by RequireAuth as a fallback when there's no session cookie, so a script can authenticate the same way a signed-in browser does with no WebAuthn ceremony to perform.

func ClearSessionCookie

func ClearSessionCookie(w http.ResponseWriter, secure bool)

ClearSessionCookie expires the session cookie immediately — logout.

func IsHTTPS

func IsHTTPS(r *http.Request) bool

IsHTTPS reports whether r itself arrived over TLS, directly or via a reverse proxy that sets the standard forwarded-proto header (Traefik, in front of this service's own deployment, terminates TLS and always sets it) — the deciding factor for whether the session cookie can carry Secure without locking out a plain-HTTP local dev run.

func RequireAdmin added in v0.9.0

func RequireAdmin(next http.Handler) http.Handler

RequireAdmin wraps next so it only ever runs for the designated admin — everyone else gets a 403. Compose it inside RequireAuth (RequireAuth(store)(RequireAdmin(next))), since it reads the user RequireAuth already put in context rather than looking one up itself.

func RequireAuth

func RequireAuth(store *Store) func(http.Handler) http.Handler

RequireAuth wraps next so it only ever runs for a request carrying a valid, unexpired session cookie or a valid personal API token (Authorization: Bearer <token>) — everyone else gets a 401 JSON body, never the handler underneath. Cookie checked first, Bearer as the fallback: the common case (a browser) never pays for a second lookup, and a script sending both would be unusual enough that "cookie wins" is a fine tiebreak rather than something worth its own rule.

func SessionToken

func SessionToken(r *http.Request) (string, bool)

SessionToken reads the session cookie from r, if any.

func SetSessionCookie

func SetSessionCookie(w http.ResponseWriter, token string, secure bool)

SetSessionCookie sets token as the session cookie on w. secure mirrors whether the request itself arrived over HTTPS — see isHTTPS, called by the handler that has the *http.Request this cookie is a response to.

func WithAccessLogUsername added in v0.77.0

func WithAccessLogUsername(ctx context.Context) (context.Context, *string)

WithAccessLogUsername returns a context carrying a pointer RequireAuth fills in with the resolved username, once it resolves one, plus that same pointer for the caller to read back later (#371).

An access-log middleware has to wrap outside RequireAuth — a rejected or unauthenticated request still needs logging — so by the time it can log anything, whatever RequireAuth put in *its own* request's context is already gone: r.WithContext returns a new *http.Request rather than mutating the caller's, so a value added deeper in the chain never becomes visible on the outer handler's own (now-stale) request. What does survive the round trip is a pointer carried through that context: writing to what it points at is visible to anyone still holding the same pointer, however many WithContext copies sit in between — which is exactly what the caller here keeps hold of.

Types

type APIToken added in v0.64.0

type APIToken struct {
	ID        string
	Label     string
	CreatedAt time.Time
	// ExpiresAt is always set (#356: mandatory at creation, no "never
	// expires" option) — UserForAPIToken rejects a token once this
	// passes, the same way it rejects one it's never heard of. A row
	// from before this field existed reads back as the zero Time, which
	// is always in the past, so it's rejected too rather than treated as
	// permanently valid just because the schema changed underneath it.
	ExpiresAt time.Time
	// LastUsedAt is nil until the token first authenticates a request —
	// the same "hasn't happened yet, not zero-value" shape
	// dashboard.RateLimit's own nilable fields use elsewhere in this
	// codebase.
	LastUsedAt *time.Time
}

APIToken is a personal API token's own metadata — never the raw value, which only Store.CreateAPIToken ever returns, once, at creation time. Matches components.schemas.APIToken.

type Credential added in v0.73.0

type Credential struct {
	ID        string
	Label     string
	CreatedAt time.Time
}

Credential is one registered passkey's own display metadata (#355) — never the credential itself, which stays on the authenticator that created it and never reaches this server at all. ID is base64url of the underlying webauthn.Credential.ID, matching how a credential ID already appears everywhere else in a WebAuthn ceremony (excludeCredentials, allowCredentials) rather than inventing a second encoding.

type Invite added in v0.86.0

type Invite struct {
	ID          string
	Username    string
	DisplayName string
	// CreatedBy is the admin's own WebAuthn user handle — the same raw
	// bytes User.ID holds — not a display name, so it survives that
	// admin's own account being renamed (usernames have no such
	// guarantee elsewhere in this schema, but nothing renames a username
	// today either; matching User.ID's shape is just the safer default).
	CreatedBy []byte
	ExpiresAt time.Time
	// ConsumedAt is nil for an outstanding invite — set once by either a
	// completed registration (ConsumeInviteIfValid) or an admin's own
	// revoke (RevokeInvite), which share the same column since this
	// schema has no separate "revoked" state to track.
	ConsumedAt *time.Time
}

Invite is a single-use, admin-issued registration invite — the same "hashed secret, shown raw exactly once" shape APIToken already uses (design.md's explicit precedent). ID is the invite's own stored token_hash, never the raw token itself: SHA-256 of a uniformly random secret can't be reversed (the same reasoning hashAPIToken's own doc comment gives for api_tokens), so it's safe to hand back to a caller as a stable identifier — it's what ListOutstandingInvites/RevokeInvite key on, not something that can complete a registration on its own.

type RequestLogFilter added in v0.90.0

type RequestLogFilter struct {
	Forge     string
	AccountID string
}

RequestLogFilter narrows ListRequests. A zero Filter (both fields empty) returns every row.

type RequestLogRow added in v0.90.0

type RequestLogRow struct {
	ID         int64
	LoggedAt   time.Time
	Forge      string
	AccountID  string // "" when no per-account credential made this request
	Method     string
	Endpoint   string
	StatusCode int    // 0 when the request never got a response at all
	Outcome    string // "success" or a dashboard.ForgeErrorKind string
	// RateLimitLimit/RateLimitRemaining/RateLimitResetsAt/RateLimitCost
	// are all nil when the response carried no rate-limit fields at all —
	// not every request reports these, and a zero would misread as a
	// real, reported zero-remaining budget.
	RateLimitLimit     *int
	RateLimitRemaining *int
	RateLimitResetsAt  *time.Time
	RateLimitCost      *int
}

RequestLogRow is one outbound-forge-request record, in the plain, dependency-free shape this store persists and returns — internal/ requestlog owns the richer Entry type callers actually work with (dashboard.Forge, a typed rate-limit struct) and translates to/from this shape at its own SQLiteRecorder boundary, rather than this store importing internal/requestlog's types directly: internal/requestlog already depends on this package (its SQLiteRecorder wraps *Store), so the reverse import would be a cycle.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service drives WebAuthn registration and login ceremonies against the configured relying party, persisting every step through Store.

func NewService

func NewService(wa *webauthn.WebAuthn, store *Store) *Service

NewService returns a Service.

func (*Service) BeginAddCredential added in v0.73.0

func (s *Service) BeginAddCredential(ctx context.Context, u *User) (*protocol.CredentialCreation, error)

BeginAddCredential starts a registration ceremony for an additional passkey on u's already-authenticated account (#355) — the same underlying WebAuthn ceremony BeginRegistration drives for a brand-new account, but against a user that already exists and, critically, already has credentials: the library's own excludeCredentials (built from u.WebAuthnCredentials()) is what stops a browser from letting someone re-register the same authenticator as a second, redundant entry.

func (*Service) BeginLogin

func (s *Service) BeginLogin(ctx context.Context, username string) (*protocol.CredentialAssertion, error)

BeginLogin starts a login ceremony for an existing username.

func (*Service) BeginRegistration

func (s *Service) BeginRegistration(ctx context.Context, username, displayName, inviteToken string) (*protocol.CredentialCreation, error)

BeginRegistration starts a registration ceremony for a brand-new username. Returns ErrAlreadyRegistered if that username already has an account.

The very first registration on a fresh instance bootstraps its admin and needs no invite — HasAnyRegisteredUser is false, so inviteToken is never even looked at. Every registration after that requires inviteToken to be a valid, unexpired, unconsumed invite issued for exactly username (ErrInvalidInvite otherwise), and the invite's own displayName wins over whatever the caller passed in — the admin who issued the invite chose it, not the anonymous request completing the ceremony.

func (*Service) CreateInvite added in v0.86.0

func (s *Service) CreateInvite(ctx context.Context, requester *User, username, displayName string) (token string, invite *Invite, err error)

CreateInvite generates a new single-use registration invite for username/displayName, issued by requester — ErrNotAdmin if requester isn't the designated admin, ErrAlreadyRegistered if username already has a completed registration (an abandoned, credential-less username is still fine to invite, same as it's still fine to self-reclaim via BeginRegistration).

func (*Service) CreateSession

func (s *Service) CreateSession(ctx context.Context, u *User) (token string, err error)

CreateSession issues a session token for u, ready to set as a cookie.

func (*Service) FinishAddCredential added in v0.73.0

func (s *Service) FinishAddCredential(ctx context.Context, u *User, label string, r *http.Request) (*Credential, error)

FinishAddCredential completes a ceremony BeginAddCredential started, attaching a new passkey labeled label to u's account. label is required — #355's whole point is that a nickname is prompted for at registration time, not derived from the authenticator.

func (*Service) FinishLogin

func (s *Service) FinishLogin(ctx context.Context, username string, r *http.Request) (*User, error)

FinishLogin completes a login ceremony, persisting the authenticator's bumped signature counter so a cloned authenticator is caught on a later login.

func (*Service) FinishRegistration

func (s *Service) FinishRegistration(ctx context.Context, username string, r *http.Request) (*User, error)

FinishRegistration completes a registration ceremony begun for username, attaching the new passkey to the account BeginRegistration already created.

func (*Service) HasAnyRegisteredUser added in v0.86.0

func (s *Service) HasAnyRegisteredUser(ctx context.Context) (bool, error)

HasAnyRegisteredUser reports whether any account has completed registration — an exported wrapper around the Store method of the same name, reachable from outside this package for the public registration-status endpoint (unauthenticated, so it has no *User of its own to check admin status against the way CreateInvite does).

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store persists users, their passkey credentials, in-flight WebAuthn ceremonies, and sessions. It owns no driver of its own — cmd wires up whichever database/sql driver is registered (modernc.org/sqlite) and hands Store a plain *sql.DB.

func NewStore

func NewStore(db *sql.DB) *Store

NewStore wraps db. Init must be called once before any other method.

func (*Store) AddCredential

func (s *Store) AddCredential(ctx context.Context, userID []byte, cred webauthn.Credential) error

AddCredential appends cred to userID's stored credentials — a brand-new passkey, from a just-finished registration ceremony.

func (*Store) ConsumeInviteIfValid added in v0.86.0

func (s *Store) ConsumeInviteIfValid(ctx context.Context, token, username string) (*Invite, error)

ConsumeInviteIfValid atomically checks that token hashes to an outstanding (unconsumed, unexpired) invite issued for exactly username, and marks it consumed in the same statement — a single UPDATE with every validity condition in its WHERE clause, so a concurrent attempt to spend the same token twice can never both succeed: SQLite serializes writes, and only the first one actually matches consumed_at IS NULL. Returns ErrNotFound uniformly for an unknown token, a username mismatch, an already-consumed invite, or an expired one — deliberately not distinguishing which, the same "don't leak which check failed" reasoning ErrInvalidInvite's own doc comment gives at the Service layer that wraps this.

func (*Store) CreateAPIToken added in v0.64.0

func (s *Store) CreateAPIToken(ctx context.Context, userID []byte, label string, expiresAt time.Time) (raw string, tok *APIToken, err error)

CreateAPIToken generates a brand-new personal API token for userID, labeled label, valid until expiresAt (#356 — mandatory, no "never expires" option; the caller, internal/api's handler, enforces the future/366-day-cap validation before this is ever called). raw is the only time the actual credential is ever returned — only its hash is stored, so losing it means generating a new one, the same "show once" handling a real secret needs.

func (*Store) CreateInvite added in v0.86.0

func (s *Store) CreateInvite(ctx context.Context, username, displayName string, createdBy []byte, ttl time.Duration) (raw string, invite *Invite, err error)

CreateInvite generates a brand-new single-use registration invite for username/displayName, issued by createdBy (an admin's own user ID), valid for ttl. raw is the only time the actual token is ever returned — only its hash is stored, so a leaked database dump can't be replayed into a registration (the same reasoning CreateAPIToken's own doc comment gives).

func (*Store) CreateSession

func (s *Store) CreateSession(ctx context.Context, userID []byte, ttl time.Duration) (token string, err error)

CreateSession issues a new opaque session token for userID, valid for ttl.

func (*Store) CreateUser

func (s *Store) CreateUser(ctx context.Context, username, displayName string, isAdmin bool) (*User, error)

CreateUser registers a brand-new account with no credentials yet — AddCredential attaches the first one once the registration ceremony that follows actually succeeds.

func (*Store) DeleteAPIToken added in v0.64.0

func (s *Store) DeleteAPIToken(ctx context.Context, userID []byte, id string) error

DeleteAPIToken revokes id, scoped to userID so one user can never revoke another's token by guessing an id — idempotent, same as DeleteUser's own sessions cleanup: deleting one that's already gone, or never belonged to userID, is not an error.

func (*Store) DeleteCeremony

func (s *Store) DeleteCeremony(ctx context.Context, username, kind string) error

DeleteCeremony clears the in-flight ceremony once it's finished, win or lose — it's single-use either way.

func (*Store) DeleteSession

func (s *Store) DeleteSession(ctx context.Context, token string) error

DeleteSession revokes a session token — logout, or an admin revoking another user's session.

func (*Store) DeleteUnregisteredUser added in v0.7.1

func (s *Store) DeleteUnregisteredUser(ctx context.Context, username string) error

DeleteUnregisteredUser removes username's account if — and only if — it has no credentials attached: an abandoned registration (begun, never finished, so nothing was ever actually attached to the username) rather than a real account. A no-op if the account has since gained a credential, or doesn't exist at all.

func (*Store) DeleteUser added in v0.9.0

func (s *Store) DeleteUser(ctx context.Context, userID []byte) error

DeleteUser removes the account and every session it holds — irreversible, and the caller's job to also clean up anything outside this store (saved forge credentials, a running dashboard refresh).

func (*Store) GetUserByID

func (s *Store) GetUserByID(ctx context.Context, id []byte) (*User, error)

GetUserByID returns ErrNotFound when no such user is registered. id is the raw WebAuthn user handle, the same bytes User.ID holds.

func (*Store) GetUserByUsername

func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error)

GetUserByUsername returns ErrNotFound when no such user is registered.

func (*Store) HasAnyRegisteredUser added in v0.11.0

func (s *Store) HasAnyRegisteredUser(ctx context.Context) (bool, error)

HasAnyRegisteredUser reports whether any account has completed registration — a row with no credentials (BeginRegistration begun, never finished) doesn't count. The first username to make this true is the one that becomes admin.

func (*Store) Init

func (s *Store) Init(ctx context.Context) error

Init creates the schema if it doesn't already exist. Safe to call every startup.

func (*Store) ListAPITokens added in v0.64.0

func (s *Store) ListAPITokens(ctx context.Context, userID []byte) ([]*APIToken, error)

ListAPITokens returns userID's own live tokens, oldest first.

func (*Store) ListCredentials added in v0.73.0

func (s *Store) ListCredentials(ctx context.Context, userID []byte) ([]*Credential, error)

ListCredentials returns userID's own passkeys, oldest first — driven by the account's real webauthn.Credential list (the source of truth for what can actually authenticate), not credential_metadata alone, so a credential somehow missing its label row still appears rather than silently vanishing from the list. defaultLabel fills that gap: a credential from before this feature existed reads back with no metadata row at all.

func (*Store) ListOutstandingInvites added in v0.86.0

func (s *Store) ListOutstandingInvites(ctx context.Context) ([]*Invite, error)

ListOutstandingInvites returns every invite that's neither consumed nor expired, soonest-expiring first — for the admin area's own list.

func (*Store) ListRequests added in v0.90.0

func (s *Store) ListRequests(ctx context.Context, filter RequestLogFilter) ([]RequestLogRow, error)

ListRequests returns every row matching filter, newest first. An empty Filter field means "don't filter on this." Never returns a nil slice for an empty result — an empty, non-nil one — so a caller can range over it with no nil check.

func (*Store) ListUsers added in v0.9.0

func (s *Store) ListUsers(ctx context.Context) ([]*User, error)

ListUsers returns every registered account, for the admin area.

func (*Store) LoadCeremony

func (s *Store) LoadCeremony(ctx context.Context, username, kind string) (webauthn.SessionData, error)

LoadCeremony returns ErrNotFound when there's no in-flight ceremony of that kind for username, or it already expired.

func (*Store) RecordRequest added in v0.90.0

func (s *Store) RecordRequest(ctx context.Context, row RequestLogRow, maxEntries int) error

RecordRequest persists row, then deletes the oldest rows past maxEntries in the same transaction — the caller (internal/requestlog.SQLiteRecorder) owns the retention cap, not this store, so changing it needs no change here.

func (*Store) RemoveCredential added in v0.73.0

func (s *Store) RemoveCredential(ctx context.Context, userID []byte, credID string) error

RemoveCredential deletes credID from userID's account, scoped to that user so one account's own credential id can never be guessed into deleting another's — the same shape DeleteAPIToken's own scoping uses. Idempotent for an id that's unknown or already gone (matching DeleteAPIToken), but never for the account's last one: that's always ErrLastCredential, not silently skipped, so a caller (or a stale list somehow down to one) can't accidentally lock the account out.

func (*Store) RevokeInvite added in v0.86.0

func (s *Store) RevokeInvite(ctx context.Context, id string) error

RevokeInvite marks id (an invite's own ID — its stored token_hash, from CreateInvite/ListOutstandingInvites, never the raw token) consumed so it can never be used to register — the row itself is kept, the same "lingers, but harmlessly" trade-off design.md accepts for a genuinely consumed invite, rather than a DELETE. Unlike DeleteAPIToken's own idempotent delete, revoking an id that's already gone, already consumed, or was never issued is ErrNotFound, not a silent no-op: an admin revoking from a list they're looking at should be told when the row they clicked isn't outstanding anymore, rather than have the button silently do nothing.

func (*Store) RevokeUser added in v0.9.0

func (s *Store) RevokeUser(ctx context.Context, userID []byte) error

RevokeUser signs userID out everywhere and clears every registered passkey, without deleting the account itself — they have to register a new passkey from scratch to get back in, same as BeginRegistration reclaiming an abandoned one. Also revokes every API token: "signed out everywhere" has to mean everywhere a request can authenticate as this user, not just the session-cookie path.

func (*Store) SaveCeremony

func (s *Store) SaveCeremony(ctx context.Context, username, kind string, session webauthn.SessionData, ttl time.Duration) error

SaveCeremony records the in-flight WebAuthn session data for username's registration or login attempt, replacing any earlier one of the same kind — a user can only ever be partway through one ceremony at a time.

func (*Store) SetCredentialLabel added in v0.73.0

func (s *Store) SetCredentialLabel(ctx context.Context, userID []byte, credID []byte, label string) error

SetCredentialLabel names credID (userID's own passkey) label — called once, right after AddCredential, for both the very first passkey a brand-new account registers and every one added afterward (#355), so Settings always has something to show even for an account that predates this feature having a UI of its own.

func (*Store) UpdateCredential

func (s *Store) UpdateCredential(ctx context.Context, userID []byte, cred webauthn.Credential) error

UpdateCredential replaces the stored credential sharing cred's ID — FinishLogin bumps the authenticator's signature counter on every successful login, and that has to be persisted to catch a cloned authenticator on a later login.

func (*Store) UserForAPIToken added in v0.64.0

func (s *Store) UserForAPIToken(ctx context.Context, raw string) (*User, error)

UserForAPIToken returns the user raw belongs to, or ErrNotFound — the Bearer-token equivalent of UserForSession. An expired token (or a legacy row with no recorded expiry at all — see APIToken.ExpiresAt) is rejected the same way an unknown one is, not a distinct error a caller could special-case into still trusting it (#356). Also records this as the token's most recent use, best-effort: a failure to persist that doesn't fail a request the token has already authenticated.

func (*Store) UserForSession

func (s *Store) UserForSession(ctx context.Context, token string) (*User, error)

UserForSession returns the user a valid, unexpired session token belongs to, or ErrNotFound.

type User

type User struct {
	ID          []byte
	Username    string
	DisplayName string
	IsAdmin     bool
	Credentials []webauthn.Credential
	CreatedAt   time.Time
}

User is a registered account. It implements webauthn.User directly, so it can be handed straight to the library's Begin/Finish calls with no adapter.

func UserFromContext

func UserFromContext(ctx context.Context) (*User, bool)

UserFromContext returns the authenticated user a RequireAuth-wrapped handler is running for. Only ever called from inside such a handler, so the zero value (found false) means RequireAuth itself has a bug, not a condition a caller needs to handle gracefully.

func (*User) WebAuthnCredentials

func (u *User) WebAuthnCredentials() []webauthn.Credential

WebAuthnCredentials satisfies webauthn.User with every passkey on file.

func (*User) WebAuthnDisplayName

func (u *User) WebAuthnDisplayName() string

WebAuthnDisplayName satisfies webauthn.User with the account's display name.

func (*User) WebAuthnID

func (u *User) WebAuthnID() []byte

WebAuthnID satisfies webauthn.User with the account's random handle.

func (*User) WebAuthnName

func (u *User) WebAuthnName() string

WebAuthnName satisfies webauthn.User with the account's username.

Jump to

Keyboard shortcuts

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