Just a concoction of auth stuff in one place.
Find a file
2026-04-26 01:36:53 +00:00
hasher authkit initial 2026-04-26 01:36:53 +00:00
middleware authkit initial 2026-04-26 01:36:53 +00:00
sqlstore authkit initial 2026-04-26 01:36:53 +00:00
.gitignore Initial commit 2026-04-25 21:15:44 +00:00
authkit.go authkit initial 2026-04-26 01:36:53 +00:00
doc.go authkit initial 2026-04-26 01:36:53 +00:00
errors.go authkit initial 2026-04-26 01:36:53 +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 authkit initial 2026-04-26 01:36:53 +00:00
models.go authkit initial 2026-04-26 01:36:53 +00:00
principal.go authkit initial 2026-04-26 01:36:53 +00:00
README.md authkit initial 2026-04-26 01:36:53 +00:00
service_apikey.go authkit initial 2026-04-26 01:36:53 +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_session.go authkit initial 2026-04-26 01:36:53 +00:00
service_test.go authkit initial 2026-04-26 01:36:53 +00:00
service_user.go authkit initial 2026-04-26 01:36:53 +00:00
stores.go authkit initial 2026-04-26 01:36:53 +00:00
Taskfile.yml authkit initial 2026-04-26 01:36:53 +00:00
tokens.go authkit initial 2026-04-26 01:36:53 +00:00
tokens_test.go authkit initial 2026-04-26 01:36:53 +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

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
  • API keys with custom abilities for per-endpoint scoping
  • A unified Principal type so middleware works the same regardless of which authentication method ran

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

  • middleware.RequireSession, RequireJWT, RequireAPIKey, RequireAny
  • middleware.RequireRole, RequireAnyRole, RequirePermission, RequireAbility
  • middleware.PrincipalFrom(ctx) to read the authenticated principal in handlers

Errors

  • Sentinel errors (ErrEmailTaken, ErrInvalidCredentials, ErrTokenInvalid, ErrTokenReused, ErrSessionInvalid, ErrAPIKeyInvalid, 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,
    APIKeys:     stores.APIKeys,
    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 the 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

// API key with abilities
plaintext, key, err := auth.IssueAPIKey(ctx, u.ID, "ci",
    []string{"billing:read", "users:list"}, nil)

// 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, IssueAPIKey, 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"))

// API-key-only route with a per-endpoint ability check
api := mux.Group("/api/v1", authkitmw.RequireAPIKey(authkitmw.Options{Auth: auth}))
api.Get("/billing", billingHandler, authkitmw.RequireAbility("billing:read"))

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.APIKeys = "api_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, API keys, 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).

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.