authkit

package module
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Apr 26, 2026 License: MIT Imports: 24 Imported by: 0

README

authkit

A pragmatic authentication and authorization toolkit for Go web services on PostgreSQL 16+.

authkit is a library, not a service. Drop it into a net/http stack and get registration, password login, opaque server-side sessions, JWT access tokens with rotating refresh, email verification, password reset, magic-link login, email OTP, and machine-targeted service tokens with consumer-defined abilities. Authorization is flat RBAC with both role-derived and direct user permissions.

Status: v1.0.0 development. The API is being stabilised; expect breaking changes until the v1.0.0 tag.

Install

go get git.juancwu.dev/juancwu/authkit

authkit depends only on the Go standard library, golang-jwt, google/uuid, golang.org/x/crypto, and errx. Bring your own driver: pgx, lib/pq, or anything else that registers a database/sql driver.

import _ "github.com/jackc/pgx/v5/stdlib" // or _ "github.com/lib/pq"

PostgreSQL 16 or newer is required.

What's included

Authentication

  • Email-only registration (CreateUser); password is optional and can be set later via SetPassword
  • Password login with Argon2id PHC-encoded hashes
  • Opaque server-side sessions with sliding TTL bounded by an absolute cap
  • HS256 JWT access tokens with rotating refresh tokens and reuse detection
  • Email verification, password reset, magic-link login, email OTP

Authorization

  • Roles and permissions (flat RBAC)
  • Direct user-permission grants in addition to role-derived ones — UserPermissions returns the UNION
  • Service tokens with consumer-defined abilities (machine credentials, no user owner)

Predicate API for middleware authz

  • Leaves: HasRole(slug), HasPermission(slug), HasAbility(slug)
  • Combinators: AnyLogin, AllLogin, AnyServiceKey, AllServiceKey
  • Compose freely: AnyLogin(HasRole("admin"), AllLogin(HasRole("manager"), HasRole("ads_manager")))

HTTP middleware

  • RequireLogin — accept session cookie OR JWT, optionally constrain by LoginAuthz
  • RequireGuest — block authenticated requests (with a configurable OnAuthenticated callback for redirects)
  • RequireServiceKey — accept a service token, optionally constrain by ServiceKeyAuthz

Storage

  • PostgreSQL 16+ only
  • Migrations and schema verification run on startup (opt-out via Config.SkipAutoMigrate / Config.SkipSchemaVerify)
  • Override individual table names via Schema.Tables
  • Schema verifier tolerates extra columns; flags missing tables, missing columns, type drift, and nullability drift

Errors

  • Sentinel errors compatible with errors.Is
  • All internal errors wrap with errx

Quick start

1. Open a database
import (
    "database/sql"
    _ "github.com/jackc/pgx/v5/stdlib"
)

db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil { /* ... */ }
defer db.Close()
2. Construct Auth
import (
    "context"

    "git.juancwu.dev/juancwu/authkit"
    "git.juancwu.dev/juancwu/authkit/hasher"
)

auth, err := authkit.New(ctx, authkit.Deps{
    DB:     db,
    Hasher: hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
}, authkit.Config{
    JWTSecret: []byte(os.Getenv("JWT_SECRET")),
    JWTIssuer: "myapp",
})
if err != nil { log.Fatal(err) }

New runs migrations and verifies the schema. Config zero values fall back to sane defaults: 24h idle / 30d absolute session TTL, 15m access / 30d refresh, 48h email-verify, 1h password-reset, 15m magic-link, 10m email OTP with 5 attempts. Cookie defaults: Secure=true, HttpOnly=true, SameSite=Lax. Pass authkit.BoolPtr(false) to opt out for local dev.

3. Seed roles, permissions, and abilities

authkit does not seed any rows. Use the bundled CLIs:

go install git.juancwu.dev/juancwu/authkit/cmd/perms@latest
go install git.juancwu.dev/juancwu/authkit/cmd/roles@latest
go install git.juancwu.dev/juancwu/authkit/cmd/abilities@latest

export AUTHKIT_DATABASE_URL=postgres://...

perms create posts:write --label "Write posts"
perms create posts:read  --label "Read posts"
roles create editor      --label "Editor"
roles grant editor posts:write
roles grant editor posts:read

abilities create events:write --label "Events ingest"

Or call the equivalent methods on *authkit.Auth from your own seed script. Slugs match ^[a-z][a-z0-9_:-]*$ (max 64 bytes); invalid slugs return ErrSlugInvalid.

4. User flows
// Email-only account, password set later.
u, _ := auth.CreateUser(ctx, "alice@example.com")
_  = auth.SetPassword(ctx, u.ID, "hunter2hunter2")
u, _ = auth.LoginPassword(ctx, "Alice@Example.com", "hunter2hunter2") // case-insensitive

// Opaque session.
plaintext, sess, _ := auth.IssueSession(ctx, u.ID, r.UserAgent(), clientIP)
http.SetCookie(w, auth.SessionCookie(plaintext, sess.ExpiresAt))

// JWT + rotating refresh.
access, refresh, _ := auth.IssueJWT(ctx, u.ID)
access, refresh, _ = auth.RefreshJWT(ctx, refresh) // old refresh is consumed

// Magic link / OTP / password reset (anti-enumeration: silent on unknown email).
linkToken, _ := auth.RequestMagicLink(ctx, "alice@example.com")
otpCode, _   := auth.RequestEmailOTP(ctx, "alice@example.com")
resetToken, _ := auth.RequestPasswordReset(ctx, "alice@example.com")

// Service token with abilities.
plaintext, sk, _ := auth.IssueServiceKey(ctx, authkit.IssueServiceKeyParams{
    Name:      "events-ingest",
    Abilities: []string{"events:write"},
})
got, _ := auth.AuthenticateServiceKey(ctx, plaintext)

The plaintext returned by every issue/mint flow is show-once — only its SHA-256 hash is stored. Show it to the user immediately; you cannot recover it later.

5. Wire middleware
import (
    "git.juancwu.dev/juancwu/authkit"
    "git.juancwu.dev/juancwu/authkit/middleware"
)

// Default RequireLogin reads the session cookie and falls through to a
// Bearer JWT.
loginMW := middleware.RequireLogin(middleware.LoginOptions{Auth: auth})

// Constrain on roles/permissions:
adminMW := middleware.RequireLogin(middleware.LoginOptions{
    Auth: auth,
    Authz: authkit.AnyLogin(
        authkit.HasRole("admin"),
        authkit.AllLogin(authkit.HasRole("manager"), authkit.HasRole("ads_manager")),
    ),
})

// Login/register pages: block if already authenticated.
guestMW := middleware.RequireGuest(middleware.GuestOptions{
    Auth: auth,
    OnAuthenticated: func(w http.ResponseWriter, r *http.Request) {
        http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
    },
})

// Service tokens with an ability gate.
apiMW := middleware.RequireServiceKey(middleware.ServiceKeyOptions{
    Auth:  auth,
    Authz: authkit.AllServiceKey(authkit.HasAbility("events:write")),
})

RequireLogin and RequireServiceKey panic at construction if any slug referenced by the predicate isn't registered in the database — typos fail at boot, not at request time.

6. Read the user in handlers

Middleware attaches the user_id to the request context. Handlers fetch the full user lazily:

func handle(w http.ResponseWriter, r *http.Request) {
    id, _ := authkit.UserIDFromCtx(r.Context())   // never queries the DB
    u, err := authkit.UserFromCtx(r.Context())     // lazy-load + per-request cache
    if err != nil { /* handle */ }

    // After an admin-side update that should be visible:
    u, err = authkit.RefreshUserInCtx(r.Context())
    _ = u; _ = id
}

The cache lives only for the request lifetime — nothing persists across requests. For service-token routes, use authkit.ServiceKeyFromCtx.

Schema verification and drift

On New, authkit introspects information_schema.columns and verifies the live database matches the expected layout (table presence, column names, data_type, is_nullable). Extra columns are tolerated; missing tables/columns and type drift fail with ErrSchemaDrift.

When a table cannot be found under the configured name, the verifier falls back to the default authkit_* name. This handles migrations from custom names back to defaults without manual intervention.

Configuration reference

Field Default Notes
Schema DefaultSchema() Override individual Tables fields; missing fields fall back to defaults
SkipAutoMigrate false Disables migration run inside New
SkipSchemaVerify false Disables schema check inside New
SessionIdleTTL 24h Sliding window applied on each authenticated request
SessionAbsoluteTTL 30d Cap from created_at; sliding never exceeds this
SessionCookieName authkit_session
SessionCookieSecure *true Pass BoolPtr(false) for local HTTP dev
SessionCookieHTTPOnly *true Pass BoolPtr(false) if JS must read it (rarely correct)
SessionCookieSameSite Lax
JWTSecret — (required) HS256 key
AccessTokenTTL / RefreshTokenTTL 15m / 30d
RefreshChainAbsoluteTTL 30d Hard cap from chain start. Refresh fails past this even if the per-token TTL hasn't elapsed; user must re-authenticate. Mirrors SessionAbsoluteTTL.
EmailVerifyTTL / PasswordResetTTL / MagicLinkTTL 48h / 1h / 15m
EmailOTPTTL / EmailOTPDigits / EmailOTPMaxAttempts 10m / 6 / 5
RevealUnknownEmail false Default anti-enumeration: silent success on unknown email
Clock time.Now().UTC Override for deterministic tests
Random crypto/rand.Reader Override for deterministic tests
LoginHook nil func(ctx, email, success) error; integration point for rate limiting / audit. Panics in the hook are recovered.

Testing

go test ./...                                                 # unit tests, no DB required
AUTHKIT_TEST_DATABASE_URL=postgres://... go test ./... -run Integration

The unit suite covers slug validation (incl. fuzz), opaque-secret roundtrip, email normalization, HTTP extractors, predicate combinators, and OTP code generation. Integration tests cover every database-bound flow: registration, login, sessions, JWT refresh + reuse, magic link, email OTP (incl. attempt cap), password reset, service tokens, RBAC, direct user permissions, schema verification (drift cases + fallback), migration idempotency, lazy user-context cache, and middleware behavior.

License

MIT. See LICENSE.

Documentation

Overview

Package authkit is a pragmatic authentication and authorization toolkit for Go web services on PostgreSQL 16+.

Drop authkit into a net/http stack and get registration, password login, opaque server-side sessions, JWT access tokens with rotating refresh, email verification, password reset, magic-link login, email OTP, and owner-agnostic service tokens with consumer-defined abilities. Authorization is flat RBAC with both role-derived and direct user permissions.

Roles, permissions, and abilities are seeded by the consumer (typically via the cmd/perms, cmd/roles, and cmd/abilities CLIs that ship with this repo). The library does not seed any rows automatically — applications own their authorization vocabulary.

Migrations and schema verification run at startup. Set Config.SkipAutoMigrate to disable.

The library does not send email or otherwise reach out to users. Token-minting flows (RequestEmailVerification, RequestPasswordReset, RequestMagicLink, RequestEmailOTP, IssueServiceKey, IssueSession, IssueJWT) return the plaintext to the caller exactly once — show it to the user immediately; only its SHA-256 hash is persisted.

Index

Constants

View Source
const MaxSlugLength = 64

MaxSlugLength is the upper bound on slug length, in bytes. Slugs are ASCII so this also bounds character count.

Variables

View Source
var (
	ErrEmailTaken         = errors.New("authkit: email already registered")
	ErrUserNotFound       = errors.New("authkit: user not found")
	ErrInvalidCredentials = errors.New("authkit: invalid credentials")
	ErrTokenInvalid       = errors.New("authkit: invalid or expired token")
	ErrTokenReused        = errors.New("authkit: token reuse detected")
	ErrSessionInvalid     = errors.New("authkit: invalid or expired session")
	ErrServiceKeyInvalid  = errors.New("authkit: invalid or expired service key")
	ErrPermissionDenied   = errors.New("authkit: permission denied")
	ErrRoleNotFound       = errors.New("authkit: role not found")
	ErrPermissionNotFound = errors.New("authkit: permission not found")
	ErrAbilityNotFound    = errors.New("authkit: ability not found")
	ErrSlugInvalid        = errors.New("authkit: invalid slug")
	ErrSlugTaken          = errors.New("authkit: slug already in use")
	ErrOTPInvalid         = errors.New("authkit: invalid or expired OTP")
	ErrNoUserContext      = errors.New("authkit: no user on request context")
	ErrSchemaDrift        = errors.New("authkit: database schema does not match expected layout")
)

Sentinel errors. Internal call sites wrap these via errx so callers using errors.Is(err, authkit.ErrFoo) get reliable matching across wrap chains.

Functions

func BoolPtr added in v0.3.0

func BoolPtr(b bool) *bool

BoolPtr is a one-line helper for Config fields that take *bool. Use it to opt out of the secure cookie defaults: cfg.SessionCookieSecure = BoolPtr(false).

func HashOpaqueSecret added in v0.2.0

func HashOpaqueSecret(plaintext string) []byte

HashOpaqueSecret returns sha256(plaintext) — the lookup key for opaque secrets. Plaintexts have full entropy from a CSPRNG so a plain hash is sufficient (no per-record salt needed; the random body is the salt).

func Migrate added in v0.3.0

func Migrate(ctx context.Context, db *sql.DB, schema Schema) error

Migrate applies every embedded migration not yet recorded in the schema-migrations table. Safe to call repeatedly and concurrently across processes; the advisory lock serialises rollouts. Each migration owns its own BEGIN/COMMIT.

Embedded migrations hard-code the default authkit_* names. If the consumer has overridden any table name, Migrate is a no-op and the consumer is responsible for managing DDL out-of-band.

func MintOpaqueSecret added in v0.2.0

func MintOpaqueSecret(rng io.Reader, prefix string) (plaintext string, hash []byte, err error)

MintOpaqueSecret generates a fresh opaque secret with the given prefix. Returns the plaintext (show once, never persist) and the SHA-256 lookup hash. A nil rng falls back to crypto/rand.Reader.

func ParseOpaqueSecret added in v0.2.0

func ParseOpaqueSecret(prefix, plaintext string) (hash []byte, ok bool)

ParseOpaqueSecret validates that a plaintext begins with the expected prefix and returns the lookup hash.

func UserIDFromCtx added in v0.3.0

func UserIDFromCtx(ctx context.Context) (uuid.UUID, bool)

UserIDFromCtx returns the authenticated user_id placed by middleware via WithUserContext. The boolean is false when no user-bound auth ran for this request (e.g. a service-key request).

func VerifySchema added in v0.3.0

func VerifySchema(ctx context.Context, db *sql.DB, schema Schema) error

VerifySchema introspects the live database against the expected layout for the given schema. Returns a wrapped ErrSchemaDrift describing every missing/mismatched table or column. Extra columns on a table are allowed.

For tables with non-default names, VerifySchema looks up the configured name first; if no rows are found, it falls back to the default name. This handles the case where a consumer migrated under custom names but later removed the overrides — drift is detected against whichever set of names actually exists.

func WithServiceKey added in v0.3.0

func WithServiceKey(ctx context.Context, k *ServiceKey) context.Context

WithServiceKey attaches a *ServiceKey to ctx. Used by service-key middleware.

func WithUserContext added in v0.3.0

func WithUserContext(ctx context.Context, a *Auth, userID uuid.UUID) context.Context

WithUserContext attaches a lazy user-context to ctx. Middleware uses this to record an authenticated user_id without paying for a DB read until a handler actually calls UserFromCtx. Custom middleware authors can use this directly to integrate hand-rolled auth flows.

Types

type Ability added in v0.3.0

type Ability struct {
	ID        uuid.UUID
	Slug      string
	Label     string
	CreatedAt time.Time
}

Ability is a unit of authorization for service tokens. Abilities are a separate vocabulary from Permissions because they target machines, not users — keep them distinct so middleware predicates remain clear about which subject they're authorizing.

type Auth

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

Auth is the high-level service. Safe for concurrent use; method receivers never mutate Auth state after construction.

func New

func New(ctx context.Context, deps Deps, cfg Config) (*Auth, error)

New validates Deps and Config, fills in defaults, runs migrations (unless SkipAutoMigrate), verifies the schema (unless SkipSchemaVerify), and returns a ready service.

Panics on missing deps, missing JWT secret, invalid schema, or schema drift — these are programmer/operator errors, not runtime failures.

func (*Auth) AssignRole

func (a *Auth) AssignRole(ctx context.Context, userID uuid.UUID, roleSlug string) error

AssignRole assigns roleSlug to userID. Idempotent — a duplicate insert is a no-op via ON CONFLICT.

func (*Auth) AuthenticateJWT

func (a *Auth) AuthenticateJWT(ctx context.Context, access string) (*Principal, error)

AuthenticateJWT validates the access JWT, cross-checks the user's session_version (instant revocation), and resolves a Principal.

func (*Auth) AuthenticateServiceKey added in v0.2.0

func (a *Auth) AuthenticateServiceKey(ctx context.Context, plaintext string) (*ServiceKey, error)

AuthenticateServiceKey validates a service token and returns the stored *ServiceKey with its abilities resolved.

func (*Auth) AuthenticateSession

func (a *Auth) AuthenticateSession(ctx context.Context, plaintext string) (*Principal, error)

AuthenticateSession validates an opaque session string, slides the TTL, resolves the user's roles+permissions, and returns a Principal.

func (*Auth) ChangePassword

func (a *Auth) ChangePassword(ctx context.Context, userID uuid.UUID, oldPassword, newPassword string) error

ChangePassword verifies the current password, sets the new one, and bumps the user's session_version (invalidating outstanding JWTs). Outstanding opaque sessions are also revoked.

func (*Auth) ClearSessionCookie added in v0.3.0

func (a *Auth) ClearSessionCookie() *http.Cookie

ClearSessionCookie returns a cookie that, when set on the response, tells the browser to delete the session cookie. Use on logout.

func (*Auth) ConfirmEmail

func (a *Auth) ConfirmEmail(ctx context.Context, plaintextToken string) (*User, error)

ConfirmEmail consumes a verification token and marks the user's email verified. Returns ErrTokenInvalid for missing/expired/already-used tokens.

func (*Auth) ConfirmPasswordReset

func (a *Auth) ConfirmPasswordReset(ctx context.Context, plaintextToken, newPassword string) error

ConfirmPasswordReset consumes the reset token, sets the new password, bumps session_version, and revokes outstanding sessions so the reset is a global logout.

func (*Auth) ConsumeEmailOTP added in v0.3.0

func (a *Auth) ConsumeEmailOTP(ctx context.Context, email, code string) (*User, error)

ConsumeEmailOTP verifies a code against the most recent active OTP for the user behind email. Successful match consumes the row. A wrong code decrements attempts_remaining and returns ErrOTPInvalid; reaching zero attempts invalidates the OTP. A successful consume implicitly verifies the email.

func (a *Auth) ConsumeMagicLink(ctx context.Context, plaintextToken string) (*User, error)

ConsumeMagicLink consumes the magic-link token and returns the authenticated user. Callers typically follow with IssueSession or IssueJWT to actually log the user in. A successful consume implicitly verifies the email (the user demonstrably controls the inbox).

func (*Auth) CreateAbility added in v0.3.0

func (a *Auth) CreateAbility(ctx context.Context, slug, label string) (*Ability, error)

CreateAbility inserts a new ability for service tokens.

func (*Auth) CreatePermission added in v0.3.0

func (a *Auth) CreatePermission(ctx context.Context, slug, label string) (*Permission, error)

CreatePermission inserts a new permission.

func (*Auth) CreateRole added in v0.3.0

func (a *Auth) CreateRole(ctx context.Context, slug, label string) (*Role, error)

CreateRole inserts a new role. Slug must be a valid normalized slug; returns ErrSlugInvalid otherwise. Returns ErrSlugTaken if the slug is already in use.

func (*Auth) CreateUser added in v0.3.0

func (a *Auth) CreateUser(ctx context.Context, email string) (*User, error)

CreateUser registers a new account with the given email. Password is optional — accounts can be created without a credential and have one set later via SetPassword. Returns ErrEmailTaken if the normalized email is already registered.

func (*Auth) DB added in v0.3.0

func (a *Auth) DB() *sql.DB

DB exposes the underlying *sql.DB. Useful for callers that want to run admin queries on the same pool.

func (*Auth) DeleteAbility added in v0.3.0

func (a *Auth) DeleteAbility(ctx context.Context, slug string) error

DeleteAbility removes an ability by its slug. Cascades to service_key_abilities.

func (*Auth) DeletePermission added in v0.3.0

func (a *Auth) DeletePermission(ctx context.Context, slug string) error

DeletePermission removes a permission by its slug. Cascades to role_permissions and user_permissions.

func (*Auth) DeleteRole added in v0.3.0

func (a *Auth) DeleteRole(ctx context.Context, slug string) error

DeleteRole removes a role by its slug. Cascades to user_roles and role_permissions. Returns ErrRoleNotFound if absent.

func (*Auth) DeleteUser added in v0.3.0

func (a *Auth) DeleteUser(ctx context.Context, userID uuid.UUID) error

DeleteUser removes the user. Cascades to sessions, tokens, role assignments, and direct permission grants via FK ON DELETE CASCADE.

func (*Auth) GetAbilityBySlug added in v0.3.0

func (a *Auth) GetAbilityBySlug(ctx context.Context, slug string) (*Ability, error)

GetAbilityBySlug fetches an ability by its slug.

func (*Auth) GetPermissionBySlug added in v0.3.0

func (a *Auth) GetPermissionBySlug(ctx context.Context, slug string) (*Permission, error)

GetPermissionBySlug fetches a permission by its slug.

func (*Auth) GetRoleBySlug added in v0.3.0

func (a *Auth) GetRoleBySlug(ctx context.Context, slug string) (*Role, error)

GetRoleBySlug fetches a role by its slug.

func (*Auth) GetUser added in v0.3.0

func (a *Auth) GetUser(ctx context.Context, userID uuid.UUID) (*User, error)

GetUser fetches the user by ID. Returns ErrUserNotFound if absent.

func (*Auth) GetUserByEmail added in v0.3.0

func (a *Auth) GetUserByEmail(ctx context.Context, email string) (*User, error)

GetUserByEmail fetches the user by email (input is normalized internally).

func (*Auth) GrantPermissionToRole added in v0.3.0

func (a *Auth) GrantPermissionToRole(ctx context.Context, roleSlug, permSlug string) error

GrantPermissionToRole adds permSlug to roleSlug's permission set. Idempotent.

func (*Auth) GrantPermissionToUser added in v0.3.0

func (a *Auth) GrantPermissionToUser(ctx context.Context, userID uuid.UUID, permSlug string) error

GrantPermissionToUser adds a direct permission grant (not through any role). Idempotent.

func (*Auth) HasAnyRole

func (a *Auth) HasAnyRole(ctx context.Context, userID uuid.UUID, slugs []string) (bool, error)

HasAnyRole reports whether the user holds at least one of the named roles.

func (*Auth) HasPermission

func (a *Auth) HasPermission(ctx context.Context, userID uuid.UUID, permSlug string) (bool, error)

HasPermission reports whether the user holds the named permission, via any combination of role-derived and direct grants.

func (*Auth) HasRole

func (a *Auth) HasRole(ctx context.Context, userID uuid.UUID, slug string) (bool, error)

HasRole reports whether the user holds the named role.

func (*Auth) IssueJWT

func (a *Auth) IssueJWT(ctx context.Context, userID uuid.UUID) (access, refresh string, err error)

IssueJWT issues a fresh access JWT and a rotating opaque refresh token. The refresh token is bound to a chain via Token.ChainID; rotation preserves that chain so reuse-detection can revoke the whole family. chainStartedAt is stamped on this row and copied forward on every rotation so RefreshJWT can enforce RefreshChainAbsoluteTTL in O(1).

func (*Auth) IssueServiceKey added in v0.2.0

func (a *Auth) IssueServiceKey(ctx context.Context, params IssueServiceKeyParams) (string, *ServiceKey, error)

IssueServiceKey mints a fresh service token. Plaintext is returned exactly once (show-once); only the SHA-256 hash is persisted. Each ability slug is resolved to its row before insertion, so the service key carries a well-defined set of abilities even after later slug renames or deletes.

func (*Auth) IssueSession

func (a *Auth) IssueSession(ctx context.Context, userID uuid.UUID, userAgent string, ip netip.Addr) (string, *Session, error)

IssueSession mints an opaque session ID, persists the session record, and returns the plaintext (for the cookie) plus the stored Session.

func (*Auth) ListAbilities added in v0.3.0

func (a *Auth) ListAbilities(ctx context.Context) ([]*Ability, error)

ListAbilities returns every ability ordered by slug.

func (*Auth) ListPermissions added in v0.3.0

func (a *Auth) ListPermissions(ctx context.Context) ([]*Permission, error)

ListPermissions returns every permission ordered by slug.

func (*Auth) ListRolePermissions added in v0.3.0

func (a *Auth) ListRolePermissions(ctx context.Context, roleSlug string) ([]*Permission, error)

ListRolePermissions returns permissions granted to a role through the role-permission link only (not direct user grants).

func (*Auth) ListRoles added in v0.3.0

func (a *Auth) ListRoles(ctx context.Context) ([]*Role, error)

ListRoles returns every role ordered by slug.

func (*Auth) ListServiceKeys added in v0.2.0

func (a *Auth) ListServiceKeys(ctx context.Context) ([]*ServiceKey, error)

ListServiceKeys returns every service token, including revoked and expired ones, ordered by creation time descending.

func (*Auth) LoginPassword

func (a *Auth) LoginPassword(ctx context.Context, email, password string) (*User, error)

LoginPassword verifies the password and returns the authenticated user. Failure does not increment any counter — consumers wanting lockout should implement it via LoginHook (see README). Success resets nothing and stamps last_login_at. LoginHook is invoked with the success outcome.

func (*Auth) RefreshJWT

func (a *Auth) RefreshJWT(ctx context.Context, plaintextRefresh string) (access, refresh string, err error)

RefreshJWT consumes the presented refresh token and mints a new access+refresh pair. Reuse of an already-consumed refresh token deletes the entire chain (logout-everywhere on that device family) and returns ErrTokenReused. The chain itself is capped at RefreshChainAbsoluteTTL from chain_started_at — past that, refresh fails with ErrTokenInvalid and the chain is deleted, forcing the user to re-authenticate.

func (*Auth) RemoveRole

func (a *Auth) RemoveRole(ctx context.Context, userID uuid.UUID, roleSlug string) error

RemoveRole removes roleSlug from userID. Idempotent on missing assignments.

func (*Auth) RequestEmailOTP added in v0.3.0

func (a *Auth) RequestEmailOTP(ctx context.Context, email string) (string, error)

RequestEmailOTP mints a numeric one-time code for the email and returns the plaintext for delivery. Anti-enumeration: unknown email returns ("", nil) unless Config.RevealUnknownEmail is set.

Code length is Config.EmailOTPDigits (default 6). Brute-force resistance comes from Config.EmailOTPMaxAttempts (default 5): after N wrong tries the code is invalidated, forcing the caller to request a new one.

func (*Auth) RequestEmailVerification

func (a *Auth) RequestEmailVerification(ctx context.Context, userID uuid.UUID) (string, error)

RequestEmailVerification mints a single-use email-verify token for the user. Return the plaintext to the caller for delivery; the lookup hash is what's stored.

func (a *Auth) RequestMagicLink(ctx context.Context, email string) (string, error)

RequestMagicLink mints a single-use magic-link token for the email and returns the plaintext for delivery.

Default behavior is anti-enumeration: if the email is not registered, returns ("", nil) — the caller cannot distinguish "exists" from "doesn't exist". Set Config.RevealUnknownEmail = true to surface ErrUserNotFound.

func (*Auth) RequestPasswordReset

func (a *Auth) RequestPasswordReset(ctx context.Context, email string) (string, error)

RequestPasswordReset mints a single-use password-reset token for the user behind email and returns the plaintext for delivery.

Default behavior is anti-enumeration: unknown email returns ("", nil). Set Config.RevealUnknownEmail = true to surface ErrUserNotFound.

func (*Auth) RevokeAllUserSessions

func (a *Auth) RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) error

RevokeAllUserSessions kills every active session for the user and bumps session_version (invalidating outstanding JWT access tokens).

func (*Auth) RevokePermissionFromRole added in v0.3.0

func (a *Auth) RevokePermissionFromRole(ctx context.Context, roleSlug, permSlug string) error

RevokePermissionFromRole removes permSlug from roleSlug's permission set. Idempotent.

func (*Auth) RevokePermissionFromUser added in v0.3.0

func (a *Auth) RevokePermissionFromUser(ctx context.Context, userID uuid.UUID, permSlug string) error

RevokePermissionFromUser removes a direct permission grant.

func (*Auth) RevokeServiceKey added in v0.2.0

func (a *Auth) RevokeServiceKey(ctx context.Context, plaintext string) error

RevokeServiceKey marks a service token revoked. Idempotent on already-revoked keys.

func (*Auth) RevokeSession

func (a *Auth) RevokeSession(ctx context.Context, plaintext string) error

RevokeSession deletes a single session by its plaintext id. Idempotent — missing sessions are not an error.

func (*Auth) Schema added in v0.3.0

func (a *Auth) Schema() Schema

Schema returns the configured schema.

func (*Auth) SessionCookie

func (a *Auth) SessionCookie(plaintext string, expires time.Time) *http.Cookie

SessionCookie builds an *http.Cookie pre-configured from Config. Pass the plaintext returned by IssueSession and the matching ExpiresAt from the returned *Session.

func (*Auth) SessionCookieName added in v0.3.0

func (a *Auth) SessionCookieName() string

SessionCookieName returns the configured cookie name. Useful for callers wiring extractors without reaching into Config.

func (*Auth) SetPassword added in v0.3.0

func (a *Auth) SetPassword(ctx context.Context, userID uuid.UUID, password string) error

SetPassword stores a password hash for the user. Use for the initial-credential flow and for administrative password changes. Bumping session_version is the caller's responsibility; SetPassword does not invalidate existing sessions on its own. ChangePassword is the safer wrapper for end-user-driven changes.

func (*Auth) UserPermissions

func (a *Auth) UserPermissions(ctx context.Context, userID uuid.UUID) ([]string, error)

UserPermissions returns the union of permission slugs the user holds via roles and direct grants.

func (*Auth) UserRoles added in v0.3.0

func (a *Auth) UserRoles(ctx context.Context, userID uuid.UUID) ([]string, error)

UserRoles returns the role slugs assigned to a user.

type AuthMethod

type AuthMethod string

AuthMethod tags how a Principal was authenticated.

const (
	AuthMethodSession AuthMethod = "session"
	AuthMethodJWT     AuthMethod = "jwt"
)

type Config

type Config struct {
	// Schema lets consumers override table names. Zero value uses
	// DefaultSchema().
	Schema Schema

	// SkipAutoMigrate disables the migration run inside New. The verifier
	// still runs; consumers running their own migrate pipeline should set
	// this and call authkit.Migrate manually before New (or skip it
	// entirely if they manage DDL out-of-band).
	SkipAutoMigrate bool

	// SkipSchemaVerify disables the startup schema check. Recommended only
	// for tests that expect schema drift; production callers should let the
	// verifier run.
	SkipSchemaVerify bool

	// Sessions
	SessionIdleTTL      time.Duration
	SessionAbsoluteTTL  time.Duration
	SessionCookieName   string
	SessionCookieDomain string
	SessionCookiePath   string
	// SessionCookieSecure / SessionCookieHTTPOnly use *bool so a nil value
	// means "fall back to the secure default (true)" while *bool(false) is
	// an explicit opt-out for local dev. BoolPtr is a one-line constructor.
	SessionCookieSecure   *bool
	SessionCookieHTTPOnly *bool
	SessionCookieSameSite http.SameSite

	// JWT (HS256)
	JWTSecret       []byte
	JWTIssuer       string
	JWTAudience     string
	AccessTokenTTL  time.Duration
	RefreshTokenTTL time.Duration
	// RefreshChainAbsoluteTTL caps the maximum lifetime of a refresh chain.
	// A user can refresh as often as they want within RefreshTokenTTL of the
	// last rotation, but the chain itself dies once now > chainStartedAt +
	// RefreshChainAbsoluteTTL — at which point the user must re-authenticate.
	// Mirrors SessionAbsoluteTTL on the session path.
	RefreshChainAbsoluteTTL time.Duration

	// Single-use tokens
	EmailVerifyTTL      time.Duration
	PasswordResetTTL    time.Duration
	MagicLinkTTL        time.Duration
	EmailOTPTTL         time.Duration
	EmailOTPMaxAttempts int
	EmailOTPDigits      int

	// RevealUnknownEmail flips request flows (RequestPasswordReset,
	// RequestMagicLink, RequestEmailOTP) from anti-enumeration silent
	// success to returning ErrUserNotFound when the email isn't
	// registered. Default false (silent).
	RevealUnknownEmail bool

	// Hooks
	Clock     func() time.Time
	Random    io.Reader
	LoginHook func(ctx context.Context, email string, success bool) error
}

Config tunes session/JWT/token TTLs, cookie shape, JWT signing material, schema overrides, and optional hooks. Zero-valued durations are replaced with sane defaults in New; required fields (notably JWTSecret) cause New to panic.

type Deps

type Deps struct {
	DB     *sql.DB
	Hasher Hasher
}

Deps bundles the runtime dependencies the Auth service requires. DB and Hasher are required; New panics on either being nil.

type Extractor

type Extractor func(r *http.Request) (string, bool)

Extractor pulls a credential string out of an HTTP request. Returns (value, true) when found, ("", false) otherwise.

func BearerExtractor

func BearerExtractor() Extractor

BearerExtractor reads the value following "Bearer " in the Authorization header. Comparison is case-insensitive on the scheme.

func ChainExtractors

func ChainExtractors(es ...Extractor) Extractor

ChainExtractors tries each extractor in order, returning the first hit.

func CookieExtractor

func CookieExtractor(name string) Extractor

CookieExtractor reads the named cookie's value.

func HeaderExtractor

func HeaderExtractor(name string) Extractor

HeaderExtractor reads a custom header verbatim.

type Hasher

type Hasher interface {
	Hash(password string) (string, error)
	Verify(password, encoded string) (ok bool, needsRehash bool, err error)
}

Hasher is the password hashing interface. The default implementation is hasher.Argon2id; consumers can swap in alternative KDFs as long as the encoded form lets Verify roundtrip and report needsRehash on parameter drift.

type IssueServiceKeyParams added in v0.3.0

type IssueServiceKeyParams struct {
	Name      string
	Abilities []string
	TTL       *time.Duration
}

IssueServiceKeyParams is the input shape for IssueServiceKey. Abilities are slugs that must already exist in authkit_abilities — issue fails with ErrAbilityNotFound if any slug is unknown. TTL is optional; nil means non-expiring.

type LoginAuthz added in v0.3.0

type LoginAuthz interface {
	// Match reports whether the principal satisfies the predicate.
	Match(p *Principal) bool
	// Validate verifies that every slug referenced by this predicate exists
	// in the database. Called at middleware-construction time so typos fail
	// at boot rather than at request time.
	Validate(ctx context.Context, a *Auth) error
}

LoginAuthz is a predicate over a *Principal. Used by middleware that gates handlers on a user's roles or permissions.

func AllLogin added in v0.3.0

func AllLogin(preds ...LoginAuthz) LoginAuthz

AllLogin returns a predicate satisfied when every child predicate matches. With no children, AllLogin matches everything (returns true).

func AnyLogin added in v0.3.0

func AnyLogin(preds ...LoginAuthz) LoginAuthz

AnyLogin returns a predicate satisfied when at least one child predicate matches. With no children, AnyLogin matches nothing (returns false).

func HasPermission added in v0.3.0

func HasPermission(slug string) LoginAuthz

HasPermission returns a leaf predicate satisfied when the principal carries the given permission slug (resolved through any combination of roles and direct grants).

func HasRole added in v0.3.0

func HasRole(slug string) LoginAuthz

HasRole returns a leaf predicate satisfied when the principal carries the given role slug.

type Permission

type Permission struct {
	ID        uuid.UUID
	Slug      string
	Label     string
	CreatedAt time.Time
}

Permission is a unit of authorization. Granted to users either through a role or directly via authkit_user_permissions.

type Principal

type Principal struct {
	UserID      uuid.UUID
	Method      AuthMethod
	SessionID   []byte
	Roles       []string
	Permissions []string
	IssuedAt    time.Time
	ExpiresAt   time.Time
}

Principal represents an authenticated user. Produced only by user-bound auth methods (session, JWT) and carries identity plus RBAC-resolved roles and permissions. Service-token auth produces a *ServiceKey instead — those credentials carry abilities, not identity.

func (*Principal) HasAnyRole

func (p *Principal) HasAnyRole(slugs ...string) bool

HasAnyRole reports whether the principal holds at least one of the named role slugs.

func (*Principal) HasPermission

func (p *Principal) HasPermission(slug string) bool

HasPermission reports whether the principal holds the named permission slug, resolved through any combination of roles and direct grants.

func (*Principal) HasRole

func (p *Principal) HasRole(slug string) bool

HasRole reports whether the principal holds the named role slug.

type Role

type Role struct {
	ID        uuid.UUID
	Slug      string
	Label     string
	CreatedAt time.Time
}

Role groups permissions for assignment to users. Slug is the immutable business key; Label is an optional human-readable name.

type Schema added in v0.3.0

type Schema struct {
	Tables Tables
}

Schema lets consumers map authkit storage to their own table names. Column overrides are not exposed in v1 — the column set is fixed.

func DefaultSchema added in v0.3.0

func DefaultSchema() Schema

DefaultSchema returns the stock authkit_* names matching the embedded migration files.

func (Schema) Validate added in v0.3.0

func (s Schema) Validate() error

Validate ensures every Schema.Tables field is a non-empty, safe identifier.

type ServiceKey added in v0.2.0

type ServiceKey struct {
	IDHash     []byte
	Name       string
	Abilities  []string
	LastUsedAt *time.Time
	CreatedAt  time.Time
	ExpiresAt  *time.Time
	RevokedAt  *time.Time
}

ServiceKey is a machine credential. It carries no identity — service tokens are produced by applications for outbound API access or inbound automation, and authorize via Abilities resolved through the join table.

func ServiceKeyFromCtx added in v0.3.0

func ServiceKeyFromCtx(ctx context.Context) (*ServiceKey, bool)

ServiceKeyFromCtx returns the authenticated *ServiceKey placed by service-key middleware. The boolean is false when no service-key authentication ran for this request.

func (*ServiceKey) HasAbility added in v0.2.0

func (k *ServiceKey) HasAbility(slug string) bool

HasAbility reports whether the service key carries the named ability slug.

type ServiceKeyAuthz added in v0.3.0

type ServiceKeyAuthz interface {
	Match(k *ServiceKey) bool
	Validate(ctx context.Context, a *Auth) error
}

ServiceKeyAuthz is the analogous predicate type for service-token authorization.

func AllServiceKey added in v0.3.0

func AllServiceKey(preds ...ServiceKeyAuthz) ServiceKeyAuthz

AllServiceKey returns a service-key predicate satisfied when every child matches.

func AnyServiceKey added in v0.3.0

func AnyServiceKey(preds ...ServiceKeyAuthz) ServiceKeyAuthz

AnyServiceKey returns a service-key predicate satisfied when at least one child matches.

func HasAbility added in v0.3.0

func HasAbility(slug string) ServiceKeyAuthz

HasAbility returns a leaf predicate satisfied when the service key carries the given ability slug.

type Session

type Session struct {
	IDHash     []byte
	UserID     uuid.UUID
	UserAgent  string
	IP         netip.Addr
	CreatedAt  time.Time
	LastSeenAt time.Time
	ExpiresAt  time.Time
}

Session is an opaque server-side credential bound to one user.

type Tables added in v0.3.0

type Tables struct {
	Users               string
	Sessions            string
	Tokens              string
	ServiceKeys         string
	ServiceKeyAbilities string
	Roles               string
	Permissions         string
	Abilities           string
	UserRoles           string
	UserPermissions     string
	RolePermissions     string
	SchemaMigrations    string
}

Tables holds per-table identifier overrides. Every field must be a valid unquoted SQL identifier (matching identifierRE). Validation runs at New()/Migrate() time so SQL injection through Schema is impossible past that gate.

type Token

type Token struct {
	Hash              []byte
	Kind              TokenKind
	UserID            uuid.UUID
	ChainID           *string
	ChainStartedAt    *time.Time
	ConsumedAt        *time.Time
	AttemptsRemaining *int
	CreatedAt         time.Time
	ExpiresAt         time.Time
}

Token is one row in authkit_tokens. AttemptsRemaining is non-nil only for tokens that allow retry on incorrect input (email OTPs); other kinds are strictly one-shot via ConsumeToken. ChainStartedAt is non-nil only for refresh-token rows; copied forward on every rotation so the absolute-cap check in RefreshJWT is O(1).

type TokenKind

type TokenKind string

TokenKind enumerates the single-use credentials persisted in authkit_tokens.

const (
	TokenEmailVerify   TokenKind = "email_verify"
	TokenPasswordReset TokenKind = "password_reset"
	TokenMagicLink     TokenKind = "magic_link"
	TokenEmailOTP      TokenKind = "email_otp"
	TokenRefresh       TokenKind = "refresh"
)

type User

type User struct {
	ID              uuid.UUID
	Email           string
	EmailNormalized string
	EmailVerifiedAt *time.Time
	PasswordHash    string
	SessionVersion  int
	LastLoginAt     *time.Time
	CreatedAt       time.Time
	UpdatedAt       time.Time
}

User is the canonical account record. Password hash is empty (and stored NULL in the DB) when no credential has been set — accounts created via invite or magic-link-only flows live in this state until SetPassword runs.

func RefreshUserInCtx added in v0.3.0

func RefreshUserInCtx(ctx context.Context) (*User, error)

RefreshUserInCtx invalidates the cached user and refetches. Use after an admin-side update that should be visible to the rest of the request.

func UserFromCtx added in v0.3.0

func UserFromCtx(ctx context.Context) (*User, error)

UserFromCtx returns the authenticated *User, lazy-loading from the database on first call within this request and caching the result for subsequent calls. Returns ErrNoUserContext if no user-bound auth ran.

Directories

Path Synopsis
cmd
abilities command
Command abilities is the seeding CLI for service-token abilities.
Command abilities is the seeding CLI for service-token abilities.
internal/clihelp
Package clihelp is a small helper used by the cmd/perms, cmd/roles, and cmd/abilities seeding CLIs to dial Postgres, build an *authkit.Auth, and share argument-parsing scaffolding.
Package clihelp is a small helper used by the cmd/perms, cmd/roles, and cmd/abilities seeding CLIs to dial Postgres, build an *authkit.Auth, and share argument-parsing scaffolding.
perms command
Command perms is the seeding CLI for authkit permissions.
Command perms is the seeding CLI for authkit permissions.
roles command
Command roles is the seeding CLI for authkit roles, plus role↔permission linking.
Command roles is the seeding CLI for authkit roles, plus role↔permission linking.
Package hasher provides password-hashing primitives that satisfy the authkit.Hasher interface.
Package hasher provides password-hashing primitives that satisfy the authkit.Hasher interface.
Package middleware provides framework-neutral HTTP middleware for authkit.
Package middleware provides framework-neutral HTTP middleware for authkit.

Jump to

Keyboard shortcuts

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