auth

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 27 Imported by: 0

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

View Source
const (
	ScopeRead   = "read"
	ScopeSubmit = "submit"
	ScopeAdmin  = "admin"
)

Coarse scopes (v0.3). read = GET, submit = participant mutations, admin = admin routes.

View Source
const CookieName = "osctf_session"

CookieName is the session cookie's name.

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

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

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

View Source
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")
)
View Source
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.

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

View Source
var ErrInvalidHash = errors.New("auth: invalid password hash")

ErrInvalidHash marks a stored hash that cannot be parsed as a PHC argon2id string.

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

View Source
var ErrNoSession = errors.New("auth: no such session")

ErrNoSession is returned when a token does not resolve to a live session.

Functions

func BurnHash

func BurnHash(ctx context.Context, password string) error

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

func ConfigureHashGate(n int, maxWait time.Duration)

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

func GenerateToken() (plaintext, hash, prefix string, err error)

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

func HashPassword(ctx context.Context, password string) (string, error)

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

func IsCommonPassword(pw string) bool

IsCommonPassword reports whether pw is on the embedded deny list.

func NeedsRehash

func NeedsRehash(encoded string) bool

NeedsRehash reports whether the stored hash uses parameters different from the current configuration (upgrade path: re-hash on next successful login).

func ValidateScopes

func ValidateScopes(scopes []string) error

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

func VerifyPassword(ctx context.Context, password, encoded string) (bool, error)

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).

func WithIdentity

func WithIdentity(ctx context.Context, id Identity) context.Context

WithIdentity attaches the identity to the context.

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

func IdentityFrom(ctx context.Context) (Identity, bool)

IdentityFrom returns the caller identity, if any.

func (Identity) IsAdmin

func (id Identity) IsAdmin() bool

IsAdmin reports whether the session role is admin (session-cached; re-check against the DB for admin endpoints).

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.

func (*LoginStateStore) Store

func (s *LoginStateStore) Store(ctx context.Context, token, provider, providerState string) error

Store records an in-flight login under an already-minted token, under the TTL.

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

func (r *Registry) Deregister(name string)

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

func (r *Registry) HasUsableLogin(emailEnabled bool) bool

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.

func (*Registry) Names

func (r *Registry) Names() []string

Names returns the registered provider names (for admin listing / GET /auth/providers).

func (*Registry) Register

func (r *Registry) Register(name string, p AuthProvider, override bool) error

Register adds or replaces a provider. A protected built-in is refused unless override is true. Plugin-registered providers are not protected. The map is swapped atomically.

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).

func (*SessionStore) Get

func (s *SessionStore) Get(ctx context.Context, token string) (Session, error)

Get resolves a token, refreshing the sliding TTL when less than half remains.

type TokenAdminMeta

type TokenAdminMeta struct {
	TokenMeta
	UserID   uuid.UUID
	Username string
}

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

func (s *TokenService) DeleteForUser(ctx context.Context, userID uuid.UUID) error

DeleteForUser disables all of a user's tokens (the ban hook; token equivalent of DeleteAllForUser for sessions).

func (*TokenService) List

func (s *TokenService) List(ctx context.Context, userID uuid.UUID) ([]TokenMeta, error)

List returns the caller's tokens (metadata only).

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

func (s *TokenService) Revoke(ctx context.Context, userID, tokenID uuid.UUID) (bool, error)

Revoke deletes one of userID's tokens. Returns false if it wasn't theirs / doesn't exist.

func (*TokenService) RevokeAny

func (s *TokenService) RevokeAny(ctx context.Context, tokenID uuid.UUID) (bool, error)

RevokeAny deletes a token by id regardless of owner (admin revoke). Returns false if none.

func (*TokenService) Touch

func (s *TokenService) Touch(ctx context.Context, id uuid.UUID) error

Touch records last-used, best-effort (the caller ignores errors — it must not fail a request).

Jump to

Keyboard shortcuts

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