Just a concoction of auth stuff in one place.
Find a file
juancwu 7f1db871bc Cut user-owned API keys; redesign subject model
Removes the APIKey primitive entirely (Auth.IssueAPIKey/AuthenticateAPIKey/
RevokeAPIKey, APIKeyStore, Deps.APIKeys, Stores.APIKeys, Tables.APIKeys,
ErrAPIKeyInvalid, AuthMethodAPIKey, Principal.{APIKeyID, Abilities, HasAbility},
prefixAPIKey, RequireAPIKey, and the 6 SQL templates). Migration
0003_drop_api_keys.sql hard-drops authkit_api_keys.

The new subject model: *Principal carries identity only (sessions, JWTs);
*ServiceKey is the only abilities-bearing credential and gains a
HasAbility(name) method. RequireAbility now reads *ServiceKey from context
(user principals 403 by design). RequireRole/RequirePermission stay
Principal-only. New RequireServiceKey + ServiceKeyFrom + MustServiceKey,
and a heterogeneous RequireAnyOrServiceKey for routes that accept either.
RequireAny is now Principal-only (default [Session, JWT]).

Adds 7 middleware tests (auth, revoked, ability accept/reject across
subjects, role rejects service key, RequireAnyOrServiceKey both paths) and
1 (*ServiceKey).HasAbility unit test. Existing API-key tests deleted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:29:17 +00:00
hasher authkit initial 2026-04-26 01:36:53 +00:00
middleware Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
sqlstore Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
.gitignore Initial commit 2026-04-25 21:15:44 +00:00
authkit.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
doc.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
errors.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
extractor.go authkit initial 2026-04-26 01:36:53 +00:00
go.mod authkit initial 2026-04-26 01:36:53 +00:00
go.sum authkit initial 2026-04-26 01:36:53 +00:00
jwt.go authkit initial 2026-04-26 01:36:53 +00:00
jwt_test.go authkit initial 2026-04-26 01:36:53 +00:00
LICENSE Initial commit 2026-04-25 21:15:44 +00:00
memstore_test.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
models.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
principal.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
README.md Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
service_authz.go authkit initial 2026-04-26 01:36:53 +00:00
service_jwt.go authkit initial 2026-04-26 01:36:53 +00:00
service_magic.go authkit initial 2026-04-26 01:36:53 +00:00
service_reset.go authkit initial 2026-04-26 01:36:53 +00:00
service_service_key.go Add owner-agnostic service tokens 2026-04-26 20:27:19 +00:00
service_service_key_test.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
service_session.go authkit initial 2026-04-26 01:36:53 +00:00
service_test.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
service_user.go authkit initial 2026-04-26 01:36:53 +00:00
stores.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
Taskfile.yml authkit initial 2026-04-26 01:36:53 +00:00
tokens.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00
tokens_test.go Cut user-owned API keys; redesign subject model 2026-04-26 20:29:17 +00:00

authkit

A pragmatic authentication and authorization toolkit for Go web services.

authkit ships interfaces for users, sessions, tokens, API keys, roles, and permissions, plus default database/sql Postgres implementations and framework-neutral HTTP middleware. It supports both opaque server-side sessions and JWT access tokens with rotating refresh tokens, hashes passwords with Argon2id, and pairs naturally with lightmux or any net/http stack.

Install

go get git.juancwu.dev/juancwu/authkit@v0.1.0

authkit itself depends only on database/sql and the Go standard library plus 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 12+ is sufficient — the schema avoids gen_random_uuid() and pgcrypto so no extensions are required.

What's included

Authentication flows

  • Email + password registration and login (Argon2id PHC-encoded hashes)
  • Opaque server-side sessions with sliding TTL bounded by an absolute cap
  • JWT access tokens (HS256) with rotating refresh tokens and reuse detection
  • Email verification, password reset, and magic-link passwordless login

Authorization

  • Roles and permissions with many-to-many wiring (resolved on user-bound Principals)
  • Owner-agnostic service tokens with custom abilities for server-to-server auth (no FK on owner; cascade-on-delete is the consumer's responsibility)
  • A Principal for user-bound auth (sessions, JWTs) and a ServiceKey for service-token auth — middleware composes around both subject types

Storage

  • Interfaces for every store so callers can plug in their own backends
  • Default Postgres implementation built on *sql.DB (sqlstore package)
  • Override table names via Schema without forking — useful when authkit lives alongside an existing application schema
  • A Dialect abstraction so future MySQL / SQLite implementations slot in without changes to store code
  • Embedded versioned migrations applied by a Migrate(ctx, db, dialect, schema) helper that takes a session-scoped advisory lock

HTTP

  • User-bound: middleware.RequireSession, RequireJWT, RequireAny
  • Service-bound: middleware.RequireServiceKey
  • Either: middleware.RequireAnyOrServiceKey (Session/JWT, falling through to ServiceKey)
  • Authz: middleware.RequireRole, RequireAnyRole, RequirePermission (operate on *Principal); middleware.RequireAbility (operates on *ServiceKey)
  • middleware.PrincipalFrom(ctx) and middleware.ServiceKeyFrom(ctx) to read the authenticated subject in handlers

Errors

  • Sentinel errors (ErrEmailTaken, ErrInvalidCredentials, ErrTokenInvalid, ErrTokenReused, ErrSessionInvalid, ErrServiceKeyInvalid, ErrPermissionDenied, ...) compatible with errors.Is
  • All internal errors wrap with errx for op tags

Out of scope (v1)

MFA/TOTP, OAuth/social login, soft-delete, in-memory permission caching, pluggable JWT signers (HS256 only), built-in HTTP handlers, MySQL/SQLite dialects (architecture supports them; only Postgres ships in v1), and column-name overrides in Schema (table-name overrides only).

Quick start

1. Open a database and run migrations

import (
    "database/sql"

    "git.juancwu.dev/juancwu/authkit/sqlstore"
    pgdialect "git.juancwu.dev/juancwu/authkit/sqlstore/dialect/postgres"

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

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

if err := sqlstore.Migrate(ctx, db, pgdialect.New(), sqlstore.DefaultSchema()); err != nil {
    log.Fatal(err)
}

Migrate is idempotent and safe to call from multiple processes — it takes a session-scoped pg_advisory_lock to serialise rollouts.

sqlx users can pass sqlxDB.DB (the underlying *sql.DB) to the same calls — the library only cares about *sql.DB.

2. Wire the service

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

stores, err := sqlstore.New(db, pgdialect.New(), sqlstore.DefaultSchema())
if err != nil { /* ... */ }

auth := authkit.New(authkit.Deps{
    Users:       stores.Users,
    Sessions:    stores.Sessions,
    Tokens:      stores.Tokens,
    ServiceKeys: stores.ServiceKeys,
    Roles:       stores.Roles,
    Permissions: stores.Permissions,
    Hasher:      hasher.NewArgon2id(hasher.DefaultArgon2idParams(), nil),
}, authkit.Config{
    JWTSecret:             []byte(os.Getenv("JWT_SECRET")),
    JWTIssuer:             "myapp",
    SessionCookieSecure:   true,
    SessionCookieHTTPOnly: true,
})

Config zero values fall back to sensible defaults (24h idle / 30d absolute session TTL, 15m access tokens, 30d refresh tokens, 48h email-verify, 1h password-reset, 15m magic-link). JWTSecret and all seven Deps fields are required; New panics on a misconfiguration.

3. Use the service

// Registration + password login
u, err := auth.Register(ctx, "alice@example.com", "hunter2hunter2")
u, err = auth.LoginPassword(ctx, "alice@example.com", "hunter2hunter2")

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

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

// Service token (owner-agnostic; ownerKind labels the namespace).
// Service tokens are the only credential type that carries free-form abilities.
plaintext, sk, err := auth.IssueServiceKey(ctx,
    "application", appID, "events-ingest",
    []string{"events:write"}, nil)
got, err := auth.AuthenticateServiceKey(ctx, plaintext)
// got.OwnerKind == "application"; got.OwnerID == appID
err = auth.RevokeServiceKey(ctx, plaintext)

// Email verification + password reset + magic link
tok, err := auth.RequestEmailVerification(ctx, u.ID)
_, err = auth.ConfirmEmail(ctx, tok)

tok, err = auth.RequestPasswordReset(ctx, "alice@example.com")
err = auth.ConfirmPasswordReset(ctx, tok, "new-password")

tok, err = auth.RequestMagicLink(ctx, "alice@example.com")
u, err = auth.ConsumeMagicLink(ctx, tok)

The plaintext returned by IssueSession, IssueJWT, IssueServiceKey, and the token-minting flows is show-once — only its SHA-256 hash is stored. Show it to the user immediately; you cannot recover it later.

4. Wire middleware

authkit/middleware returns standard func(http.Handler) http.Handler values, so it composes with lightmux.Mux.Use/Group/Handle and any net/http mux that accepts the same shape.

import (
    authkitmw "git.juancwu.dev/juancwu/authkit/middleware"
    "git.juancwu.dev/juancwu/lightmux"
)

mux := lightmux.New()

cookieAuth := authkitmw.RequireSession(authkitmw.Options{
    Auth:      auth,
    Extractor: authkit.ChainExtractors(
        authkit.CookieExtractor("authkit_session"),
        authkit.BearerExtractor(),
    ),
})

me := mux.Group("/me", cookieAuth)
me.Get("", func(w http.ResponseWriter, r *http.Request) {
    p := authkitmw.MustPrincipal(r)
    json.NewEncoder(w).Encode(p)
})

// RBAC: stack authz on top of any auth method
admin := mux.Group("/admin", cookieAuth, authkitmw.RequireRole("admin"))

// Service-token route with a per-endpoint ability check
api := mux.Group("/api/v1", authkitmw.RequireServiceKey(authkitmw.Options{Auth: auth}))
api.Get("/events", eventsHandler, authkitmw.RequireAbility("events:write"))

// Mixed route — accept either a session cookie or a service token
mixed := mux.Group("/v1", authkitmw.RequireAnyOrServiceKey(authkitmw.Options{Auth: auth}))
mixed.Get("/profile", func(w http.ResponseWriter, r *http.Request) {
    if p, ok := authkitmw.PrincipalFrom(r.Context()); ok {
        // user request
        _ = p
    } else if k, ok := authkitmw.ServiceKeyFrom(r.Context()); ok {
        // service request
        _ = k
    }
})

Options.Extractor defaults to BearerExtractor; pass CookieExtractor (or chain extractors) when reading session cookies. Options.OnUnauth and Options.OnForbidden default to a JSON 401 / 403; override them to match your error envelope.

Custom table names

Pass a non-default Schema to use your own table names. Identifiers must match ^[a-zA-Z_][a-zA-Z0-9_]*$; anything else is rejected at New() and Migrate() time, so SQL injection through the schema is impossible.

schema := sqlstore.DefaultSchema()
schema.Tables.Users = "accounts"
schema.Tables.ServiceKeys = "service_credentials"

stores, _ := sqlstore.New(db, pgdialect.New(), schema)

The bundled migration files use the default authkit_* names. If you override, you're responsible for matching DDL (most consumers with custom naming already have their own DDL pipeline).

Column-name overrides are not exposed in v1 — the column set is fixed for each table. Adding column overrides later is purely additive.

How things work

Secret token format

Sessions, refresh tokens, service tokens, email-verify tokens, password-reset tokens, and magic-link tokens all share one format:

plaintext = "<prefix>_" + base64url(32 random bytes, no padding)
lookup    = sha256(plaintext)

Plaintext is returned to the caller exactly once and never persisted; the SHA-256 is the database lookup key. Random bytes come from crypto/rand (or Config.Random for tests). The mint/parse/hash helpers are exported as MintOpaqueSecret, ParseOpaqueSecret, and HashOpaqueSecret for callers building bespoke token storage on top of the same shape.

User credentials vs. service tokens

authkit exposes two distinct subject types, and middleware composes around them differently.

User credentials — sessions and JWTs — prove identity. They are produced by IssueSession / IssueJWT and authenticate via AuthenticateSession / AuthenticateJWT, which return a *Principal carrying UserID, Method, and the user's roles + permissions resolved through RBAC. Authorization on these requests is role/permission-based via RequireRole / RequirePermission. User credentials carry no abilities; "what this user may do" is answered by the user's RBAC, not by anything embedded on the credential itself.

Service tokensIssueServiceKey — prove "this caller may do X". They are owner-agnostic: OwnerKind labels the namespace ("application", "tenant", whatever) and OwnerID identifies the entity within it. The database column has no foreign key on purpose — authkit makes no assumption about what the owner is, and cascade-on-delete is the consumer's responsibility. AuthenticateServiceKey returns a *ServiceKey directly (no *Principal, no role/permission resolution). Authorization on these requests is ability-based via RequireAbility; the abilities slice is free-form and not linked to authkit_roles / authkit_permissions.

plaintext, key, err := auth.IssueServiceKey(ctx,
    "application", appID, "events-ingest",
    []string{"events:write"}, nil)

k, err := auth.AuthenticateServiceKey(ctx, plaintext)
// k.OwnerKind == "application"; k.OwnerID == appID; k.HasAbility("events:write")

err = auth.RevokeServiceKey(ctx, plaintext)

When a consumer-owned entity (an application, a tenant) is deleted, the consumer must revoke the associated service tokens itself — typically by iterating ListServiceKeys(ctx, ownerKind, ownerID).

JWT revocation

Access tokens carry sv (session_version) in their claims. When you call RevokeAllUserSessions or ChangePassword, the user's session_version column increments and every outstanding access token fails the next AuthenticateJWT. This is the only way to invalidate a JWT before its exp.

Refresh token rotation

Each RefreshJWT consumes the presented refresh token and issues a new one on the same chain (the chain_id column on authkit_tokens). If a consumed refresh token is ever presented again — a strong replay signal — the entire chain is deleted via TokenStore.DeleteByChain and the call returns ErrTokenReused.

Sliding session TTL

Each authenticated request via AuthenticateSession slides expires_at to now + Config.SessionIdleTTL, capped at created_at + Config.SessionAbsoluteTTL. Long-lived idle sessions still hit the absolute boundary.

Schema and migrations

sqlstore.Migrate applies every embedded .sql file under sqlstore/dialect/postgres/migrations/ whose version (filename without .sql) is not in authkit_schema_migrations. The dialect's AcquireMigrationLock (Postgres uses pg_advisory_lock) serialises concurrent migrators. Each migration owns its own transaction so future migrations can use statements like CREATE INDEX CONCURRENTLY.

Every default table is prefixed authkit_ so the schema can live alongside your application's own tables in a shared database.

Driver and dialect architecture

The sqlstore package speaks database/sql only. Driver-specific behaviour lives behind a small Dialect interface:

type Dialect interface {
    Name() string
    BuildQueries(s Schema) Queries
    Bootstrap(ctx context.Context, db *sql.DB) error
    AcquireMigrationLock(ctx context.Context, conn *sql.Conn) (release func(), err error)
    Migrations() fs.FS
    IsUniqueViolation(err error) bool
    Placeholder(n int) string
    PlaceholderList(start, count int) string
}

v1 ships dialect/postgres. A future MySQL or SQLite dialect adds a new implementation; no changes to store code.

Configuration reference

Field Default Notes
SessionIdleTTL 24h Sliding window applied on each authenticated request
SessionAbsoluteTTL 30d Cap from created_at; sliding never exceeds this
SessionCookieName authkit_session
SessionCookieSameSite Lax
SessionCookieSecure / HTTPOnly false / false Set both to true in production
JWTSecret — (required) HS256 key
JWTIssuer / JWTAudience empty When set, parser enforces them
AccessTokenTTL 15m
RefreshTokenTTL 30d
EmailVerifyTTL / PasswordResetTTL / MagicLinkTTL 48h / 1h / 15m
Clock time.Now().UTC Controls every observable timestamp; 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

Implementing your own store

Every store is a small interface with explicit semantics — see stores.go. The most subtle contract is TokenStore.ConsumeToken: it MUST mark the token consumed and return it in a single statement (UPDATE ... RETURNING on Postgres / SQLite 3.35+) so two concurrent callers cannot both succeed.

Testing

go test ./...                                                   # unit tests, no DB
AUTHKIT_TEST_DATABASE_URL=postgres://... go test ./sqlstore...  # integration tests

Unit tests cover token mint/parse, Argon2id encode/verify (including needsRehash on parameter change), JWT issue/parse (incl. expired, sv-mismatch, refresh rotation, reuse detection), session lifecycle, email verification, password reset cascading session invalidation, magic-link self-verification, API keys with abilities, and RBAC role-permission resolution. Integration tests run the full sqlstore contract against a real Postgres when AUTHKIT_TEST_DATABASE_URL is set.

License

MIT. See LICENSE.