| hasher | ||
| middleware | ||
| sqlstore | ||
| .gitignore | ||
| authkit.go | ||
| doc.go | ||
| errors.go | ||
| extractor.go | ||
| go.mod | ||
| go.sum | ||
| jwt.go | ||
| jwt_test.go | ||
| LICENSE | ||
| memstore_test.go | ||
| models.go | ||
| principal.go | ||
| README.md | ||
| service_apikey.go | ||
| service_authz.go | ||
| service_jwt.go | ||
| service_magic.go | ||
| service_reset.go | ||
| service_session.go | ||
| service_test.go | ||
| service_user.go | ||
| stores.go | ||
| Taskfile.yml | ||
| tokens.go | ||
| tokens_test.go | ||
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
Principaltype 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(sqlstorepackage) - Override table names via
Schemawithout forking — useful when authkit lives alongside an existing application schema - A
Dialectabstraction 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,RequireAnymiddleware.RequireRole,RequireAnyRole,RequirePermission,RequireAbilitymiddleware.PrincipalFrom(ctx)to read the authenticated principal in handlers
Errors
- Sentinel errors (
ErrEmailTaken,ErrInvalidCredentials,ErrTokenInvalid,ErrTokenReused,ErrSessionInvalid,ErrAPIKeyInvalid,ErrPermissionDenied, ...) compatible witherrors.Is - All internal errors wrap with
errxfor 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.