auth

package
v0.10.0 Latest Latest
Warning

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

Go to latest
Published: Aug 5, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package auth holds the authentication primitives that were previously only present as generated code.

It is deliberately narrow. #52 proposes extracting the whole authentication system as a framework-agnostic module, which is a larger and riskier piece of work than it looks -- roughly 1,100 lines currently living in templates, plus store interfaces that do not exist yet, plus a security review that the issue itself sets as a precondition for tagging. Cramming that into the tail of a release is how auth libraries ship with the bug they were written to avoid.

What is here instead is the part that is self-contained, has no dependency on a store or a session, and is where two of this project's four advisories actually lived: API token generation and verification. Generated code cannot be unit-tested, which is precisely why those defects reached users. This can.

The remaining flows -- users, sessions, 2FA, password reset -- stay in templates until they get their own change with its own review.

Example

Example shows the two halves of a token's life. It is compiled and run by `go test`, so it cannot drift from the API the way a README snippet can.

// At issue time: show the plaintext to the user, store only the hash.
issued, err := NewToken(24 * time.Hour)
if err != nil {
	panic(err)
}
stored := struct {
	Hash   []byte
	Expiry time.Time
}{issued.Hash, issued.Expiry}

// At request time: the plaintext arrives in a header, and the stored hash
// is all that is needed to check it.
presented, err := FromAuthorizationHeader("Bearer " + issued.PlainText)
if err != nil {
	panic(err)
}

check := &Token{Hash: stored.Hash, Expiry: stored.Expiry}
fmt.Println(check.Verify(presented, time.Now()) == nil)

// A token that is not ours does not verify.
fmt.Println(check.Verify("AAAAAAAAAAAAAAAAAAAAAAAAAA", time.Now()) == nil)
Output:
true
false

Index

Examples

Constants

View Source
const MaxPasswordBytes = 72

MaxPasswordBytes is bcrypt's input limit.

bcrypt hashes at most 72 bytes. Historically it truncated silently, which meant a library accepting a 200-byte passphrase and comparing the first 72 was telling the user their password is stronger than it is. x/crypto now returns an error instead, but the limit still has to be checked before hashing so validation can report it rather than discovering it at write time.

golang.org/x/crypto/bcrypt does not export this as a constant.

Variables

View Source
var (
	// ErrNotAMember is returned when an account is not in an organization.
	ErrNotAMember = errors.New("auth: not a member of this organization")

	// ErrForbidden is returned when a member lacks the required permission.
	ErrForbidden = errors.New("auth: insufficient permission")

	// ErrInvitationInvalid covers unknown, expired and already-accepted
	// invitations, for the same reason ErrInvalidReset covers three cases:
	// distinguishing them lets someone probe which invitations exist.
	ErrInvitationInvalid = errors.New("auth: invalid or expired invitation")
)
View Source
var ErrBadCredentials = errors.New("auth: invalid credentials")

ErrBadCredentials is returned when an email or password does not match.

One error for both, deliberately. Distinguishing "no such user" from "wrong password" turns the login form into a user-enumeration oracle, and every application that has ever leaked its user list to a signup form has done it by being helpful here.

View Source
var ErrInvalidReset = errors.New("auth: invalid or expired reset token")

ErrInvalidReset is returned for a reset token that is unknown, already used, or expired.

One error for all three. Telling a caller which it was hands an attacker a way to probe whether a token existed, and there is nothing a legitimate user can do differently with the distinction anyway.

View Source
var ErrInvalidToken = errors.New("auth: invalid token")

ErrInvalidToken is returned when a token is malformed, unknown or expired.

One error for all three, deliberately: distinguishing "no such token" from "expired token" tells an attacker which of their guesses existed.

View Source
var ErrMalformedRecord = errors.New("auth: malformed passkey record")

ErrMalformedRecord is returned when a stored record cannot be parsed.

View Source
var ErrNoActiveOrganization = errors.New("auth: no active organization in this context")

ErrNoActiveOrganization is returned when a scoped operation is attempted without one.

Deliberately an error rather than a silent unscoped query. A helper that quietly dropped the filter when no organization was set would be the exact bug it was written to prevent.

View Source
var ErrNotActivated = errors.New("auth: account is not activated")

ErrNotActivated is returned when credentials are correct but the account has not been activated.

Distinct from ErrBadCredentials on purpose, and it is the one distinction worth making: it is only ever returned *after* the password has been verified, so it reveals nothing to someone who does not already know the password.

View Source
var ErrPasswordTooLong = fmt.Errorf("auth: password exceeds %d bytes", MaxPasswordBytes)

ErrPasswordTooLong is returned for input bcrypt cannot hash.

Functions

func Authorize added in v0.10.0

func Authorize(ctx context.Context, store OrganizationStore, perms Permissions, orgID, accountID string, permission Permission) error

Authorize reports whether an account may perform a permission in an organization.

The membership lookup happens on every call rather than being cached in the session. A session that carried the role would keep granting it after the person was removed from the organization, until they happened to log out -- which is the difference between revocation and a suggestion.

func ConstantTimeEquals added in v0.10.0

func ConstantTimeEquals(a, b []byte) bool

ConstantTimeEquals compares two secrets without leaking their contents through timing.

Exported because callers keep needing it for their own tokens and keep reaching for == instead.

func DecodePasskey added in v0.10.0

func DecodePasskey(record string, credential any) ([]string, error)

DecodePasskey parses a stored record into credential, which must be a pointer to the type the WebAuthn library expects.

The version is checked but the payload is not interpreted here. A record from a future version is refused rather than guessed at.

func EqualHash added in v0.10.0

func EqualHash(a, b []byte) bool

EqualHash compares two token hashes in constant time.

func FromAuthorizationHeader

func FromAuthorizationHeader(header string) (string, error)

FromAuthorizationHeader extracts a bearer token.

It rejects anything that is not exactly "Bearer <token>" of the right length, rather than accepting a prefix match. A header of "Bearer" with no value, or with extra fields after the token, is a malformed request rather than something to interpret generously.

func HashPassword added in v0.10.0

func HashPassword(plain string) ([]byte, error)

HashPassword returns a bcrypt hash suitable for storage.

func HashResetToken added in v0.10.0

func HashResetToken(plain string) []byte

HashResetToken returns the stored form, for looking a token up.

func HashToken

func HashToken(plain string) []byte

HashToken returns the stored form of a plaintext token.

SHA-256 rather than bcrypt, and that is not an oversight. A password is low-entropy and chosen by a human, so it needs a slow hash to survive being guessed. A token is 128 bits from crypto/rand, so there is nothing to guess, and a slow hash would only mean every authenticated API request pays for a key-derivation function.

func MustBelong added in v0.10.0

func MustBelong(ctx context.Context, store OrganizationStore, accountID string) (string, error)

MustBelong checks membership before a scoped operation.

Scoping alone answers "which rows", not "may this person see them". An account with no membership at all still gets a syntactically valid scoped query; this is what makes it an empty one on purpose rather than by accident.

func NeedsRehash added in v0.10.0

func NeedsRehash(hash []byte) bool

NeedsRehash reports whether a stored hash was made with a weaker cost than the current default, so it can be upgraded on the next successful login.

Without this, a password hashed at cost 10 in 2019 is still at cost 10 today. The upgrade is free: at login the plaintext is in hand for the only moment it ever will be.

func OrganizationFrom added in v0.10.0

func OrganizationFrom(ctx context.Context) (string, error)

OrganizationFrom returns the active organization.

func Redeem added in v0.10.0

func Redeem(ctx context.Context, store ResetStore, plain string, purpose ResetPurpose) (string, error)

Redeem consumes a token and returns the user it was minted for.

The expiry check happens here rather than being left to the store, so every implementation gets it. Stores are still expected to filter on expiry in SQL for the sake of the index, but correctness does not depend on them doing so.

func RemoveMember added in v0.10.0

func RemoveMember(ctx context.Context, store OrganizationStore, orgID, accountID string) error

RemoveMember removes someone, refusing to remove the last owner.

An organization with no owner cannot be administered, cannot be deleted, and cannot have a new owner appointed -- it is stranded, and recovering it is a support ticket against the database. Refusing is cheaper than the recovery procedure.

func ResetPassword added in v0.10.0

func ResetPassword(ctx context.Context, store ResetStore, policy PasswordPolicy, plainToken, newPassword string) (userID string, hash []byte, err error)

ResetPassword redeems a token and returns the new password hash to store.

It does not write anything. The caller updates the password and marks the token used in one transaction -- which is why Consume is part of the store interface rather than something this function calls on its own schedule.

Every other outstanding reset token for the user is invalidated, because a password change means the previous ones were either used or should not be.

func ScopeTo added in v0.10.0

func ScopeTo[T any](ctx context.Context, qb interface {
	Where(string, string, interface{}) T
}, column string) (T, error)

ScopeTo adds the organization filter to a query builder.

Generic over the builder type so it composes with the fluent API without the auth package depending on it:

qb, err := auth.ScopeTo(ctx, database.NewQueryBuilder(db).Table("invoices"), "organization_id")
if err != nil {
    return err
}
rows, err := qb.Where("status", "=", "unpaid").Get()

It returns an error rather than an unscoped builder when no organization is active, so the mistake is loud.

func VerifyPassword added in v0.10.0

func VerifyPassword(hash []byte, plain string) error

VerifyPassword reports whether plain matches hash.

Pass a nil or empty hash when the user was not found. It still performs a full bcrypt comparison against a dummy value, so the timing of a failed login does not reveal whether the account exists. That only holds if the caller keeps doing the lookup-then-verify sequence unconditionally -- returning early on "user not found" puts the oracle back.

func WithOrganization added in v0.10.0

func WithOrganization(ctx context.Context, orgID string) context.Context

WithOrganization returns a context carrying the active organization.

Set it in middleware, once, from the session. Passing the organization as a function argument everywhere is the alternative and it fails the same way a forgotten WHERE clause does: the one call site that omits it compiles.

Types

type Account added in v0.10.0

type Account interface {
	// AuthID is a stable identifier for this account, used as the subject of
	// sessions and reset tokens. It must not change when the email does.
	AuthID() string

	// PasswordHash returns the stored hash, or nil if the account has no
	// password -- which is normal for an account that only uses a passkey or
	// a social provider.
	PasswordHash() []byte

	// Activated reports whether the account may sign in.
	Activated() bool
}

Account is the minimum a stored user must expose for authentication.

Deliberately tiny. Everything else -- names, avatars, preferences, whatever the application actually cares about -- stays in the application's own type, which embeds or wraps this. A library that insisted on owning the user record is the thing Val Town left Clerk over: an app that needs to join on users ends up with two users tables.

func Authenticate added in v0.10.0

func Authenticate(ctx context.Context, store AccountStore, email, password string) (Account, error)

Authenticate verifies an email and password.

The lookup and the verification happen unconditionally, in that order, so a login attempt for an address that does not exist costs the same as one that does. Short-circuiting on "no such user" is the single most common way to turn a login form into a user-enumeration oracle, and it is why VerifyPassword accepts a nil hash rather than the caller checking first.

It returns ErrBadCredentials for both a missing account and a wrong password, and never says which.

func AuthenticateAndUpgrade added in v0.10.0

func AuthenticateAndUpgrade(ctx context.Context, store AccountStore, email, password string, upgrade func(ctx context.Context, account Account, newHash []byte)) (Account, error)

AuthenticateAndUpgrade is Authenticate plus an opportunistic rehash.

Login is the only moment the plaintext password exists, so it is the only moment a hash made at an outdated cost can be upgraded. Without this, a password hashed in 2019 stays at 2019's cost forever.

upgrade is called with the new hash when one was produced. It is the caller's job to store it, and a failure to do so must not fail the login -- the user authenticated correctly, and a storage problem is not their problem.

type AccountStore added in v0.10.0

type AccountStore interface {
	// ByEmail returns the account for an address.
	//
	// It must return (nil, nil) when there is no such account rather than an
	// error, so Authenticate can do the timing-equalising dummy comparison.
	// Returning an error here reintroduces the enumeration oracle, because a
	// missing user then costs a round trip and a present one costs bcrypt.
	ByEmail(ctx context.Context, email string) (Account, error)
}

AccountStore looks up accounts for authentication.

type Dialect added in v0.10.0

type Dialect int

Dialect selects placeholder syntax and the atomic-claim strategy.

const (
	// DialectPostgres uses $1 placeholders and UPDATE ... RETURNING.
	DialectPostgres Dialect = iota
	// DialectMySQL uses ? placeholders and a transaction with SELECT ... FOR UPDATE.
	DialectMySQL
	// DialectSQLite uses ? placeholders and a transaction; SQLite serialises
	// writers, which provides the same exclusivity.
	DialectSQLite
)

type Invitation added in v0.10.0

type Invitation struct {
	PlainText      string
	Hash           []byte
	OrganizationID string
	Email          string
	Role           Role
	InvitedBy      string
	Expiry         time.Time
}

Invitation is a single-use, expiring offer of membership.

It reuses the reset-token design rather than inventing a second one: the plaintext goes in the emailed link, only the hash is stored, and acceptance is atomic. An invitation that can be accepted twice creates two memberships, and an invitation whose token is stored in plaintext is a membership anyone with database access can grant themselves.

func NewInvitation added in v0.10.0

func NewInvitation(orgID, email string, role Role, invitedBy string, ttl time.Duration) (*Invitation, error)

NewInvitation mints an invitation.

type Membership added in v0.10.0

type Membership struct {
	OrganizationID string
	AccountID      string
	Role           Role
	JoinedAt       time.Time
}

Membership ties an account to an organization with a role.

func AcceptInvitation added in v0.10.0

func AcceptInvitation(ctx context.Context, store OrganizationStore, plainToken, accountID, accountEmail string) (*Membership, error)

AcceptInvitation consumes an invitation and creates the membership.

The email on the invitation is checked against the accepting account's address. Without it, anyone who obtains the link -- a forwarded email, a shared inbox, a leaked notification -- joins the organization, which turns an invitation into a bearer token for membership.

type OrganizationStore added in v0.10.0

type OrganizationStore interface {
	// MembershipOf returns the membership, or (nil, nil) when there is none.
	MembershipOf(ctx context.Context, orgID, accountID string) (*Membership, error)

	// MembershipsFor returns every organization an account belongs to.
	MembershipsFor(ctx context.Context, accountID string) ([]*Membership, error)

	// AddMember creates a membership. It must be idempotent or reject
	// duplicates; two memberships for one account in one organization is a
	// state nothing else here handles.
	AddMember(ctx context.Context, m *Membership) error

	// RemoveMember deletes a membership.
	RemoveMember(ctx context.Context, orgID, accountID string) error

	// SaveInvitation persists an invitation. Only the hash is stored.
	SaveInvitation(ctx context.Context, inv *Invitation) error

	// ConsumeInvitation atomically finds an unaccepted, unexpired invitation
	// by hash and marks it accepted.
	//
	// Atomic for the same reason ResetStore.Consume is: a read followed by a
	// write lets two requests both accept, and two memberships is a state the
	// rest of this package does not expect.
	ConsumeInvitation(ctx context.Context, hash []byte) (*Invitation, error)

	// CountOwners returns how many owners an organization has.
	CountOwners(ctx context.Context, orgID string) (int, error)
}

OrganizationStore is the storage the caller provides.

type PasskeyRecord added in v0.10.0

type PasskeyRecord struct {
	// Record is the opaque stored form. This is the only field that belongs in
	// a database column.
	Record string

	// CredentialID identifies the credential to the authenticator. Stored
	// separately only because lookups need an indexable key -- it is derivable
	// from Record.
	CredentialID []byte

	// Transports is a hint about how the authenticator is reachable
	// ("internal", "hybrid", "usb", "nfc", "ble").
	Transports []string
}

PasskeyRecord is one stored credential.

It is produced by EncodePasskey and consumed by DecodePasskey; applications store Record and nothing else. The struct is exported so a caller can inspect what they have, not so they can build the string themselves.

func EncodePasskey added in v0.10.0

func EncodePasskey(credentialID []byte, credential any, transports []string) (*PasskeyRecord, error)

EncodePasskey renders a credential into the opaque record format.

credential is whatever the WebAuthn library produced. It is marshalled to JSON and base64'd rather than being decomposed into fields, which is the whole point: this package does not claim to know the shape of a credential, and neither should the database.

type PasskeyStore added in v0.10.0

type PasskeyStore interface {
	// AddPasskey stores a record for an account. An account may have several.
	AddPasskey(ctx context.Context, accountID string, rec *PasskeyRecord, label string) error

	// PasskeysFor returns every record for an account.
	PasskeysFor(ctx context.Context, accountID string) ([]*PasskeyRecord, error)

	// PasskeyByCredentialID finds the account and record for a credential.
	// Returns ("", nil, nil) when there is no such credential.
	PasskeyByCredentialID(ctx context.Context, credentialID []byte) (accountID string, rec *PasskeyRecord, err error)

	// RevokePasskey removes one credential from an account.
	//
	// Revoking must not be able to leave an account with no way in. That check
	// belongs to the caller, which knows whether a password is also set --
	// this interface deliberately does not, because guessing would either
	// block a legitimate revocation or allow a lockout.
	RevokePasskey(ctx context.Context, accountID string, credentialID []byte) error
}

PasskeyStore is the storage the caller provides.

Note what it does not have: an update method for the credential itself. Passkeys are not edited, they are added and revoked, and an interface that offered mutation would invite an application to rewrite a record it should treat as opaque.

type PasswordPolicy added in v0.10.0

type PasswordPolicy struct {
	// MinRunes is measured in runes, not bytes, so a passphrase in a
	// non-Latin script is not penalised for its encoding.
	MinRunes int
}

PasswordPolicy is the minimum a password must satisfy.

Deliberately just a length floor. Composition rules -- one uppercase, one digit, one symbol -- push people towards Password1! and are recommended against by NIST SP 800-63B, which asks for length and a breached-password check instead. The breach check is the caller's to make; this library will not ship a wordlist.

func DefaultPasswordPolicy added in v0.10.0

func DefaultPasswordPolicy() PasswordPolicy

DefaultPasswordPolicy is 12 runes, following NIST SP 800-63B's guidance to favour length over composition.

func (PasswordPolicy) Validate added in v0.10.0

func (p PasswordPolicy) Validate(plain string) error

Validate reports whether plain satisfies the policy.

type Permission added in v0.10.0

type Permission string

Permission is an action within an organization.

const (
	PermManageMembers Permission = "members:manage"
	PermManageBilling Permission = "billing:manage"
	PermManageOrg     Permission = "org:manage"
	PermDeleteOrg     Permission = "org:delete"
	PermRead          Permission = "read"
	PermWrite         Permission = "write"
)

type Permissions added in v0.10.0

type Permissions map[Role][]Permission

Permissions maps roles to what they may do.

A map rather than a hierarchy: "admin implies member" reads well and then someone adds a role that is not on the line and the model stops describing reality. Listing them is longer and stays true.

func DefaultPermissions added in v0.10.0

func DefaultPermissions() Permissions

DefaultPermissions is the three-role model almost every application starts with. Callers add roles by extending it, which is why it returns a fresh map.

func (Permissions) Allows added in v0.10.0

func (p Permissions) Allows(role Role, permission Permission) bool

Allows reports whether role may perform permission.

type ResetPurpose added in v0.10.0

type ResetPurpose string

ResetPurpose distinguishes what a single-use token is for, so one minted for account activation cannot be spent on a password reset.

Without it, two flows sharing a table share an attack surface: an activation link mailed to an unverified address would be redeemable against the password endpoint.

const (
	PurposePasswordReset ResetPurpose = "password_reset"
	PurposeActivation    ResetPurpose = "activation"
	PurposeEmailChange   ResetPurpose = "email_change"
)

type ResetStore added in v0.10.0

type ResetStore interface {
	// Save persists a token. Only Hash, UserID, Purpose and Expiry are stored;
	// PlainText must not be.
	Save(ctx context.Context, t *ResetToken) error

	// Consume atomically finds the token with this hash and purpose, marks it
	// used, and returns it. It must return ErrInvalidReset if no unused,
	// unexpired token matches -- and must not report which condition failed.
	//
	// Implement it as a single UPDATE ... WHERE hash = ? AND used_at IS NULL
	// RETURNING, or as a transaction. A SELECT followed by an UPDATE lets two
	// requests both see the token as unused.
	Consume(ctx context.Context, hash []byte, purpose ResetPurpose) (*ResetToken, error)

	// InvalidateUser marks every outstanding token for a user as used.
	InvalidateUser(ctx context.Context, userID string, purpose ResetPurpose) error
}

ResetStore is the storage the caller provides.

Consume is deliberately one operation rather than a read followed by a delete. A reset token that can be read, validated and then redeemed twice concurrently is a race with account takeover at the end of it, and pushing the atomicity requirement into the interface is the only way a library can insist on it.

type ResetToken added in v0.10.0

type ResetToken struct {
	// PlainText is set only by NewResetToken. It goes in the emailed link and
	// is never stored.
	PlainText string

	// Hash is what the row keeps. A database read gives an attacker nothing
	// they can redeem.
	Hash []byte

	UserID  string
	Purpose ResetPurpose
	Expiry  time.Time
}

ResetToken is a single-use credential bound to a user.

It carries no identity of its own. That is the correction to GHSA-44g2-5v2v-xh66, where the scaffolded flow decrypted an email address out of a form field using unauthenticated AES-CFB -- so an attacker could bit-flip their own token's ciphertext into another address and reset that account's password. The identity now lives in the row this token's hash points at, and the token is nothing but an unguessable lookup key.

func NewResetToken added in v0.10.0

func NewResetToken(userID string, purpose ResetPurpose, ttl time.Duration) (*ResetToken, error)

NewResetToken mints a token for userID valid for ttl.

type Role added in v0.10.0

type Role string

Role is a named set of permissions within an organization.

const (
	RoleOwner  Role = "owner"
	RoleAdmin  Role = "admin"
	RoleMember Role = "member"
)

type SQLResetStore added in v0.10.0

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

SQLResetStore is a ResetStore over database/sql.

It exists because Consume has to be atomic, and that is exactly the requirement an application implementing the interface by hand will get wrong: a SELECT to check the token followed by an UPDATE to mark it used lets two concurrent requests both see it as unused, and the prize for winning that race is somebody else's account.

Shipping a correct implementation is cheaper than documenting the hazard and hoping.

func NewSQLResetStore added in v0.10.0

func NewSQLResetStore(db *sql.DB, dialect Dialect) *SQLResetStore

NewSQLResetStore returns a store using the default table name.

func (*SQLResetStore) Consume added in v0.10.0

func (s *SQLResetStore) Consume(ctx context.Context, hash []byte, purpose ResetPurpose) (*ResetToken, error)

Consume claims a token atomically.

func (*SQLResetStore) DeleteExpired added in v0.10.0

func (s *SQLResetStore) DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)

DeleteExpired removes spent and expired rows.

Without this the table grows forever: every password-reset request anyone ever made stays in it. Run it from the scheduler.

func (*SQLResetStore) InvalidateUser added in v0.10.0

func (s *SQLResetStore) InvalidateUser(ctx context.Context, userID string, purpose ResetPurpose) error

func (*SQLResetStore) Migrate added in v0.10.0

func (s *SQLResetStore) Migrate(ctx context.Context) error

Migrate creates the table if it does not exist.

func (*SQLResetStore) Save added in v0.10.0

func (s *SQLResetStore) Save(ctx context.Context, t *ResetToken) error

func (*SQLResetStore) WithTable added in v0.10.0

func (s *SQLResetStore) WithTable(name string) *SQLResetStore

WithTable overrides the table name.

type Token

type Token struct {
	// PlainText is populated only by NewToken. It is never read back from
	// storage, because storage never has it.
	//
	// v0.7.0 fixed exactly this: tokens were persisted in plaintext and
	// serialised into JSON responses, so a database read or a logged response
	// body handed over working credentials.
	PlainText string `json:"-"`

	// Hash is what gets stored and compared.
	Hash []byte `json:"-"`

	Expiry time.Time `json:"expiry"`
}

Token is an API token: a plaintext value shown to the user exactly once, and a hash that is all the server retains.

func NewToken

func NewToken(ttl time.Duration) (*Token, error)

NewToken mints a token valid for ttl.

The plaintext exists only in the returned value. Show it to the user once and store the hash; there is no way to recover it afterwards, which is the point.

func (*Token) Verify

func (t *Token) Verify(plain string, now time.Time) error

Verify reports whether plain matches this token and has not expired.

Constant-time comparison: one that returns on the first differing byte leaks the stored hash to anyone willing to measure, one byte at a time.

Jump to

Keyboard shortcuts

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