auth

package module
v0.0.0-...-fc146fb Latest Latest
Warning

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

Go to latest
Published: Oct 5, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

Documentation

Overview

Package auth owns identity: users, their credentials, and their sessions. It is the only place in the system that handles a password, and the only place that can turn a session token into a user. Other services hold a user reference and nothing else.

Index

Constants

View Source
const (
	// ServiceName is the service segment of every reference this package owns.
	ServiceName = "auth"
	// UserResourceType is the type segment of a user reference.
	UserResourceType = "user"
	// GroupAuthenticated holds everyone with a live session. authz knows it only as a subject that
	// a caller names along with the user.
	GroupAuthenticated = "urn:" + ServiceName + ":group:authenticated"
)
View Source
const (
	MinUsernameLength = 3
	MaxUsernameLength = 32
)
View Source
const MaxEmailLength = 254

Variables

View Source
var (
	// ErrInvalidCredentials covers an unknown account, a wrong password and an account with no
	// password alike. Telling them apart would turn the login form into an account oracle.
	ErrInvalidCredentials = errors.New("invalid username, email or password")

	ErrUserNotFound = errors.New("user not found")
	// ErrInvalidUsername wraps every username rule failure, so callers can show the reason without
	// matching on message text.
	ErrInvalidUsername = errors.New("invalid username")
	ErrUsernameTaken   = errors.New("username is already taken")
	ErrInvalidEmail    = errors.New("invalid email address")
	ErrEmailTaken      = errors.New("email address is already in use")
	ErrSessionNotFound = errors.New("session not found")
	// ErrTokenNotFound covers a token that never existed, was used, has expired, or is for another
	// purpose. A link that does not work gets one answer whatever the reason.
	ErrTokenNotFound = errors.New("link is invalid or has expired")

	ErrIdentityNotFound = errors.New("identity not found")
	ErrIdentityTaken    = errors.New("identity is linked to another account")
	// ErrAccountExists stops a provider sign-in from taking over an account whose email address
	// nobody has proved. The person signs in another way and links the provider from settings.
	ErrAccountExists = errors.New("an account with this email address already exists")
	// ErrLastSignInMethod keeps an account from losing its only way in.
	ErrLastSignInMethod = errors.New("this is the account's only way to sign in")
	ErrSignUpClosed     = errors.New("sign-up is closed")

	// ErrSetupClosed is returned once the first user exists. Setup never reopens.
	ErrSetupClosed = errors.New("setup is already complete")
)

Functions

func NormalizeEmail

func NormalizeEmail(email string) string

NormalizeEmail lowercases the whole address. The local part is case-sensitive by the letter of RFC 5321, but no mainstream provider treats it so, and two accounts differing only in case would be a support problem rather than a feature.

func NormalizeUsername

func NormalizeUsername(username string) string

NormalizeUsername is applied before every lookup and every insert, so "Ada" and "ada" are the same account rather than two.

func UsernameFromEmail

func UsernameFromEmail(email string) string

UsernameFromEmail proposes a valid username for an account whose owner never picked one: the email's local part with every other character turned into a hyphen.

func ValidateEmail

func ValidateEmail(email string) error

ValidateEmail accepts a normalized bare address: no display name, no angle brackets.

func ValidateUsername

func ValidateUsername(username string) error

ValidateUsername accepts a normalized username.

TODO: usernames are not NFKC-normalized or checked for confusables (UTS #39), so two visually identical names can coexist. That matters once usernames are shown as an identity claim.

Types

type ChangePasswordRequest

type ChangePasswordRequest struct {
	UserID          string
	CurrentPassword string
	NewPassword     string
	// KeepSessionID survives the revocation of the user's other sessions, so changing a password
	// does not log the browser doing it straight back out.
	KeepSessionID string
}

type FirstUserHook

type FirstUserHook interface {
	OnFirstUser(ctx context.Context, userRef string) error
}

FirstUserHook runs once, when setup creates the very first user, so the composition root can make that account an administrator without auth knowing an authorization service exists.

type FirstUserHookFunc

type FirstUserHookFunc func(ctx context.Context, userRef string) error

func (FirstUserHookFunc) OnFirstUser

func (f FirstUserHookFunc) OnFirstUser(ctx context.Context, userRef string) error

type Identity

type Identity struct {
	Provider string
	// Subject is the provider's stable id for the account. An email address can change; this
	// cannot.
	Subject   string
	UserID    string
	Email     string
	CreatedAt time.Time
}

Identity links an account at an external provider, such as Google, to a user.

type IdentityRepository

type IdentityRepository interface {
	Insert(ctx context.Context, identity *Identity) error
	Get(ctx context.Context, provider, subject string) (*Identity, error)
	// ListByUser orders by provider, then subject.
	ListByUser(ctx context.Context, userID string) ([]Identity, error)
	Delete(ctx context.Context, userID, provider, subject string) error
}

IdentityRepository returns ErrIdentityNotFound for an absent identity and ErrIdentityTaken for a collision.

type Links interface {
	VerifyEmail(token string) string
	ResetPassword(token string) string
	EmailLogin(token, next string) string
	ConfirmEmailChange(token string) string
}

Links turns a token into the absolute URL mailed to the person. The pages own their paths, so they build the links.

type Mailer

type Mailer interface {
	Send(ctx context.Context, message mail.Message) error
}

type Options

type Options struct {
	Passwords     *hash.Registry
	FirstUserHook FirstUserHook
	SessionTTL    time.Duration
	Mailer        Mailer
	Links         Links
	Logger        *slog.Logger
}

Options are the dependencies a Service needs beyond its storage.

type ProviderClaims

type ProviderClaims struct {
	Subject       string
	Email         string
	EmailVerified bool
	Name          string
}

ProviderClaims is what a provider vouches for about the person signing in.

type RegisterRequest

type RegisterRequest struct {
	Username string
	Email    string
	Name     string
	Password string
}

type Service

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

func NewService

func NewService(
	userRepo UserRepository,
	sessionRepo SessionRepository,
	tokenRepo TokenRepository,
	identityRepo IdentityRepository,
	options Options,
) (*Service, error)

func (*Service) Authenticate

func (svc *Service) Authenticate(ctx context.Context, login, password string) (*User, error)

Authenticate takes a username or an email address as login.

func (*Service) ChangePassword

func (svc *Service) ChangePassword(ctx context.Context, req ChangePasswordRequest) error

ChangePassword checks the current password only when the account has one. Setting a first password on an account without one relies on the caller having just re-authenticated the person.

func (*Service) CompleteSetup

func (svc *Service) CompleteSetup(ctx context.Context, req RegisterRequest) (*User, error)

CompleteSetup creates the first user and hands its reference to the first-user hook. A failing hook removes the user, so setup stays open rather than leaving an account nobody can use.

func (*Service) ConfirmEmailChange

func (svc *Service) ConfirmEmailChange(ctx context.Context, secret string) (*User, error)

func (*Service) ConsumeEmailLogin

func (svc *Service) ConsumeEmailLogin(
	ctx context.Context,
	secret string,
	allowSignUp bool,
) (*User, error)

ConsumeEmailLogin returns the account the link signs in to, creating it for a link that was sent to an address with no account.

func (*Service) CreateSession

func (svc *Service) CreateSession(
	ctx context.Context,
	userID string,
	meta SessionMeta,
) (string, *Session, error)

CreateSession issues a new token. It is called on every login, so there is no existing session identifier an attacker could have fixed beforehand.

func (*Service) DeleteExpiredSessions

func (svc *Service) DeleteExpiredSessions(ctx context.Context) (int64, error)

func (*Service) DeleteExpiredTokens

func (svc *Service) DeleteExpiredTokens(ctx context.Context) (int64, error)

func (*Service) DeleteUser

func (svc *Service) DeleteUser(ctx context.Context, id string) error

Sessions go with the account, so anyone signed in as it is signed out at once.

TODO: resources this user created in other services still carry their reference. References are opaque and never dereferenced, so nothing breaks, but an operator may want them reassigned.

func (*Service) GetUser

func (svc *Service) GetUser(ctx context.Context, id string) (*User, error)

func (*Service) GetUserByEmail

func (svc *Service) GetUserByEmail(ctx context.Context, email string) (*User, error)

GetUserByEmail takes the address in any case.

func (*Service) GetUserByRef

func (svc *Service) GetUserByRef(ctx context.Context, userRef string) (*User, error)

func (*Service) GetUserByUsername

func (svc *Service) GetUserByUsername(ctx context.Context, username string) (*User, error)

GetUserByUsername takes the username in any case.

func (*Service) LinkProvider

func (svc *Service) LinkProvider(
	ctx context.Context,
	userID, provider string,
	claims ProviderClaims,
) error

LinkProvider adds a provider identity to a signed-in account. An identity already on this account is no error; one on another account is.

func (*Service) ListIdentities

func (svc *Service) ListIdentities(ctx context.Context, userID string) ([]Identity, error)

func (*Service) ListUsers

func (svc *Service) ListUsers(ctx context.Context, limit int) ([]User, error)

func (*Service) Register

func (svc *Service) Register(ctx context.Context, req RegisterRequest) (*User, error)

FIXME: no rate limiting. Registration, login, and password change are all open to unlimited attempts (OWASP ASVS V2.2.1). A limiter keyed by username and client address is the next step.

A verification link goes to the new address. Failing to send it does not fail the registration: the person can ask for another from their settings.

func (*Service) RequestEmailChange

func (svc *Service) RequestEmailChange(ctx context.Context, userID, newEmail string) error

RequestEmailChange mails a confirmation link to the new address. The account keeps its current address until the link is followed, so a typo cannot lock anyone out.

func (*Service) RequestEmailLogin

func (svc *Service) RequestEmailLogin(
	ctx context.Context,
	email, next string,
	allowSignUp bool,
) error

RequestEmailLogin mails a sign-in link. An address with no account gets a link that creates one when allowSignUp is set, and nothing otherwise. The answer is the same either way.

FIXME: no rate limiting, as for RequestPasswordReset.

func (*Service) RequestPasswordReset

func (svc *Service) RequestPasswordReset(ctx context.Context, email string) error

RequestPasswordReset answers the same whether or not the address has an account, so the form cannot be used to find out which addresses are registered.

FIXME: no rate limiting, so anyone can make this send mail to any address repeatedly. It shares the limiter Register is waiting for.

func (*Service) ResetPassword

func (svc *Service) ResetPassword(ctx context.Context, secret, newPassword string) (*User, error)

ResetPassword also verifies the address, since following the link proved it, and signs out every session: whoever knew the old password is out.

func (*Service) ResolveSession

func (svc *Service) ResolveSession(ctx context.Context, token string) (*Session, *User, error)

ResolveSession turns a token into its session and user. An expired session is deleted and reported as absent so a stale cookie heals itself on the next request.

func (*Service) RevokeSession

func (svc *Service) RevokeSession(ctx context.Context, token string) error

func (*Service) SendVerification

func (svc *Service) SendVerification(ctx context.Context, userID string) error

SendVerification mails a link that proves the account's address. An address already verified is left alone.

func (*Service) SetupOpen

func (svc *Service) SetupOpen(ctx context.Context) (bool, error)

SetupOpen reports whether the instance still has no users.

func (*Service) SignInWithProvider

func (svc *Service) SignInWithProvider(
	ctx context.Context,
	provider string,
	claims ProviderClaims,
	allowSignUp bool,
) (*User, error)

SignInWithProvider returns the account the provider identity belongs to. An identity seen for the first time links to the account with the same address when both sides have proved that address, and otherwise creates an account when allowSignUp is set.

func (*Service) UnlinkProvider

func (svc *Service) UnlinkProvider(ctx context.Context, userID, provider, subject string) error

UnlinkProvider refuses to remove the account's last way in: no password, no address an email link can reach, and no other provider.

func (*Service) UpdateProfile

func (svc *Service) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*User, error)

func (*Service) VerifyEmail

func (svc *Service) VerifyEmail(ctx context.Context, secret string) (*User, error)

VerifyEmail fails if the account's address changed after the link was sent, since the link proves the old one.

type Session

type Session struct {
	ID     string
	UserID string
	// TokenHash is the SHA-256 of the token handed to the browser. The token is never stored, so a
	// copy of the database does not hand over live sessions.
	TokenHash string
	CreatedAt time.Time
	// ExpiresAt is absolute, not sliding. A stolen token that is in constant use still dies.
	ExpiresAt time.Time
	LastSeen  time.Time
	UserAgent string
	IP        string
}

func (*Session) Expired

func (s *Session) Expired(now time.Time) bool

type SessionMeta

type SessionMeta struct {
	UserAgent string
	IP        string
}

type SessionRepository

type SessionRepository interface {
	Insert(ctx context.Context, session *Session) error
	GetByTokenHash(ctx context.Context, tokenHash string) (*Session, error)
	Touch(ctx context.Context, id string, lastSeen time.Time) error
	DeleteByTokenHash(ctx context.Context, tokenHash string) error
	// DeleteByUser removes every session of a user except exceptSessionID, which may be
	// empty to remove all of them.
	DeleteByUser(ctx context.Context, userID, exceptSessionID string) error
	DeleteExpired(ctx context.Context, now time.Time) (int64, error)
}

SessionRepository returns ErrSessionNotFound for an absent session.

type Token

type Token struct {
	TokenHash string
	Purpose   TokenPurpose
	// UserID is empty for an email-login token addressed to someone with no account yet.
	UserID string
	// Email is where the link was sent. For a change-email token it is the new address.
	Email     string
	CreatedAt time.Time
	ExpiresAt time.Time
}

Token is a single-use secret mailed as a link. Like a session, only its hash is stored.

func (*Token) Expired

func (t *Token) Expired(now time.Time) bool

type TokenPurpose

type TokenPurpose string
const (
	TokenVerifyEmail   TokenPurpose = "verify-email"
	TokenResetPassword TokenPurpose = "reset-password"
	TokenEmailLogin    TokenPurpose = "email-login"
	TokenChangeEmail   TokenPurpose = "change-email"
)

type TokenRepository

type TokenRepository interface {
	Insert(ctx context.Context, token *Token) error
	// Consume deletes the token and returns it, so a link works once however many requests
	// race to use it. An expired token or one for another purpose is not found.
	Consume(
		ctx context.Context,
		tokenHash string,
		purpose TokenPurpose,
		now time.Time,
	) (*Token, error)
	DeleteByUser(ctx context.Context, userID string, purpose TokenPurpose) error
	DeleteExpired(ctx context.Context, now time.Time) (int64, error)
}

TokenRepository returns ErrTokenNotFound for an absent token.

type UpdateProfileRequest

type UpdateProfileRequest struct {
	UserID   string
	Username string
	Name     string
}

type User

type User struct {
	ID       string
	Username string
	// Email is empty only for accounts created before email addresses were collected.
	Email string
	// EmailVerifiedAt is set once the person follows a link sent to Email, or a provider vouches
	// for it.
	EmailVerifiedAt *time.Time
	Name            string
	// PasswordHash must never leave this service. Nothing outside auth has a reason to read it.
	// It is empty for an account that signs in by email link or a provider only.
	PasswordHash string
	CreatedAt    time.Time
	UpdatedAt    time.Time
}

func (*User) EmailVerified

func (u *User) EmailVerified() bool

func (*User) HasPassword

func (u *User) HasPassword() bool

func (*User) Ref

func (u *User) Ref() string

func (*User) SessionGroups

func (u *User) SessionGroups() []string

SessionGroups are the groups of a user who has signed in.

type UserRepository

type UserRepository interface {
	Insert(ctx context.Context, user *User) error
	Update(ctx context.Context, user *User) error
	Delete(ctx context.Context, id string) error
	Get(ctx context.Context, id string) (*User, error)
	GetByUsername(ctx context.Context, username string) (*User, error)
	GetByEmail(ctx context.Context, email string) (*User, error)
	List(ctx context.Context, limit int) ([]User, error)
	Count(ctx context.Context) (int, error)
}

UserRepository returns ErrUserNotFound for an absent user, and ErrUsernameTaken and ErrEmailTaken for collisions. Deleting a user removes its sessions, tokens and identities with it.

Directories

Path Synopsis
Package app is the composition root of the identity service running on its own.
Package app is the composition root of the identity service running on its own.
Package backend is the one place that maps a storage driver to auth's repositories.
Package backend is the one place that maps a storage driver to auth's repositories.
Package client calls the identity service over HTTP.
Package client calls the identity service over HTTP.
cmd
auth command
Package hash turns passwords into storable strings and checks them again.
Package hash turns passwords into storable strings and checks them again.
Package httpapi serves the part of the identity service that other services need.
Package httpapi serves the part of the identity service that other services need.
Package kit builds the identity service and its pages from configuration, so every binary that runs auth, on its own or inside an application, wires it the same way.
Package kit builds the identity service and its pages from configuration, so every binary that runs auth, on its own or inside an application, wires it the same way.
Package oidc signs people in with an OpenID Connect provider, such as Google or Apple, using the authorization code flow with PKCE.
Package oidc signs people in with an OpenID Connect provider, such as Google or Apple, using the authorization code flow with PKCE.
oidctest
Package oidctest runs an OpenID Connect provider in the test process, so sign-in with Google or Apple can be tested end to end without either.
Package oidctest runs an OpenID Connect provider in the test process, so sign-in with Google or Apple can be tested end to end without either.
Package postgres stores auth's users and sessions in Postgres.
Package postgres stores auth's users and sessions in Postgres.
Package repotest is the contract every auth repository must satisfy.
Package repotest is the contract every auth repository must satisfy.
Package sqlite stores auth's users and sessions in SQLite.
Package sqlite stores auth's users and sessions in SQLite.
Package ui serves the browser pages that must handle a password: sign in, register, first-run setup, and password change.
Package ui serves the browser pages that must handle a password: sign in, register, first-run setup, and password change.

Jump to

Keyboard shortcuts

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