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 ¶
- Constants
- Variables
- func Authorize(ctx context.Context, store OrganizationStore, perms Permissions, ...) error
- func ClearRememberCookie(name string, secure bool) *http.Cookie
- func ConstantTimeEquals(a, b []byte) bool
- func DecodePasskey(record string, credential any) ([]string, error)
- func EqualHash(a, b []byte) bool
- func Forget(ctx context.Context, store ResetStore, userID string) error
- func ForgetOne(ctx context.Context, store ResetStore, plain string) error
- func FromAuthorizationHeader(header string) (string, error)
- func HashPassword(plain string) ([]byte, error)
- func HashResetToken(plain string) []byte
- func HashToken(plain string) []byte
- func MustBelong(ctx context.Context, store OrganizationStore, accountID string) (string, error)
- func NeedsRehash(hash []byte) bool
- func NewRecoveryCodes(ctx context.Context, store ResetStore, userID string, ttl time.Duration) ([]string, error)
- func NewTOTPSecret() (string, error)
- func OrganizationFrom(ctx context.Context) (string, error)
- func Recall(ctx context.Context, store ResetStore, plain string, ttl time.Duration) (userID, replacement string, err error)
- func Redeem(ctx context.Context, store ResetStore, plain string, purpose ResetPurpose) (string, error)
- func Remember(ctx context.Context, store ResetStore, userID string, ttl time.Duration) (string, error)
- func RememberCookie(name, token string, ttl time.Duration, secure bool) *http.Cookie
- func RememberedToken(r *http.Request, name string) (string, error)
- func RemoveMember(ctx context.Context, store OrganizationStore, orgID, accountID string) error
- func ResetPassword(ctx context.Context, store ResetStore, policy PasswordPolicy, ...) (userID string, hash []byte, err error)
- func ScopeTo[T any](ctx context.Context, qb interface{ ... }, column string) (T, error)
- func TOTPCode(secret string, at time.Time) (string, error)
- func TOTPURI(issuer, account, secret string) string
- func UseRecoveryCode(ctx context.Context, store ResetStore, code string) (string, error)
- func VerifyPassword(hash []byte, plain string) error
- func VerifyTOTP(secret, code string, now time.Time, lastStep int64) (int64, error)
- func WithOrganization(ctx context.Context, orgID string) context.Context
- type Account
- type AccountStore
- type Ceremony
- type Dialect
- type Invitation
- type Login
- type Membership
- type OrganizationStore
- type PasskeyConfig
- type PasskeyRecord
- type PasskeyStore
- type Passkeys
- func (p *Passkeys) BeginLogin(ctx context.Context) (*Ceremony, error)
- func (p *Passkeys) BeginRegistration(ctx context.Context, accountID, name, displayName string) (*Ceremony, error)
- func (p *Passkeys) FinishLogin(ctx context.Context, state []byte, r *http.Request) (*Login, error)
- func (p *Passkeys) FinishRegistration(ctx context.Context, accountID, label string, state []byte, r *http.Request) (*PasskeyRecord, error)
- func (p *Passkeys) Revoke(ctx context.Context, accountID string, credentialID []byte, hasPassword bool) error
- type PasswordPolicy
- type Permission
- type Permissions
- type ResetPurpose
- type ResetStore
- type ResetToken
- type Role
- type SQLPasskeyStore
- func (s *SQLPasskeyStore) AddPasskey(ctx context.Context, accountID string, rec *PasskeyRecord, label string) error
- func (s *SQLPasskeyStore) Migrate(ctx context.Context) error
- func (s *SQLPasskeyStore) PasskeyByCredentialID(ctx context.Context, credentialID []byte) (string, *PasskeyRecord, error)
- func (s *SQLPasskeyStore) PasskeysFor(ctx context.Context, accountID string) ([]*PasskeyRecord, error)
- func (s *SQLPasskeyStore) RevokePasskey(ctx context.Context, accountID string, credentialID []byte) error
- func (s *SQLPasskeyStore) WithTable(name string) *SQLPasskeyStore
- type SQLResetStore
- func (s *SQLResetStore) Consume(ctx context.Context, hash []byte, purpose ResetPurpose) (*ResetToken, error)
- func (s *SQLResetStore) DeleteExpired(ctx context.Context, olderThan time.Duration) (int64, error)
- func (s *SQLResetStore) InvalidateUser(ctx context.Context, userID string, purpose ResetPurpose) error
- func (s *SQLResetStore) Migrate(ctx context.Context) error
- func (s *SQLResetStore) Save(ctx context.Context, t *ResetToken) error
- func (s *SQLResetStore) WithTable(name string) *SQLResetStore
- type Token
Examples ¶
Constants ¶
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.
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.
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 ¶
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") )
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.
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.
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.
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.
var ErrMalformedRecord = errors.New("auth: malformed passkey record")
ErrMalformedRecord is returned when a stored record cannot be parsed.
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.
var ErrNoRememberCookie = errors.New("auth: no remember cookie")
ErrNoRememberCookie is returned when the request carries no token.
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.
var ErrPasswordTooLong = fmt.Errorf("auth: password exceeds %d bytes", MaxPasswordBytes)
ErrPasswordTooLong is returned for input bcrypt cannot hash.
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
ClearRememberCookie returns the cookie that removes one.
func ConstantTimeEquals ¶ added in v0.10.0
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
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 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 ¶
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
HashPassword returns a bcrypt hash suitable for storage.
func HashResetToken ¶ added in v0.10.0
HashResetToken returns the stored form, for looking a token up.
func HashToken ¶
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
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
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
NewTOTPSecret returns a fresh shared secret, base32-encoded for display.
func OrganizationFrom ¶ added in v0.10.0
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
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
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
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
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
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
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
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
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
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
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
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
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.
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
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.