auth

package
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jun 5, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package auth owns the application's authentication primitives: bcrypt password hashing, CSRF token generation, and the session lifecycle (create / lookup / touch / destroy). The Service type bundles a pgx pool with the sqlc-generated db.Queries and exposes Signup + Authenticate so callers don't reach into the DB directly.

Session expiry policy:

  • sliding window of 7 days bumped on every authenticated request via Touch
  • hard cap of 30 days from CreatedAt; Touch will never extend past it

Two configurable knobs (SlidingWindow / MaxLifetime) exist so tests can run against shrunk windows. Production callers leave them at the defaults.

Index

Constants

View Source
const BcryptCost = 10

BcryptCost is bcrypt's work factor. 10 is Go's package default; matches the `bf` cost used by pgcrypto's gen_salt('bf', 10) in scripts/operator/reset-password.sql, so app-hashed and operator-hashed passwords sit on the same cost curve.

View Source
const CSRFFormField = "csrf_token"

CSRFFormField is the hidden form input emitted by the @csrfField() templ helper for plain (non-htmx) form submissions.

View Source
const CSRFHeader = "X-CSRF-Token"

CSRFHeader is the HTTP header htmx forwards on every request via the hx-headers attribute set in views/layout.templ.

View Source
const DefaultMaxLifetime = 30 * 24 * time.Hour

DefaultMaxLifetime caps how long a session can live from CreatedAt no matter how often it's touched.

View Source
const DefaultSlidingWindow = 7 * 24 * time.Hour

DefaultSlidingWindow is how far into the future Touch pushes ExpiresAt on each authenticated request.

View Source
const InviteTTL = 7 * 24 * time.Hour

InviteTTL is how long a generated invite stays valid before its expires_at trips. Plan §"Decisions for this step" — 7-day TTL.

View Source
const InviteTokenPlaintextLength = 43

InviteTokenPlaintextLength is the length of an invite token (no prefix).

View Source
const MaxNameLength = 100

MaxNameLength caps user-supplied names (team / project / token). Long enough for readable descriptions, short enough to keep DB rows and UI rendering bounded. Plan Issue 11.

View Source
const MaxPasswordLength = 72

MaxPasswordLength is bcrypt's hard ceiling: bytes 73+ are silently truncated, so a 100-char password and the same prefix at 72 chars produce identical hashes. Reject longer inputs at the boundary instead.

View Source
const MinPasswordLength = 12

MinPasswordLength is the minimum length we accept at signup / password change. Short enough to not annoy users, long enough that an offline brute force on a leaked bcrypt hash is uneconomical.

View Source
const PublicTokenPlaintextLength = len(PublicTokenPrefix) + 64

PublicTokenPlaintextLength is the exact length of a valid plaintext public ingest token: 9 (prefix) + 64 (hex of 32 random bytes) = 73. Hex keeps the random part strictly alphanumeric ([0-9a-f]) — no '-' or '_'.

View Source
const PublicTokenPrefix = "mere_pub_"

PublicTokenPrefix tags the snippet/ingest token (mere_pub_…). Public by design — its presence in client HTML is the entire point — so the prefix being scanner-visible is fine.

View Source
const SessionCookieName = "mere_session"

SessionCookieName is the cookie name used by web.SessionMiddleware.

Variables

View Source
var ErrCurrentPasswordWrong = errors.New("current password is incorrect")

ErrCurrentPasswordWrong is returned by ChangePassword when the caller supplied the wrong current password. Distinguished from ErrInvalidCredentials because the user is already authenticated; the form renders a specific "current password is incorrect" message.

View Source
var ErrEmailTaken = errors.New("email already registered")

ErrEmailTaken is returned by Signup when a user with the same email (case-insensitive) already exists.

View Source
var ErrInvalidCredentials = errors.New("invalid credentials")

ErrInvalidCredentials is returned by Authenticate when either the email is unknown or the password is wrong. The two cases are deliberately conflated to avoid leaking which accounts exist.

View Source
var ErrInviteInvalid = errors.New("invite is no longer valid")

ErrInviteInvalid is returned by ConsumeInvite / SignupWithInvite when the invite token doesn't resolve to an active, unexpired row. The four miss cases — unknown, consumed, expired, malformed — are collapsed for the same enumeration defense as ErrNotVisible.

View Source
var ErrNotVisible = errors.New("not visible to viewer")

ErrNotVisible is returned by every Viewer read/write when the row either doesn't exist or exists but the viewer has no team membership granting access. Handlers map it to 404. Plan Issue 6.

View Source
var ErrSessionExpired = errors.New("session expired")

ErrSessionExpired is returned by LookupSession when the row exists but expires_at is in the past.

View Source
var ErrSessionNotFound = errors.New("session not found")

ErrSessionNotFound is returned by LookupSession when the cookie's session id doesn't exist in the sessions table.

Functions

func CSRFTokenEqual

func CSRFTokenEqual(a, b string) bool

CSRFTokenEqual constant-time compares two CSRF tokens. Empty tokens never compare equal even to other empty tokens — a missing token in either side is a programmer error, never a match.

func CSRFTokenFrom

func CSRFTokenFrom(ctx context.Context) string

CSRFTokenFrom returns the request's contextual CSRF token. Returns the empty string only if the middleware chain that sets it didn't run — a programmer error, not a user-facing state.

func GenerateCSRFToken

func GenerateCSRFToken() (string, error)

GenerateCSRFToken returns a URL-safe random token sized for inclusion in HTTP headers and form fields.

func GenerateInviteToken

func GenerateInviteToken() (plaintext, hashHex string, err error)

GenerateInviteToken returns a 43-character base64url plaintext invite token (no prefix) and its sha256 hex hash. Invite tokens flow through /invites/{token}, not the bearer middleware, so they don't need a prefix.

func GeneratePublicToken

func GeneratePublicToken() (plaintext, hashHex string, err error)

GeneratePublicToken returns the plaintext snippet ingest token and its sha256 hex hash. The caller writes hash to api_tokens.token_hash and persists plaintext alongside so the project page can re-display it.

func HashPassword

func HashPassword(plaintext string) (string, error)

HashPassword returns the bcrypt hash of plaintext at the package's configured cost.

func HashToken

func HashToken(plaintext string) string

HashToken returns the sha256 hex hash of plaintext. Deterministic; same input → same output. Used at issuance and at every lookup (api tokens, invites, oauth access tokens, oauth authorization codes).

func NormalizeEmail

func NormalizeEmail(s string) string

NormalizeEmail trims whitespace and lowercases the local + domain parts. The users_email_lower_idx in 0001_init.up.sql is on lower(email), so all lookups go through this helper to keep app and index aligned.

func ValidateEmail

func ValidateEmail(s string) error

ValidateEmail does the bare-minimum syntactic check: contains exactly one '@' with non-empty local and domain parts. We deliberately don't validate against RFC 5322 — that's a swamp, and bounce-on-send is the source of truth.

func ValidateName

func ValidateName(fieldLabel, raw string) (string, error)

ValidateName applies the shared name policy: trim whitespace, non-empty, length cap. Returns the trimmed value on success so callers don't need to re-trim before persisting.

fieldLabel is the user-facing field name ("Team", "Project", "Token"); it gets embedded in the validation error message rendered back to the form.

func ValidatePassword

func ValidatePassword(plaintext string) error

ValidatePassword applies the length policy without hashing. Returns a *ValidationError suitable for surfacing in the signup form; callers use errors.As to recover the user-safe message.

func VerifyPassword

func VerifyPassword(hash, plaintext string) bool

VerifyPassword reports whether plaintext matches hash. Any error from bcrypt (mismatch, malformed hash) returns false.

func WithCSRFToken

func WithCSRFToken(ctx context.Context, tok string) context.Context

WithCSRFToken stashes the current request's CSRF token in ctx so templ helpers can recover it without re-reading cookies. Authenticated requests get session.CSRFToken; anonymous requests get the mere_csrf cookie value.

func WithSession

func WithSession(ctx context.Context, s *Session) context.Context

WithSession returns a child context carrying s. A nil s is allowed and means "unauthenticated"; downstream code calls SessionFrom and gets nil back.

func WithViewer

func WithViewer(ctx context.Context, v *Viewer) context.Context

WithViewer attaches v to ctx so downstream handlers can recover it without re-threading svc.Queries + userID.

Types

type InviteResult

type InviteResult struct {
	Plaintext string
	Invite    db.TeamInvite
}

InviteResult is what CreateInvite returns: the plaintext token (embed in the URL the inviter shares) and the persisted row.

type ProjectsChain

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

func (*ProjectsChain) ByID

func (c *ProjectsChain) ByID(projectID string) (db.Project, error)

ByID returns the project if the viewer's team owns it and it's not soft-deleted; ErrNotVisible otherwise.

func (*ProjectsChain) Create

func (c *ProjectsChain) Create(teamID, name string) (db.Project, error)

Create issues a project under teamID and bootstraps its public token in the same transaction. Caller must be a team member; otherwise the project INSERT's WHERE EXISTS guard yields no row → ErrNotVisible (with the public token insert never running, since the tx aborts at that point).

Returns only the project row; the public token is fetched separately via the project page using GetPublicTokenForProjectForUser.

func (*ProjectsChain) ListForTeam

func (c *ProjectsChain) ListForTeam(teamID string) ([]db.Project, error)

ListForTeam returns active projects under the team. Empty slice for a team with no projects; ErrNotVisible only if the viewer can't see the team itself (we pre-check via the JOIN by returning zero rows on no membership — distinguished from the empty-team case via the team-existence sanity caller-side).

func (*ProjectsChain) ListForTeams

func (c *ProjectsChain) ListForTeams(teamIDs []string) ([]db.Project, error)

ListForTeams powers the rebuilt home page. Bounded 2-query pattern (Issue 15): call Teams.List then this with the resulting ids. Returns a flat slice grouped by team_id in iteration order.

func (*ProjectsChain) SoftDelete

func (c *ProjectsChain) SoftDelete(projectID string) error

SoftDelete sets deleted_at on a viewer-owned project. Returns ErrNotVisible if the project is not in any team the viewer belongs to OR is already soft-deleted (collapsed for the same UUID-enumeration defense as ByID).

type Service

type Service struct {
	SlidingWindow time.Duration
	MaxLifetime   time.Duration
	// contains filtered or unexported fields
}

Service bundles the dependencies needed for password / session operations.

func NewService

func NewService(pool *pgxpool.Pool) *Service

NewService returns a Service backed by pool. The defaults can be overridden after construction (tests do this; production code leaves them alone).

func (*Service) Authenticate

func (s *Service) Authenticate(ctx context.Context, rawEmail, password string) (db.User, error)

Authenticate verifies an email + password pair and returns the corresponding user row on success. The error path collapses unknown-email and wrong-password into ErrInvalidCredentials.

func (*Service) ChangePassword

func (s *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error

ChangePassword swaps a user's password after verifying the current one, and clears must_change_password on success. Length policy is enforced via ValidatePassword (returns *ValidationError so the form can surface it).

The current-password check is the same as Authenticate without the email-collapse — we already know the userID from the session.

func (*Service) ConsumeInvite

func (s *Service) ConsumeInvite(ctx context.Context, userID, plaintextToken string) (db.Team, error)

ConsumeInvite atomically burns the invite token and adds userID to the team, in a single transaction:

BEGIN
  UPDATE team_invites SET consumed_at=NOW(), consumed_by=$user
    WHERE token_hash=$h AND consumed_at IS NULL AND expires_at > NOW()
    RETURNING team_id                       ──┐
  INSERT INTO team_memberships ...           │  same tx
COMMIT                                       └──┘

Outcomes (Issues 7, 10):

  • invite valid + user not yet a member → membership created, returns team
  • invite valid + user already a member → silent success, invite burned
  • invite consumed/expired/unknown → ErrInviteInvalid

func (*Service) CreateSession

func (s *Service) CreateSession(ctx context.Context, userID string) (*Session, error)

CreateSession issues a new session row for userID with a fresh CSRF token, returning the populated session ready to be set as a cookie.

func (*Service) DestroySession

func (s *Service) DestroySession(ctx context.Context, id string) error

DestroySession deletes the row identified by id. Missing rows are not an error — logout against an already-expired session should still succeed.

func (*Service) LookupSession

func (s *Service) LookupSession(ctx context.Context, id string) (*Session, error)

LookupSession resolves a session id to its joined session+user row. Returns ErrSessionNotFound for an unknown id and ErrSessionExpired when the row's ExpiresAt is in the past; the latter is also opportunistically deleted (best-effort; ignored on failure).

func (*Service) Queries

func (s *Service) Queries() *db.Queries

Queries exposes the service's sqlc handle. The web middleware uses this to build per-request viewers (auth.Viewer) without re-wiring the pool.

func (*Service) SetNow

func (s *Service) SetNow(fn func() time.Time)

SetNow swaps the Service's clock. Test-only.

func (*Service) Signup

func (s *Service) Signup(ctx context.Context, req SignupRequest) (*SignupResult, error)

Signup creates a user, an auto-named "personal" team, and the membership linking the two in a single transaction. Either everything lands or the row state is unchanged.

NOT wired to any HTTP route. The public /signup endpoint was removed — production user creation goes through SignupWithInvite (invite-based web flow) or scripts/operator/create-user.sql (operator bootstrap). This function is retained as a seed primitive for tests; do not reintroduce an HTTP handler that calls it.

func (*Service) SignupWithInvite

func (s *Service) SignupWithInvite(ctx context.Context, req SignupRequest, invitePlaintext string) (*SignupResult, error)

SignupWithInvite runs the standard signup tx and consumes the invite token in the same tx. If the invite is invalid at POST time, the entire signup is aborted with ErrInviteInvalid — Issue 12's strict path; the caller (the anon branch of /invites/{token} POST) re-renders the page as InviteInvalid.

invitePlaintext is required — public open-signup is no longer supported. Operator-bootstrapped users go through scripts/operator/create-user.sql, which bypasses this path entirely.

On success, SignupResult.Team is the user's personal team and SignupResult.InvitedTeam is the team the invite belongs to.

func (*Service) TouchSession

func (s *Service) TouchSession(ctx context.Context, sess *Session) (time.Time, error)

TouchSession pushes ExpiresAt forward by SlidingWindow, capped at CreatedAt + MaxLifetime. Returns the new ExpiresAt the caller should use for the cookie's Max-Age.

type Session

type Session struct {
	ID                 string
	UserID             string
	UserEmail          string
	CSRFToken          string
	CreatedAt          time.Time
	ExpiresAt          time.Time
	MustChangePassword bool
}

Session is the per-request view of an authenticated user. The HTTP middleware loads it once per request and attaches it via WithSession.

func SessionFrom

func SessionFrom(ctx context.Context) *Session

SessionFrom returns the session attached to ctx, or nil if no session was attached (anonymous request).

type SignupRequest

type SignupRequest struct {
	Email    string
	Password string
}

SignupRequest carries the validated form fields from the signup handler.

type SignupResult

type SignupResult struct {
	User        db.User
	Team        db.Team
	InvitedTeam db.Team
}

SignupResult is what Signup / SignupWithInvite return on success.

Team is always the user's auto-created personal team. InvitedTeam is the team named by the invite token and is only populated when the call went through SignupWithInvite — Signup leaves it as the zero db.Team. Web callers redirect to InvitedTeam after invite-based signup so the user lands where they expected.

type TeamsChain

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

func (*TeamsChain) ByID

func (c *TeamsChain) ByID(teamID string) (db.Team, error)

ByID returns the team if the viewer is a member; ErrNotVisible otherwise.

func (*TeamsChain) List

func (c *TeamsChain) List() ([]db.Team, error)

List returns every team the viewer belongs to, oldest first (signup auto-creates the personal team, so it's always index 0).

func (*TeamsChain) MembersOf

func (c *TeamsChain) MembersOf(teamID string) ([]db.ListMembersForTeamForUserRow, error)

MembersOf returns the team's members if the viewer is themselves a member; ErrNotVisible otherwise. Used by the team-settings page.

type TokensChain

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

func (*TokensChain) PublicForProject

func (c *TokensChain) PublicForProject(projectID string) (string, error)

PublicForProject returns the plaintext public_ingest token for the project. Every project is bootstrapped with one at create time, so a missing row is a hard bug rather than a user-facing 404. Callers always pre-check project visibility via Projects.ByID, so the "viewer not in team" path can't reach here.

type ValidationError

type ValidationError struct {
	Field string
	Msg   string
}

ValidationError carries a field name and user-safe message returned from the Validate* helpers. Handlers use errors.As(err, &*ValidationError) to surface Msg directly to the form; everything else logs and returns 500.

func (*ValidationError) Error

func (e *ValidationError) Error() string

type Viewer

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

Viewer is the per-request capability bag used by handlers to read and mutate the resources the current user is allowed to touch. Every query goes through a membership-gated SQL statement (JOIN team_memberships) so a caller can never reach data outside the teams they belong to.

Construction:

request →  authMiddleware loads session  →  WithViewer(ctx, NewViewer(...))
                                           │
                                           ▼
                    handler:  v := auth.ViewerFrom(r.Context())
                              p, err := v.Projects(ctx).ByID(projectID)
                              if errors.Is(err, auth.ErrNotVisible) { 404 }

On membership miss every query returns ErrNotVisible — a single sentinel that handlers translate to 404 without distinguishing "doesn't exist" from "exists but not yours" (Issue 6; defends against UUID enumeration).

Viewer holds the full *Service so chain methods that need a transaction (e.g. Projects.Create, which also bootstraps the project's public token) can borrow the pool without re-wiring. Reads continue to go through svc.queries directly.

func NewViewer

func NewViewer(svc *Service, userID string) *Viewer

NewViewer builds a viewer for a specific user against a Service. The middleware in package web constructs one per request; tests construct directly against the test Service.

func ViewerFrom

func ViewerFrom(ctx context.Context) *Viewer

ViewerFrom returns the viewer attached to ctx, or nil for anonymous requests. Handlers behind requireSession can rely on a non-nil viewer.

func (*Viewer) CreateInvite

func (v *Viewer) CreateInvite(ctx context.Context, teamID string, now time.Time) (*InviteResult, error)

CreateInvite issues a one-shot invite for teamID; caller must be a team member. Returns ErrNotVisible on missing membership.

Invite tokens have no prefix — they flow through /invites/{token}, not the bearer middleware, so the leak-scanner prefix gains nothing here.

func (*Viewer) Projects

func (v *Viewer) Projects(ctx context.Context) *ProjectsChain

func (*Viewer) Teams

func (v *Viewer) Teams(ctx context.Context) *TeamsChain

func (*Viewer) Tokens

func (v *Viewer) Tokens(ctx context.Context) *TokensChain

func (*Viewer) UserID

func (v *Viewer) UserID() string

UserID returns the viewer's user id. Tests and handlers occasionally need the raw id (e.g. for self-membership banners) without going through a chain.

Jump to

Keyboard shortcuts

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