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>
|
||
|---|---|---|
| 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_authz.go | ||
| service_jwt.go | ||
| service_magic.go | ||
| service_reset.go | ||
| service_service_key.go | ||
| service_service_key_test.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@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
Principalfor user-bound auth (sessions, JWTs) and aServiceKeyfor 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(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
- 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)andmiddleware.ServiceKeyFrom(ctx)to read the authenticated subject in handlers
Errors
- Sentinel errors (
ErrEmailTaken,ErrInvalidCredentials,ErrTokenInvalid,ErrTokenReused,ErrSessionInvalid,ErrServiceKeyInvalid,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,
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 tokens — IssueServiceKey — 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.