A multi-tenant identity & access management service that issues, verifies, and rotates credentials over HTTP. It speaks several standards (OAuth 2.0, OpenID Connect, WebAuthn, TOTP) so that any client following those specs can authenticate without custom integration.
Non-goals (today): SAML, SCIM, social login, full admin UI, custom claim mapping. Each is named in the roadmap.
Every domain is split into four layers:
internal/<domain>/
domain/ — entities + repository interfaces (ports)
app/ — use cases (business logic)
infra/... — postgres / redis / nats implementations (adapters)
transport/http — HTTP handlers (delivery)
middleware/ — request-level concerns
Use cases depend on interfaces, never on concrete implementations. The composition root (cmd/iam-server/main.go) is the only place where concrete types are picked. This gives us:
- Testability: in-memory repos for unit tests of use cases.
- Swappability: a postgres repo can be replaced with a redis repo with no domain change.
- No god classes: each file fits in your head.
There is no DI framework. main.go wires everything by hand. This is verbose but explicit — you can trace dependencies by reading 250 lines top to bottom, no struct tags, no reflection, no init() side effects.
Two tables anchor the model:
identities— one row per human (global, email-unique, email-verified bit, status)users— one row per (tenant, identity) pair (tenant-scoped membership, can have its own username/display name)
A single person logging into two tenants gets two users rows with one shared identities row. Every query above the identity layer is tenant-scoped by default; mixing tenants is impossible by construction.
- JWT access tokens (15 min) → stateless verify, no DB lookup, scales horizontally.
- Opaque refresh tokens (7 d) → stored hashed (SHA-256) in
sessions.refresh_token_hash, rotated on each refresh, revoked sessions can't be replayed. - Session-ID auth (legacy / mobile fallback) → DB-backed, now cached in Redis (5 min TTL) for sub-2ms verify.
Both auth paths produce the same Principal struct in context, so handlers never have to know which path was used.
auth-server/
├── cmd/
│ ├── iam-server/ composition root (HTTP API)
│ ├── worker/ audit consumer + session cleanup
│ ├── key-rotator/ generates/rotates ES256/RS256 signing keys
│ └── migrate/ (placeholder)
└── internal/
├── identity/ identities + tenant-scoped users
├── auth/ register / login / refresh / logout / authn middleware
├── authorization/ roles, permissions, RBAC checks
├── tenant/ tenant + tenant policies
├── session/ opaque-refresh-token-backed sessions
├── token/ JWT signing keys + issuance/verification
├── oauth/ authorization code + PKCE + refresh_token grants
├── oidc/ ID tokens, discovery, userinfo
├── mfa/ TOTP enrollment + challenge
├── webauthn/ passkey registration + discoverable login
├── audit/ NATS publisher/consumer + audit_events repo
├── platform/
│ ├── postgres/ pgx pool + Pools (writer/reader)
│ ├── redis/ go-redis client
│ ├── nats/ nats.go connection
│ ├── httpserver/ chi router + CORS + request logger
│ ├── metrics/ Prometheus registry + middleware
│ └── config/ env loader
└── shared/
├── context/ typed context keys (principal, transaction)
├── principal/ Principal struct
├── errors/ sentinel errors
├── password/ Argon2id hash/verify
├── token/ random + SHA-256 helpers
├── id/ UUID generation
├── slug/ slug generation
└── tx/ transaction manager
client server postgres redis
|--POST /login-------->|
| |--GetByEmail-------------->|
| |<-identity, credential-----|
| | argon2.Verify (CPU bound)
| |--GetByIdentityID--------->|
| |<-user----------------------|
| |--TenantPolicy.Get--------->|
| |<-policy--------------------|
| |--GetVerifiedFactors------>|
| |<-[] (no MFA)---------------|
| |--GenerateRandom(32)
| |--SHA-256 hash refresh
| |--GetRoles + GetPermissions->|
| |--Create session------------>|
| |--JWT.Issue (ES256)
|<--200 + access_token-|
| + Set-Cookie: refresh_token; HttpOnly; SameSite=Strict
client server redis
|--POST /login-------->|
| ...password verify...
| |--GetVerifiedFactors-->...
| | factors > 0
| |--GenerateRandom(32) (mfa_token)
| |--Store challenge------->| TTL=5min
|<--200 {mfa_required:true, mfa_token}
|
|--POST /mfa/complete->|
| |--ChallengeStore.Consume->| (one-time GetDel)
| |--TOTPService.Verify (window=1 step)
| |--create session + JWT
|<--200 + tokens
client server
|--POST /login-------->|
| ...password verify...
| |--TenantPolicy.Get
| | policy.RequireMFA=true
| |--GetVerifiedFactors-->[]
|<--403 "mfa enrollment required"
Out-of-band: an admin or a previous-session-with-mfa flow must enroll TOTP first.
client server postgres redis
|--POST /refresh------>|
| cookie or body
| |--SHA-256 refresh
| |--GetByRefreshTokenHash----->|
| | check ExpiresAt, RevokedAt
| |--sessionRepo.Revoke(old)--->|
| |--principalCache.Delete----->|
| |--GenerateRandom(32) (new refresh)
| |--Create new session-------->|
| | parent_session_id = old.ID
| |--Identity + Roles + Perms-->|
| |--JWT.Issue
|<--200 + new tokens + Set-Cookie (new refresh)
Replay-prevention property: the old refresh token's hash is no longer in the table for a non-revoked row, so a stolen old token returns 401.
SPA server redis
|--code_verifier = random
|--code_challenge = base64url(SHA256(verifier))
|
|--GET /oauth/authorize?
| response_type=code
| client_id=...
| redirect_uri=...
| code_challenge=... method=S256
| nonce=... state=... ----->|
| |--client repo lookup
| |--verify redirect_uri allowlist
| |--Principal must come from session/JWT
| |--generate code, store in Redis (10 min TTL)
|<--302 redirect_uri?code=...&state=...
|
|--POST /oauth/token
| grant_type=authorization_code
| code=...
| code_verifier=...
| client_id=... -------------->|
| |--CodeStore.Consume (one-time GetDel)
| |--verify base64url(SHA256(verifier)) == challenge
| |--verify redirect_uri match
| |--create session + JWT
| |--if openid in scopes → IDTokenService.Issue
|<--{access_token, refresh_token, id_token?, expires_in, scope}
PKCE is mandatory for every client (public and confidential) and the only supported method is S256 (RFC 7636 §4.4).
browser server redis postgres
|--POST /webauthn/login/begin --->|
| |--webauthn.BeginDiscoverableLogin
| |--store SessionData --->| TTL=5min
|<--{session_key, options}--------|
|
|--navigator.credentials.get(options)
| user taps Touch ID / passkey
|
|--POST /webauthn/login/complete-->|
| {session_key, response}
| |--SessionData.Consume <--|
| |--FinishDiscoverableLogin
| | handler resolves user_handle -> user
| | verifies signature & sign-count
| |--update sign_count -------->|
| |--issue session + JWT
|<--{access_token, refresh_token, ...}
| Token | TTL | Where stored | Where transmitted | Purpose |
|---|---|---|---|---|
| Access token (JWT) | 15 min | Client memory (SPA) / secure storage (mobile) | Authorization: Bearer |
Stateless, sent on every request |
| Refresh token | 7 days | sessions.refresh_token_hash (SHA-256) |
HTTP-only Set-Cookie + JSON body |
Get new access token without re-prompting password |
| ID token | 15 min | Client | JSON body of /oauth/token (OIDC scope) |
Identity claim to relying party |
| Authorization code | 10 min | Redis (oauth:code:<code>) |
URL query string | Single-use, PKCE-bound |
| MFA challenge token | 5 min | Redis (mfa:challenge:<token>) |
JSON body | Carries identity context between password and TOTP |
| Signing key | indefinite | signing_keys table |
Private — never leaves DB; public served via JWKS | ES256/RS256 |
Set-Cookie: refresh_token=<token>;
Path=/;
Max-Age=604800;
HttpOnly;
Secure; (in prod; off in dev)
SameSite=Strict
- HttpOnly — JS can never read it → XSS-proof for the refresh path.
- SameSite=Strict — no third-party origin can trigger a refresh → CSRF-proof.
- Path=/ — sent on every endpoint so
/refreshand/logoutcan both touch it.
type Principal struct {
SessionID, IdentityID, TenantID, UserID, Email string
Roles, Permissions []string
}The authentication middleware loads it (either via JWT decode or DB+cache for session-id) and stores it in the request context under a typed key. Every handler reads it like this:
p, ok := authctx.Principal(r.Context())
if !ok { return 401 }Authorization middleware (RequirePermission("users:read")) is a separate layer that checks p.Permissions and 403s on mismatch.
Key: auth:principal:<sessionID>
Value: JSON-encoded Principal
TTL: 5 minutes
Invalidation:
- Explicit
Deleteon logout - Explicit
Deleteon refresh (which revokes the old session) - Implicit: TTL expiry (5 min)
Tradeoff: a role grant takes up to 5 minutes to propagate to active sessions. That's the cost of skipping 4 DB reads on every request. Acceptable for auth; not acceptable for, say, billing.
All three use the same Redis-GetDel pattern: single-use, short TTL, atomic consume. Code:
b, err := client.GetDel(ctx, key).Bytes()
if err == goredis.Nil { return ErrNotFound }GetDel is atomic in Redis — there is no race window where two clients could redeem the same code.
| Stream | Tool | What it captures |
|---|---|---|
| Logs | slog JSON to stdout |
Per-request line + ERROR/WARN on failure, structured fields, signal-grade |
| Metrics | Prometheus at GET /metrics |
RED metrics + auth events + cache hit-rate |
| Audit | NATS → worker → audit_events table |
Long-term immutable record (planned in v0.10.0) |
We deliberately keep cardinality bounded:
route= chi route template (/users/{userID}), not the actual URLstatus= e.g.200,401,500— not2xx- No
tenant_idoruser_idlabels — that would explode cardinality on a busy server.
{1ms, 5ms, 10ms, 25ms, 50ms, 100ms, 250ms, 500ms, 1s, 2.5s, 5s, 10s} — covers JWT verify (≈4ms) through password login (≈100ms) through worst-case timeouts.
tenants ─── 1:1 ─── tenant_policies (NULL row = system defaults)
│
├── password_{min_length, require_upper, require_digit, require_symbol}
├── allowed_email_domains JSONB
├── require_mfa
└── max_active_sessions
Policy is fetched on the hot path (login). Defaults apply when no row exists — no migration is needed for existing tenants. New tenants get nothing extra until an admin PUT /tenant/policy-s.
permissions.tenant_id is now nullable. NULL = system/global permission (existing rows). The unique constraint moved from (name) to COALESCE(tenant_id::text, '') + name so each tenant can have its own permission space without colliding with the global one. This lays the groundwork for tenant-defined permissions in v0.11.0.
| Use | Algorithm | Why |
|---|---|---|
| Password storage | Argon2id (m=64MB, t=3, p=2, salt=16B) | Memory-hard, side-channel resistant, OWASP-recommended |
| Password compare | crypto/subtle.ConstantTimeCompare |
Defeats timing attacks |
| Refresh token | 32B random → SHA-256 → hex | Hash is what's stored; the raw token is the bearer secret |
| JWT signing | ES256 (P-256 ECDSA) by default; RS256 also supported | Small signatures, fast verify; RS256 for legacy RP compatibility |
| PKCE | SHA-256 → base64url, S256 method only | Plain method is insecure (RFC 7636 §7.2) |
| TOTP | SHA-1, 6 digits, 30s, ±1 step tolerance | Google Authenticator compatibility (SHA-256/512 are stronger but break compatibility) |
| WebAuthn | ES256 / RS256 / EdDSA (whatever authenticator supports) | Discoverable resident keys (passkeys) |
| Signing key rotation | Manual via cmd/key-rotator -alg ES256 |
New keys become active, old keys remain in JWKS for ~7 days for in-flight tokens |
| Failure | Today | Future |
|---|---|---|
| Redis down | Principal cache returns "miss"; system falls back to 4-DB-read path. Code store / MFA challenge fail outright with invalid_grant — acceptable since they need state |
Optional in-memory secondary cache |
| NATS down | Audit publish silently dropped (logged at WARN) | Local file buffer with replay |
| DB down | Server returns 500. Liveness probe fails. | Read-replica fallback, circuit breaker |
| Key rotation mid-flight | Old kid still in signing_keys for 7 days → tokens stay valid |
Keyless JWT acceptance window |
| Replay of revoked refresh | RevokedAt != nil → 401 |
Same |
| MFA factor compromised | Admin must delete via DB | Self-service DELETE /mfa/factors/{id} (already implemented) |
| Decision | Why we chose it | What we gave up |
|---|---|---|
| Hand-wired DI | Explicit, debuggable | Verbosity in main.go |
| Postgres-only persistence | Single source of truth, transactions for free | No multi-region writes |
| Redis for ephemeral state | Atomic GetDel, sub-ms TTL | Extra dep, extra failure mode |
1:1 tenant_policies table (not columns on tenants) |
Easy to add new policy fields without schema churn on tenants |
One extra join (avoided on the hot path by caching) |
| Bearer JWT in JSON body, refresh in cookie | Works for SPA + mobile + SSR identically | Slightly more client complexity (must remember the cookie is set) |
| ES256 default | Modern, small, fast | Some legacy OIDC RPs need RS256 — supported as second key alg |
| Argon2id at m=64MB | Industry recommendation, ~100ms login | Can't sustain >10 logins/s/CPU; mitigated by horizontal scaling, not by weakening params |
| No DI framework | Build is go run; no codegen step |
Repetition in tests |
Native Go testing.F for fuzz |
Zero extra deps, runs in CI | Less feature-rich than libFuzzer |
In priority order (locked in the planning doc):
- Identity & Access — email verification, password reset, magic links, social login (Google/GitHub/Apple), SAML, SCIM, account linking, impersonation, session management API, API keys, custom JWT claims.
- OAuth Advanced — third-party client registration with consent, client credentials grant (M2M), device authorization grant, token revocation, token introspection, PAR.
- Security Hardening — rate limiting (close the documented brute-force gap), constant-time login (close the documented timing gap), account lockout, anomaly detection, DPoP, mTLS service accounts, encrypted signing keys at rest.
- FAPI — JAR, JARM, mTLS-bound tokens, JTI replay prevention.
- RFC 6749 — OAuth 2.0
- RFC 7636 — PKCE
- RFC 6238 — TOTP
- RFC 7519 — JWT
- RFC 7517 — JWK
- RFC 8414 — OAuth 2.0 Authorization Server Metadata
- OpenID Connect Core 1.0
- WebAuthn Level 3
- OWASP ASVS v4 Level 2 — used as the security audit checklist