Just a concoction of auth stuff in one place.
Find a file
juancwu ea3a6ae8c4 Update Taskfile for v1 layout
test:integration no longer points at ./sqlstore/... (gone) — runs against
the whole module filtered by TestIntegration_*. Add fuzz:slug for the
slug-validation fuzz target and cli:install for the three seeding CLIs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:54:51 +00:00
cmd Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
hasher Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
middleware Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
migrations Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
.gitignore Initial commit 2026-04-25 21:15:44 +00:00
authkit.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
authz.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
authz_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
doc.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
email.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
email_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
errors.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
extractor.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
extractor_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +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 Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
LICENSE Initial commit 2026-04-25 21:15:44 +00:00
models.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
principal.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
README.md Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
service_authz.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_jwt.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
service_jwt_test.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
service_magic.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_magic_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_otp.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_otp_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_reset.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_seed.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_seed_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_service_key.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_service_key_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_session.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_session_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_user.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
service_user_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
slug.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
slug_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_abilities.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_errors.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_migrate.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_permissions.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_queries.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
store_roles.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_scan.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_schema.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_service_keys.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_sessions.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_tokens.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
store_users.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
store_verify.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
store_verify_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
Taskfile.yml Update Taskfile for v1 layout 2026-04-26 23:54:51 +00:00
testdb_test.go Cap refresh chain lifetime via RefreshChainAbsoluteTTL 2026-04-26 23:41:02 +00:00
tokens.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
tokens_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
userctx.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00
userctx_test.go Rebuild for v1.0.0: postgres-only, slug-keyed authz, predicate API 2026-04-26 23:27:30 +00:00

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.