authkit initial
This commit is contained in:
parent
5173b0a43d
commit
134393fbca
43 changed files with 5188 additions and 1 deletions
344
README.md
344
README.md
|
|
@ -1,3 +1,345 @@
|
|||
# authkit
|
||||
|
||||
Just a concoction of auth stuff in one place.
|
||||
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`](https://git.juancwu.dev/juancwu/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.
|
||||
|
||||
```go
|
||||
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`](https://git.juancwu.dev/juancwu/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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
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
|
||||
|
||||
```go
|
||||
// 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.
|
||||
|
||||
```go
|
||||
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.
|
||||
|
||||
```go
|
||||
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:
|
||||
|
||||
```go
|
||||
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`.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue