auth

package
v0.13.0 Latest Latest
Warning

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

Go to latest
Published: Aug 7, 2026 License: MIT Imports: 22 Imported by: 0

Documentation

Overview

Package auth is authentication as a library: verbs and interfaces, no server, no tables of its own.

It exists because this project published four advisories and two of them were in generated authentication code -- a password reset that verified nothing, and API tokens stored in plaintext. Generated code cannot be unit-tested, which is precisely why those defects reached users. Everything here can be, and the tests are the argument for the design rather than a formality.

Storage stays yours

Every flow takes a store interface and returns what to persist; nothing here owns a user record, a session or a schema. That is deliberate: a library that insists on owning the users table is the thing applications end up with two of, and it is why swapping the backend later means a migration.

What is here

  • Passwords: bcrypt with cost upgrade on login, a rune-counted policy, and a timing-equalised failure path.
  • Login: lookup and comparison unconditional and in that order.
  • Single-use tokens: password reset, activation, recovery codes and remember-me, separated by purpose and consumed atomically.
  • Two-factor: TOTP per RFC 6238, with replay rejection, and recovery codes.
  • Passkeys: both ceremonies, stored in an opaque interoperable record.
  • Organizations: memberships, roles, permissions, invitations, scoping.
  • API tokens: minted once, stored as a hash.

What is not, and will not be

Sessions and the HTTP layer. A session belongs to the framework the application already chose, and the one decision this package insists on instead of implementing is that the caller renews the session at every point where authentication completes -- password login, 2FA, passkey and remember-me. Skipping it is session fixation, and it is the mistake generated code kept making.

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 DefaultRememberTTL = 30 * 24 * time.Hour

DefaultRememberTTL is thirty days. Long enough to be the convenience it is sold as, short enough that an abandoned laptop stops being a login within a month. The year the scaffolded cookie used was not a considered number.

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.

View Source
const RecoveryCodeCount = 10

RecoveryCodeCount is how many codes are issued at a time. Ten is what every service that does this issues, and it is enough that losing a phone is an inconvenience rather than a support ticket.

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 ErrInvalidCode = errors.New("auth: invalid or already used code")

ErrInvalidCode is returned for a one-time code that does not verify.

One error for wrong, expired and already-used. A form that distinguishes them tells an attacker whether they guessed a code that existed, and there is nothing a legitimate user does differently with the distinction.

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 ErrNoRememberCookie = errors.New("auth: no remember cookie")

ErrNoRememberCookie is returned when the request carries no token.

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.

View Source
var ErrUnknownCredential = errors.New("auth: unknown passkey credential")

ErrUnknownCredential is returned when an authenticator presents a credential this application has never stored.

One error whether the credential is unknown, revoked, or belongs to an account that no longer exists. A login form that distinguishes them tells an attacker which of their guesses corresponds to a real account.

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 ClearRememberCookie added in v0.11.0

func ClearRememberCookie(name string, secure bool) *http.Cookie

ClearRememberCookie returns the cookie that removes one.

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 Forget added in v0.11.0

func Forget(ctx context.Context, store ResetStore, userID string) error

Forget invalidates every remember token of an account.

Log-out calls this. So should a password change: the whole point of changing a password is to end sessions you no longer control, and a remember cookie that survives it makes the change cosmetic.

func ForgetOne added in v0.11.0

func ForgetOne(ctx context.Context, store ResetStore, plain string) error

ForgetOne invalidates a single remember token, the one in this browser's cookie.

This is what logging out should call. Forget would sign the user out of every device they own, which is not what "log out" means on the machine they are sitting at -- and doing it silently is worse than not doing it.

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 NewRecoveryCodes added in v0.11.0

func NewRecoveryCodes(ctx context.Context, store ResetStore, userID string, ttl time.Duration) ([]string, error)

NewRecoveryCodes issues replacement codes for an account, invalidating any it already had.

The plaintext is returned once, for display, and never stored -- these are single-use tokens and are kept exactly the way the password-reset tokens are, on the same store, with a purpose that keeps the two from being redeemed against each other's endpoints.

ttl is long by nature: a recovery code is useful precisely when someone has not been able to log in for a while. Ten years is a reasonable default and still an expiry rather than a token that lives forever.

func NewTOTPSecret added in v0.11.0

func NewTOTPSecret() (string, error)

NewTOTPSecret returns a fresh shared secret, base32-encoded for display.

func OrganizationFrom added in v0.10.0

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

OrganizationFrom returns the active organization.

func Recall added in v0.11.0

func Recall(ctx context.Context, store ResetStore, plain string, ttl time.Duration) (userID, replacement string, err error)

Recall spends a remember token and issues its replacement.

It returns the account the token belonged to and a new plaintext for the cookie. Rotation is the point: a token presented twice is a token that was copied, and after this call the copy is worthless whichever of the two browsers used it first.

The caller must renew the session before treating the user as logged in. Promoting an anonymous session to an authenticated one without renewing it is session fixation, and it is the specific mistake the scaffolded middleware made -- it set userID on whatever session the request arrived with.

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 Remember added in v0.11.0

func Remember(ctx context.Context, store ResetStore, userID string, ttl time.Duration) (string, error)

Remember issues a token that will log userID back in.

Call it when a login completes and the user asked to be remembered. The returned plaintext goes in the cookie; the store keeps only its hash.

func RememberCookie added in v0.11.0

func RememberCookie(name, token string, ttl time.Duration, secure bool) *http.Cookie

RememberCookie builds the cookie for a token.

name should be application-specific, and secure should be true anywhere but local development: a cookie that logs someone in and travels over plaintext HTTP is a credential handed to the network.

func RememberedToken added in v0.11.0

func RememberedToken(r *http.Request, name string) (string, error)

RememberedToken reads the token out of a request.

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 TOTPCode added in v0.11.0

func TOTPCode(secret string, at time.Time) (string, error)

TOTPCode returns the code for a secret at a point in time.

Exported because a caller sometimes needs to produce one -- a test, a diagnostic, a support tool that confirms the server and the phone agree.

func TOTPURI added in v0.11.0

func TOTPURI(issuer, account, secret string) string

TOTPURI builds the otpauth:// URI an authenticator app scans.

issuer appears twice on purpose -- in the label and as a parameter -- which is what Google's key-uri-format document specifies and what apps rely on to show "Example (alex@example.com)" rather than just an address.

func UseRecoveryCode added in v0.11.0

func UseRecoveryCode(ctx context.Context, store ResetStore, code string) (string, error)

UseRecoveryCode spends a code and reports which account it belonged to.

The caller must check that the account matches the one trying to log in. This returns the owner rather than taking it, because the atomic consumption -- which is what stops one code logging in twice -- happens by hash, and a hash does not know whose it is until the row is read.

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 VerifyTOTP added in v0.11.0

func VerifyTOTP(secret, code string, now time.Time, lastStep int64) (int64, error)

VerifyTOTP checks a code and returns the time step it matched.

lastStep is the step this account last authenticated with, and passing it is not optional bookkeeping: RFC 6238 §5.2 requires that a code be accepted only once. Without it a code stays usable for its whole window -- up to ninety seconds with skew -- so one observed over a shoulder, read from a phishing page or left in a log can be replayed. Store the returned step against the account and pass it back next time; pass 0 the first time.

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 Ceremony added in v0.11.0

type Ceremony struct {
	Options json.RawMessage
	State   []byte
}

Ceremony is a challenge in flight.

Options is JSON for the browser: hand it to navigator.credentials.create() or .get() unchanged.

State is opaque and belongs in the user's server-side session until the matching Finish call. It must not travel through a form field, a query parameter or an unsigned cookie: the challenge is the entire replay protection of the ceremony, and one the client can choose is not a challenge.

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 Login added in v0.11.0

type Login struct {
	// AccountID is who authenticated.
	AccountID string

	// CredentialID is which passkey they used, so an application can show
	// "signed in with your phone" or revoke that specific credential.
	CredentialID []byte

	// SignCount is the authenticator's counter. Synced passkeys report zero
	// forever; see the note on clone detection at the top of this file.
	SignCount uint32
}

Login is the outcome of a successful authentication ceremony.

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 PasskeyConfig added in v0.11.0

type PasskeyConfig struct {
	// RelyingPartyID is the origin's registrable domain without scheme or port,
	// "example.com" for https://app.example.com.
	//
	// It is baked into every credential the authenticator creates, and changing
	// it invalidates all of them. Set it to the registrable domain rather than
	// the current host, or moving from example.com to app.example.com becomes a
	// re-registration for every user.
	RelyingPartyID string

	// RelyingPartyName is what the authenticator shows the user.
	RelyingPartyName string

	// Origins are the full origins allowed to run ceremonies,
	// "https://app.example.com". At least one is required.
	Origins []string

	// RequireUserVerification demands a PIN, biometric or equivalent rather
	// than mere presence.
	//
	// Off by default, which is the WebAuthn default and the right one for a
	// second factor. Turn it on when a passkey is the only factor, because
	// without it "possession of an unlocked laptop" is the whole authentication.
	RequireUserVerification bool
}

PasskeyConfig identifies the relying party -- this application, as the authenticator sees it.

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 Passkeys added in v0.11.0

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

Passkeys runs the registration and authentication ceremonies.

func NewPasskeys added in v0.11.0

func NewPasskeys(cfg PasskeyConfig, store PasskeyStore) (*Passkeys, error)

NewPasskeys returns a ceremony runner.

func (*Passkeys) BeginLogin added in v0.11.0

func (p *Passkeys) BeginLogin(ctx context.Context) (*Ceremony, error)

BeginLogin starts a discoverable login: the authenticator offers the account, so there is no username field and nothing to enumerate.

func (*Passkeys) BeginRegistration added in v0.11.0

func (p *Passkeys) BeginRegistration(ctx context.Context, accountID, name, displayName string) (*Ceremony, error)

BeginRegistration starts adding a passkey to an existing, authenticated account.

accountID is the stable internal identifier and becomes the WebAuthn user handle, which is stored by the authenticator and synced to the user's other devices. Do not pass an email address: it is neither stable nor private, and it ends up on hardware outside your control.

name and displayName are shown in the authenticator's account picker.

func (*Passkeys) FinishLogin added in v0.11.0

func (p *Passkeys) FinishLogin(ctx context.Context, state []byte, r *http.Request) (*Login, error)

FinishLogin verifies the authenticator's response and reports who signed in.

It does not create a session. That is the caller's, and it is where the session has to be renewed -- a passkey login that reuses the anonymous session id is a session-fixation bug wearing better cryptography.

func (*Passkeys) FinishRegistration added in v0.11.0

func (p *Passkeys) FinishRegistration(ctx context.Context, accountID, label string, state []byte, r *http.Request) (*PasskeyRecord, error)

FinishRegistration verifies the authenticator's response and stores the credential.

accountID must be the account the ceremony was begun for -- read it from the session, never from the request body. state is what BeginRegistration returned.

func (*Passkeys) Revoke added in v0.11.0

func (p *Passkeys) Revoke(ctx context.Context, accountID string, credentialID []byte, hasPassword bool) error

Revoke removes one credential from an account.

It refuses to remove the last one when the account has no password, because that is a lockout with no recovery path -- the account would be reachable only by a support ticket against the database. hasPassword is the caller's to answer: this package does not own the user record and cannot see it.

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"
)
const PurposeRecoveryCode ResetPurpose = "recovery_code"

PurposeRecoveryCode marks a single-use code that substitutes for an authenticator app.

const PurposeRemember ResetPurpose = "remember"

PurposeRemember marks a token that stands in for a completed login.

Separated from the reset and activation purposes for the reason all of them are: without it, a token minted to keep someone signed in would be redeemable against the password-reset endpoint.

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 SQLPasskeyStore added in v0.11.0

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

SQLPasskeyStore is a PasskeyStore over database/sql.

One row per credential, and the credential itself is one opaque column. There is no column for the public key, the attestation format or the signature counter, and that is the whole design: decomposing the credential into fields would freeze this library's idea of a credential into the schema, which is exactly the migration the record format exists to avoid.

func NewSQLPasskeyStore added in v0.11.0

func NewSQLPasskeyStore(db *sql.DB, dialect Dialect) *SQLPasskeyStore

NewSQLPasskeyStore returns a store using the default table name.

func (*SQLPasskeyStore) AddPasskey added in v0.11.0

func (s *SQLPasskeyStore) AddPasskey(ctx context.Context, accountID string, rec *PasskeyRecord, label string) error

AddPasskey stores a credential.

The credential id is the primary key, so registering the same credential twice is a conflict rather than a duplicate row -- an authenticator that somehow re-registers an existing key does not silently produce two rows that revocation then has to remove one at a time.

func (*SQLPasskeyStore) Migrate added in v0.11.0

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

Migrate creates the table if it does not exist.

func (*SQLPasskeyStore) PasskeyByCredentialID added in v0.11.0

func (s *SQLPasskeyStore) PasskeyByCredentialID(ctx context.Context, credentialID []byte) (string, *PasskeyRecord, error)

PasskeyByCredentialID finds the account a credential belongs to.

func (*SQLPasskeyStore) PasskeysFor added in v0.11.0

func (s *SQLPasskeyStore) PasskeysFor(ctx context.Context, accountID string) ([]*PasskeyRecord, error)

PasskeysFor returns every credential of an account, newest first.

func (*SQLPasskeyStore) RevokePasskey added in v0.11.0

func (s *SQLPasskeyStore) RevokePasskey(ctx context.Context, accountID string, credentialID []byte) error

RevokePasskey removes one credential from an account.

Scoped by account as well as credential id, so a caller that mixes up whose credential it is holding removes nothing rather than someone else's key.

func (*SQLPasskeyStore) WithTable added in v0.11.0

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

WithTable overrides the table name.

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