Documentation
¶
Overview ¶
Package auth owns credentials and sessions: argon2id password hashing (PHC strings), the AuthProvider interface with the v0.1 email+password implementation, Redis-backed sessions, and the request identity context. Parameters per docs/v0.1/06-auth.md.
Index ¶
- Constants
- Variables
- func BurnHash(ctx context.Context, password string) error
- func ConfigureHashGate(n int, maxWait time.Duration)
- func DefaultHashConcurrency() int
- func GenerateToken() (plaintext, hash, prefix string, err error)
- func HashPassword(ctx context.Context, password string) (string, error)
- func IsCommonPassword(pw string) bool
- func NeedsRehash(encoded string) bool
- func ValidateScopes(scopes []string) error
- func VerifyPassword(ctx context.Context, password, encoded string) (bool, error)
- func WithIdentity(ctx context.Context, id Identity) context.Context
- type AuthMethod
- type AuthProvider
- type EmailPasswordProvider
- type ExternalIdentity
- type ExternalResolver
- type Identity
- type LoginState
- type LoginStateStore
- type ProvisionPolicy
- type RedirectProvider
- type Registry
- func (r *Registry) Default() AuthProvider
- func (r *Registry) Deregister(name string)
- func (r *Registry) Get(name string) (AuthProvider, bool)
- func (r *Registry) HasUsableLogin(emailEnabled bool) bool
- func (r *Registry) Names() []string
- func (r *Registry) Register(name string, p AuthProvider, override bool) error
- type RejectionError
- type Session
- type SessionStore
- func (s *SessionStore) Create(ctx context.Context, userID uuid.UUID, role, ip, ua string) (Session, error)
- func (s *SessionStore) Delete(ctx context.Context, token string) error
- func (s *SessionStore) DeleteAllForUser(ctx context.Context, userID uuid.UUID, keepToken string) error
- func (s *SessionStore) Get(ctx context.Context, token string) (Session, error)
- type TokenAdminMeta
- type TokenMeta
- type TokenService
- func (s *TokenService) Authenticate(ctx context.Context, raw string, now time.Time) (Identity, error)
- func (s *TokenService) Create(ctx context.Context, userID uuid.UUID, name string, scopes []string, ...) (plaintext string, meta TokenMeta, err error)
- func (s *TokenService) DeleteForUser(ctx context.Context, userID uuid.UUID) error
- func (s *TokenService) List(ctx context.Context, userID uuid.UUID) ([]TokenMeta, error)
- func (s *TokenService) ListAll(ctx context.Context, limit, offset int32) ([]TokenAdminMeta, int64, error)
- func (s *TokenService) Revoke(ctx context.Context, userID, tokenID uuid.UUID) (bool, error)
- func (s *TokenService) RevokeAny(ctx context.Context, tokenID uuid.UUID) (bool, error)
- func (s *TokenService) Touch(ctx context.Context, id uuid.UUID) error
Constants ¶
const ( ScopeRead = "read" ScopeSubmit = "submit" ScopeAdmin = "admin" )
Coarse scopes (v0.3). read = GET, submit = participant mutations, admin = admin routes.
const CookieName = "osctf_session"
CookieName is the session cookie's name.
const LoginStateCookie = "osctf_login_state"
LoginStateCookie carries the state value bound to one in-flight external login. It exists so the callback can prove it reached the SAME browser that started the login: the query parameter alone is attacker-suppliable, and a state that only lives server-side would still accept a callback replayed into someone else's browser (login CSRF — the victim silently ends up signed in as the attacker).
const LoginStateTTL = 10 * time.Minute
LoginStateTTL bounds one redirect round trip. Short on purpose: it is the window in which a captured state would be useful. The store and the bound cookie share this one value so they cannot expire at different times.
const TokenPrefix = "osctf_pat_" //nolint:gosec // G101: a public prefix marker, not a secret
TokenPrefix marks an OSCTF personal access token. It makes tokens greppable — good for secret scanners, and the reason a leaked one must never reach a log/response/audit/metric.
Variables ¶
var ( // ErrInvalidToken covers a malformed, unknown, or revoked token — all indistinguishable. ErrInvalidToken = errors.New("auth: invalid token") ErrTokenExpired = errors.New("auth: token expired") ErrUserBanned = errors.New("auth: token owner is banned") // ErrTokenScope means the token carries a scope the server does not recognise — fail closed. ErrTokenScope = errors.New("auth: token carries an unrecognized scope") )
var ErrExternalRejected = errors.New("auth: external login rejected")
ErrExternalRejected is what every rejection matches. The caller renders one generic failure so a login response never reveals whether an account exists, is banned, or was refused by policy; the specific reason goes to the log instead.
var ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInvalidCredentials is the single failure mode of Authenticate — callers must not learn whether the account exists, is banned, or had a wrong password.
var ErrInvalidHash = errors.New("auth: invalid password hash")
ErrInvalidHash marks a stored hash that cannot be parsed as a PHC argon2id string.
var ErrNoLoginState = errors.New("auth: no such login state")
ErrNoLoginState means the state was never issued, already used, or expired. All three are the same to a caller: the login does not proceed.
var ErrNoSession = errors.New("auth: no such session")
ErrNoSession is returned when a token does not resolve to a live session.
Functions ¶
func BurnHash ¶
BurnHash performs one argon2 verification against the dummy hash so a login with an unknown email costs the same as one with a known email. It returns the gate error (if any) so the caller can shed the unknown-email path the same way it sheds the known-email path — keeping the 401/503 status uniform under load.
func ConfigureHashGate ¶
ConfigureHashGate bounds concurrent argon2id derivations to n, each queued request waiting at most maxWait before its caller receives a 503-mapped error. n <= 0 disables the gate. Call once at startup before serving; the package var is read without a lock, which is safe under that set-once discipline (tests reconfigure in the sequential, non-parallel phase).
func DefaultHashConcurrency ¶
func DefaultHashConcurrency() int
DefaultHashConcurrency derives a gate size from the host memory limit (cgroup v2/v1, then /proc/meminfo), budgeting a quarter of memory to concurrent hashing, clamped to [2,64]. It falls back to GOMAXPROCS when the memory limit can't be read (e.g. non-Linux dev hosts).
func GenerateToken ¶
GenerateToken returns a new plaintext token (shown to the user exactly once), its sha-256 hash (stored), and its display/lookup prefix (stored).
func HashPassword ¶
HashPassword derives an argon2id hash and encodes it as a PHC string: $argon2id$v=19$m=65536,t=3,p=4$<b64 salt>$<b64 hash>. It blocks on the concurrency gate and may return an *apperr.Unavailable (503) under overload.
func IsCommonPassword ¶
IsCommonPassword reports whether pw is on the embedded deny list.
func NeedsRehash ¶
NeedsRehash reports whether the stored hash uses parameters different from the current configuration (upgrade path: re-hash on next successful login).
func ValidateScopes ¶
ValidateScopes returns an error naming the first unknown scope. Enforced at create time AND at auth time: a token carrying an unknown scope is rejected (fail closed), never silently treated as scopeless-and-allowed.
func VerifyPassword ¶
VerifyPassword recomputes the hash with the parameters stored in the PHC string and compares in constant time. It blocks on the concurrency gate and may return an *apperr.Unavailable (503) under overload — callers must distinguish that from a (false, nil) mismatch (see EmailPasswordProvider).
Types ¶
type AuthMethod ¶
type AuthMethod string
AuthMethod records how a request authenticated. Scope enforcement applies only to token auth; a session carries the caller's full role.
const ( AuthSession AuthMethod = "session" AuthToken AuthMethod = "token" )
type AuthProvider ¶
type AuthProvider interface {
// Name returns a stable identifier, e.g. "email".
Name() string
// Authenticate verifies credentials and returns the user ID.
// Returns ErrInvalidCredentials on any failure (no enumeration).
Authenticate(ctx context.Context, identifier, secret string) (userID uuid.UUID, err error)
}
AuthProvider authenticates credentials and yields a platform user identity. v0.1 implementation: EmailPasswordProvider. Future: OAuth, LDAP, SAML plugins.
type EmailPasswordProvider ¶
type EmailPasswordProvider struct {
// contains filtered or unexported fields
}
EmailPasswordProvider verifies an email + password against the users table.
func NewEmailPasswordProvider ¶
func NewEmailPasswordProvider(q *gen.Queries, rehash func(ctx context.Context, userID uuid.UUID, newHash string)) *EmailPasswordProvider
NewEmailPasswordProvider builds the v0.1 provider.
func (*EmailPasswordProvider) Authenticate ¶
func (p *EmailPasswordProvider) Authenticate(ctx context.Context, email, password string) (uuid.UUID, error)
Authenticate implements AuthProvider with timing uniformity: an unknown email still burns one argon2 verification before returning the generic error.
func (*EmailPasswordProvider) Name ¶
func (p *EmailPasswordProvider) Name() string
Name implements AuthProvider.
type ExternalIdentity ¶
type ExternalIdentity struct {
Subject string // stable unique id at the provider; required
Email string // may be empty
Username string // a SUGGESTION; the host picks the actual username
EmailVerified bool // the provider asserts it verified Email (ABI 1.1)
Claims map[string]string // informational only, and screened below
}
ExternalIdentity is the host-side view of what an auth plugin asserted. It is a CLAIM, not a grant; what it is allowed to mean is decided in Resolve.
type ExternalResolver ¶
type ExternalResolver struct {
// contains filtered or unexported fields
}
ExternalResolver maps a plugin's assertion to a local user under policy.
func NewExternalResolver ¶
func NewExternalResolver(q *gen.Queries, policy ProvisionPolicy, log *slog.Logger) *ExternalResolver
NewExternalResolver builds the resolver. policy must already be validated.
func (*ExternalResolver) Policy ¶
func (r *ExternalResolver) Policy() ProvisionPolicy
Policy reports the configured provisioning policy.
func (*ExternalResolver) Resolve ¶
func (r *ExternalResolver) Resolve(ctx context.Context, provider string, id ExternalIdentity) (gen.User, error)
Resolve turns an asserted external identity into a local user, or rejects it. Every rejection is logged with the provider and reason and returned as ErrExternalRejected.
The log deliberately carries no identity fields — not the subject, not the email. A rejected login is exactly the case where the address may belong to someone who is not the person attempting it, and operator logs are not the place to accumulate that.
type Identity ¶
type Identity struct {
UserID uuid.UUID
Role string
SessionToken string // session auth only
Method AuthMethod // how this request authenticated
Scopes []string // token auth only — the granted scope set
TokenID uuid.UUID // token auth only — for rate-limit keying and audit
}
Identity is the authenticated caller attached to a request context by the auth middleware. For session auth, Role comes from the session (cheap checks) and admin endpoints re-read the user row. For token auth, Role is resolved LIVE per request and Scopes/TokenID are set.
func IdentityFrom ¶
IdentityFrom returns the caller identity, if any.
type LoginState ¶
type LoginState struct {
Provider string `json:"provider"`
ProviderState string `json:"provider_state"`
}
LoginState is the core-owned record of one in-flight external login.
The core mints this value, not the provider. A provider-chosen state would put CSRF protection inside the component the return-path contract says not to trust; ProviderState is carried alongside purely so it can be handed back to the plugin on completion.
type LoginStateStore ¶
type LoginStateStore struct {
// contains filtered or unexported fields
}
LoginStateStore keeps in-flight external logins in Redis under a short TTL. Entries are SINGLE-USE: consuming one deletes it, so a captured callback URL cannot be replayed.
func NewLoginStateStore ¶
func NewLoginStateStore(rdb *redis.Client, ttl time.Duration) *LoginStateStore
NewLoginStateStore builds the store. ttl should be short — a login redirect is a round trip through the provider, not a session.
func (*LoginStateStore) Consume ¶
func (s *LoginStateStore) Consume(ctx context.Context, token string) (LoginState, error)
Consume fetches and DELETES the state in one round trip, so a state is usable exactly once. An absent, expired, or already-consumed token returns ErrNoLoginState.
func (*LoginStateStore) Mint ¶
func (s *LoginStateStore) Mint() (string, error)
Mint generates a fresh, unguessable state token WITHOUT storing it.
Minting and storing are separate because the state has to exist before the provider is asked to build its authorize URL — the provider must embed this exact value, so the host can verify on the callback that the value the identity provider echoed is the one the host chose. Storing first and rewriting later would leave a window where a state is live but unusable.
type ProvisionPolicy ¶
type ProvisionPolicy string
ProvisionPolicy governs what an external login may do when it carries no existing binding. Every policy resolves an existing binding the same way; they differ only in whether an unbound identity may attach to an account, and whether it may create one.
known binding verified email matches neither open log in bind + log in create participant invite-only log in bind + log in reject off log in reject reject
const ( // ProvisionOpen lets a verified identity with no account create one (lowest role). ProvisionOpen ProvisionPolicy = "open" // ProvisionInviteOnly binds only to accounts that already exist — the "invite" is an admin // having created the account. It never creates a user, so a compromised or careless provider // cannot manufacture accounts. ProvisionInviteOnly ProvisionPolicy = "invite-only" // ProvisionOff resolves only identities already bound; it neither matches nor creates. ProvisionOff ProvisionPolicy = "off" )
func ParseProvisionPolicy ¶
func ParseProvisionPolicy(s string) (ProvisionPolicy, error)
ParseProvisionPolicy validates the configured policy. An unrecognised value is an error rather than a silent default: guessing here would pick a security posture on the operator's behalf.
type RedirectProvider ¶
type RedirectProvider interface {
// Begin returns the URL to send the browser to, plus the provider's own round-trip value.
// hostState is the CSRF state the core minted; the provider must place it in the authorize
// URL unchanged, and the caller verifies that it did.
Begin(ctx context.Context, hostState, redirectURI string) (authorizeURL, providerState string, err error)
// Complete exchanges the callback parameters for an asserted identity.
Complete(ctx context.Context, state string, params map[string]string) (ExternalIdentity, error)
}
RedirectProvider is the optional capability for external (OAuth/OIDC) logins. A provider implements it only if its plugin advertised the "redirect" capability, so a type assertion for it is a real capability check rather than a hopeful one — a password-only provider does not satisfy this interface at all.
The core owns everything security-relevant around these two calls: it generates and verifies the state, issues the session, and maps the returned identity to a local user through Resolve. The provider's job is only to talk to its identity source.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
Registry resolves a named AuthProvider. v0.3 replaces the single injected provider with a registry so the plugin loader can add providers (OIDC, GitHub, …) alongside the built-in email/password one; with only the built-in registered it is behaviourally identical to the injected provider.
Concurrency (v0.3 decision): the provider map is IMMUTABLE and held behind an atomic pointer. Readers (Get/Default, on the login hot path) load the pointer lock-free and read a map that never changes under them. A writer (Register, at boot and later on a plugin reload) builds a fresh map copy-on-write and atomically swaps the pointer, under a mutex that serialises writers only. So a register/reload is atomic from a reader's perspective: a lookup in flight resolves to the old map or the new one, never a torn or partially-updated map, never nothing. Pinned by TestAuthRegistryReaderAtomicSwap.
func NewRegistry ¶
func NewRegistry(def AuthProvider) *Registry
NewRegistry builds a registry whose default (the credential provider for the primary email/password login) is def, registered as a protected built-in.
func (*Registry) Default ¶
func (r *Registry) Default() AuthProvider
Default returns the primary credential provider (the built-in registered at construction) — what POST /auth/login authenticates against.
func (*Registry) Deregister ¶
Deregister removes a plugin-registered provider, used on revert-before-death when its plugin terminates. A protected built-in is NEVER removable: email/password is the break-glass path, and a plugin dying must not be able to take it away. Removing an absent name is a no-op.
Removal is fail-closed by construction: once the entry is gone, a login naming that provider resolves nothing and is refused. There is no fallback to another provider — an auth fallback would let a dying plugin silently redirect logins somewhere else.
func (*Registry) Get ¶
func (r *Registry) Get(name string) (AuthProvider, bool)
Get resolves a provider by name. Lock-free.
func (*Registry) HasUsableLogin ¶
HasUsableLogin reports whether at least one login method is available, given the email-login toggle: the built-in email/password path (if emailEnabled), or any registered non-default provider (a redirect/SSO provider, arriving in P4). The platform calls this at boot and refuses to start when it returns false — booting with no way to log in is worse than refusing to boot. In P1–P3 only `email` is registered, so this is false exactly when email login is disabled.
type RejectionError ¶
type RejectionError struct{ Reason string }
RejectionError carries the reason for the operator's log while still matching ErrExternalRejected for the caller.
func (*RejectionError) Error ¶
func (e *RejectionError) Error() string
func (*RejectionError) Is ¶
func (e *RejectionError) Is(target error) bool
type Session ¶
type Session struct {
Token string
UserID uuid.UUID
Role string
CreatedAt time.Time
IP string
UA string
}
Session is the server-side session state stored in Redis.
type SessionStore ¶
type SessionStore struct {
// contains filtered or unexported fields
}
SessionStore manages sessions in Redis: `sess:{token}` hashes with a sliding TTL, plus a `sess:user:{id}` set for O(sessions) bulk revocation.
func NewSessionStore ¶
func NewSessionStore(rdb *redis.Client, ttl time.Duration) *SessionStore
NewSessionStore builds a store with the configured sliding TTL.
func (*SessionStore) Create ¶
func (s *SessionStore) Create(ctx context.Context, userID uuid.UUID, role, ip, ua string) (Session, error)
Create mints a token, writes the session, and indexes it for the user.
func (*SessionStore) Delete ¶
func (s *SessionStore) Delete(ctx context.Context, token string) error
Delete revokes one session.
func (*SessionStore) DeleteAllForUser ¶
func (s *SessionStore) DeleteAllForUser(ctx context.Context, userID uuid.UUID, keepToken string) error
DeleteAllForUser revokes every session of a user (ban, password reset). keepToken, when non-empty, survives (self password-change keeps the current session).
type TokenAdminMeta ¶
TokenAdminMeta is a token's metadata plus its owner (for the admin cross-user view).
type TokenMeta ¶
type TokenMeta struct {
ID uuid.UUID
Name string
Prefix string
Scopes []string
LastUsedAt *time.Time
ExpiresAt *time.Time
CreatedAt time.Time
}
TokenMeta is a token's non-secret metadata (never carries the plaintext or hash).
type TokenService ¶
type TokenService struct {
// contains filtered or unexported fields
}
TokenService issues and resolves API tokens.
func NewTokenService ¶
func NewTokenService(q *gen.Queries) *TokenService
NewTokenService builds a token service over the given queries.
func (*TokenService) Authenticate ¶
func (s *TokenService) Authenticate(ctx context.Context, raw string, now time.Time) (Identity, error)
Authenticate resolves a presented bearer token to an Identity with the owner's LIVE role. Lookup is by prefix + constant-time hash comparison; role and ban are read per request (there is NO cache), so revocation, expiry, ban, and demotion all take effect on the next request. An unknown scope on the token fails closed.
func (*TokenService) Create ¶
func (s *TokenService) Create(ctx context.Context, userID uuid.UUID, name string, scopes []string, expiresAt *time.Time) (plaintext string, meta TokenMeta, err error)
Create issues a token for userID and returns the plaintext (once) plus its metadata.
func (*TokenService) DeleteForUser ¶
DeleteForUser disables all of a user's tokens (the ban hook; token equivalent of DeleteAllForUser for sessions).
func (*TokenService) ListAll ¶
func (s *TokenService) ListAll(ctx context.Context, limit, offset int32) ([]TokenAdminMeta, int64, error)
ListAll returns a page of every token with its owner (admin view; metadata only).
func (*TokenService) Revoke ¶
Revoke deletes one of userID's tokens. Returns false if it wasn't theirs / doesn't exist.