diff --git a/.env.exampl b/.env.exampl deleted file mode 100644 index fa70af0..0000000 --- a/.env.exampl +++ /dev/null @@ -1,5 +0,0 @@ -DATABASE_URL=postgresql://postgres.xxxxxxxx:your-password@aws-1-eu-west-3.pooler.supabase.com:5432/postgres?sslmode=require -JWT_SECRET= -CORS_ORIGINS=http://localhost:5173,https://yourapp.com -PORT=8080 -ACCESS_TOKEN_TTL_MINUTES=15 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..beb6db7 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +DATABASE_URL=postgresql://postgres.xxxxxxxx:your-password@aws-1-eu-west-3.pooler.supabase.com:5432/postgres?sslmode=require +JWT_SECRET= +CORS_ORIGINS=http://localhost:5173,https://yourapp.com +PORT=8080 +ACCESS_TOKEN_TTL_MINUTES=15 +BASE_URL=http://localhost:8080 + +# OAuth providers are optional; one missing its ID or secret is simply +# unavailable (404 oauth_provider_not_configured), not a startup error. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +MICROSOFT_CLIENT_ID= +MICROSOFT_CLIENT_SECRET= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +GITLAB_CLIENT_ID= +GITLAB_CLIENT_SECRET= + +# Apple needs all four (its "secret" is a JWT this API signs with the .p8 +# key, so there is no static secret). Write the key's newlines as \n. +APPLE_CLIENT_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= + +# Second factors. ENCRYPTION_KEY is required for TOTP and/or passkeys and +# encrypts TOTP secrets at rest — treat it like JWT_SECRET. Leave it unset +# to run password-only; the second-factor endpoints then answer 404 +# rather than the server refusing to start. +ENCRYPTION_KEY= +TOTP_ISSUER_NAME= +WEBAUTHN_RP_ID= +WEBAUTHN_RP_DISPLAY_NAME= +WEBAUTHN_RP_ORIGINS= diff --git a/README.md b/README.md index c3580a6..2917ba9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ cp .env.example .env # fill in DATABASE_URL, JWT_SECRET, CORS_ORIGINS go run . ``` -Run the migrations in `migrations/` against your database first (copies of CrydenSync's own migrations, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy). `002_oauth_identities` is required even if you don't use OAuth yet — `NewOAuthStore` is wired into the engine config unconditionally. +Run the migrations in `migrations/` against your database first, in order (copies of CrydenSync's own migrations, kept here so this repo is self-contained for local dev and CI — same as `typebook` keeps its own copy). `002_oauth_identities` is required even if you don't use OAuth yet — `NewOAuthStore` is wired into the engine config unconditionally. `004` through `008` are the TOTP, WebAuthn, recovery-code, login-attempt and API-key tables; run them even if you leave `ENCRYPTION_KEY` unset, since `007` is what the engine's credential-stuffing detection reads once Tier 2 wires it up and `008` is what the API-key work will use. OAuth is optional. To enable a provider, set its client ID/secret plus `BASE_URL` (used to build the callback URL registered in that provider's console): @@ -32,6 +32,55 @@ GITHUB_CLIENT_SECRET=... A provider missing its client ID or secret is simply unavailable — its endpoints return `404 oauth_provider_not_configured` rather than the server refusing to start. +Supported providers are `google`, `github`, `microsoft`, `discord`, `gitlab` and `apple`. + +`google`/`github`/`microsoft`/`discord`/`gitlab` all have the same authorization-code shape, so each is one case in `httpapi/oauth_handlers.go` plus its env vars: + +``` +MICROSOFT_CLIENT_ID=... MICROSOFT_CLIENT_SECRET=... +DISCORD_CLIENT_ID=... DISCORD_CLIENT_SECRET=... +GITLAB_CLIENT_ID=... GITLAB_CLIENT_SECRET=... +``` + +Apple is the one that does not fit that shape, and it is the only provider needing more than an ID and a secret: + +``` +APPLE_CLIENT_ID=com.example.web # your Services ID, not the app bundle ID +APPLE_TEAM_ID=XXXXXXXXXX +APPLE_KEY_ID=XXXXXXXXXX +APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY----- +``` + +- Its client "secret" is a short-lived ES256 JWT this API signs itself (`httpapi/apple.go`), which is why it needs the `.p8` key rather than a string. In `.env`, write the key's newlines as `\n`; a real multiline value passed through a secret manager is used as-is. +- There is no userinfo endpoint. The email and account ID come from the `id_token` in the token response, verified against Apple's published signing keys (issuer, audience, expiry and RS256 all enforced) rather than merely decoded. +- The authorization request pins `response_mode=query`, so the existing GET callback route works unchanged. Consequently the one-time `user` payload (the name Apple sends only on a first authorization) is not captured — this API stores the id_token's email, not names. + +All four `APPLE_*` values are required; a partially configured Apple is simply unavailable, like any other unconfigured provider. + +## Second factors + +TOTP and passkeys are optional and all-or-nothing on `ENCRYPTION_KEY`: cryden refuses to construct an engine with a TOTP or WebAuthn store set and no encryption key (a TOTP secret must be recoverable in plaintext to check a code, so it is encrypted rather than hashed). With the key unset, both methods answer `404 totp_not_configured` / `404 passkeys_not_configured` per request, the same shape an unconfigured OAuth provider uses, rather than the server refusing to start. + +``` +ENCRYPTION_KEY=... # required for TOTP and/or passkeys +TOTP_ISSUER_NAME=YourApp # cosmetic; shown in the authenticator app +WEBAUTHN_RP_ID=yourapp.com # your real registrable domain — a security parameter, not a label +WEBAUTHN_RP_DISPLAY_NAME=Your App Inc +WEBAUTHN_RP_ORIGINS=https://yourapp.com +``` + +Passkeys also need all three `WEBAUTHN_*` values; setting only some of them logs a startup warning and leaves passkeys off. + +Magic-link login needs no extra configuration — it reuses the same `Verifications` store email change uses, and `consoleMagicLinkSender` (in `email_sender.go`) is a dev stand-in exactly like `consoleEmailSender`. + +**Once an account has a confirmed second factor, a correct password is no longer enough.** `POST /v1/login`, the OAuth callback and `/v1/magic-link/complete` all answer `200` with: + +```json +{ "data": { "second_factor_required": true, "pending_token": "...", "methods": ["totp", "webauthn"] } } +``` + +`pending_token` is then handed to whichever completion endpoint matches a listed method: `/v1/login/totp`, `/v1/login/passkey/begin` + `/v1/login/passkey/finish`, or `/v1/login/recovery-code`. It is not an access or refresh token and does nothing on any other endpoint. + ## Rate limiting Two independent layers: @@ -52,6 +101,12 @@ Every response follows one of two shapes: `code` is the stable string to branch on programmatically. `message` is for humans — never parse it. +One error carries a third, optional key: `password_policy_violation` includes a `details` array holding every broken rule at once as stable codes (`min_length`, `max_length`, `require_uppercase`, `require_lowercase`, `require_digit`, `require_symbol`), so a client can list them together instead of discovering one per submit. Every other error has exactly the two keys above. + +```json +{ "error": { "code": "password_policy_violation", "message": "password does not meet the required policy", "details": ["min_length", "require_digit"] } } +``` + ## Endpoints ``` @@ -72,9 +127,25 @@ GET /v1/oauth/{provider}/callback GET /v1/oauth/{provider}/link (auth required) GET /v1/oauth/{provider}/link/callback GET /v1/health + +POST /v1/totp/enroll (auth required) +POST /v1/totp/confirm (auth required) +POST /v1/totp/disable (auth required) +POST /v1/passkeys/register/begin (auth required) +POST /v1/passkeys/register/finish (auth required) +GET /v1/passkeys (auth required) +DELETE /v1/passkeys/{credentialID} (auth required, password in body) +POST /v1/recovery-codes/generate (auth required) +POST /v1/magic-link/request +POST /v1/magic-link/complete +POST /v1/login/totp (completes a paused login) +POST /v1/login/passkey/begin (completes a paused login) +POST /v1/login/passkey/finish (completes a paused login) +POST /v1/login/recovery-code (completes a paused login) ``` -`{provider}` is `google` or `github`. The two OAuth flows are separate +`{provider}` is `google`, `github`, `microsoft`, `discord`, `gitlab` or `apple`. +The two OAuth flows are separate on purpose: - `/oauth/{provider}` → `/oauth/{provider}/callback` is login/signup — no auth required, since this IS how you get authenticated. @@ -98,6 +169,9 @@ Authenticated endpoints expect `Authorization: Bearer `. - `consoleEmailSender` (in `email_sender.go`) is a dev stand-in — logs verification tokens to the console instead of sending real email. Replace with a real provider (Resend, SES, SendGrid) before real users depend on email verification. - Every engine error is mapped to a stable `(status, code)` pair in `httpapi/errors.go` — add new engine errors there once, every handler benefits. `*auth.ErrOAuthEmailConflict` is the one non-sentinel case in that file (it's a struct carrying `Email`/`Provider`, unwrapped via `errors.As` rather than `errors.Is`). - The OAuth linking flow's HMAC-signed cookie (`oauth_handlers.go`) is genuinely new plumbing, not copied from an existing pattern elsewhere in this repo — worth reading closely if you're touching that code, not just trusting it because it compiles. +- A paused login is a `200`, not an error: nothing failed, the caller just has one more step. `httpapi/second_factor.go` is the one place that response shape is written. +- `DELETE /v1/passkeys/{credentialID}` takes a JSON body (`{"password": "..."}`) — the password is re-confirmation, so a stolen access token alone cannot weaken an account's own auth requirements. +- Passkey ceremony options and the browser's credential response travel as raw JSON (an object, not a JSON-encoded string), since that is exactly what `navigator.credentials.create()`/`.get()` produce and consume. ## License diff --git a/config/config.go b/config/config.go index 3d6f54e..db4cae1 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,48 @@ type Config struct { GoogleClientSecret string GitHubClientID string GitHubClientSecret string + + MicrosoftClientID string + MicrosoftClientSecret string + DiscordClientID string + DiscordClientSecret string + GitLabClientID string + GitLabClientSecret string + + // Apple is the one provider that needs more than an ID and a secret: + // its client "secret" is a short-lived ES256 JWT this repo signs + // itself, with a key downloaded from Apple's developer console, so + // the signing material is configuration here rather than a static + // string. All four must be set for the provider to be available. + // + // AppleClientID is the Services ID (e.g. "com.example.web"), not the + // app bundle ID. ApplePrivateKey is the .p8 file's contents; because + // a PEM cannot sit on one .env line, literal "\n" sequences are + // converted to real newlines when this is loaded. + AppleClientID string + AppleTeamID string + AppleKeyID string + ApplePrivateKey string + + // EncryptionKey encrypts TOTP secrets and WebAuthn ceremony state at + // rest. Required only if TOTP or WebAuthn is enabled — cryden refuses + // to construct an engine with either store set and this empty, since a + // TOTP secret has to be recoverable in plaintext to check a code, so + // it is encrypted rather than hashed. Treat it with the same care as + // JWT_SECRET. + EncryptionKey string + + // TOTPIssuerName is what the user's authenticator app shows next to + // the account. Cosmetic. Empty means cryden's own default ("Cryden"). + TOTPIssuerName string + + // WebAuthnRPID is the app's real registrable domain — passkeys are + // cryptographically bound to it, so unlike TOTPIssuerName this is a + // genuine security parameter, not a label. WebAuthnRPOrigins must + // list the exact scheme+host+port the browser will send. + WebAuthnRPID string + WebAuthnRPDisplayName string + WebAuthnRPOrigins []string } // Load reads .env (if present, filling only gaps — real env vars @@ -90,6 +132,39 @@ func Load() (Config, error) { cfg.GoogleClientSecret = os.Getenv("GOOGLE_CLIENT_SECRET") cfg.GitHubClientID = os.Getenv("GITHUB_CLIENT_ID") cfg.GitHubClientSecret = os.Getenv("GITHUB_CLIENT_SECRET") + cfg.MicrosoftClientID = os.Getenv("MICROSOFT_CLIENT_ID") + cfg.MicrosoftClientSecret = os.Getenv("MICROSOFT_CLIENT_SECRET") + cfg.DiscordClientID = os.Getenv("DISCORD_CLIENT_ID") + cfg.DiscordClientSecret = os.Getenv("DISCORD_CLIENT_SECRET") + cfg.GitLabClientID = os.Getenv("GITLAB_CLIENT_ID") + cfg.GitLabClientSecret = os.Getenv("GITLAB_CLIENT_SECRET") + cfg.AppleClientID = os.Getenv("APPLE_CLIENT_ID") + cfg.AppleTeamID = os.Getenv("APPLE_TEAM_ID") + cfg.AppleKeyID = os.Getenv("APPLE_KEY_ID") + // .env files are line-oriented, so a PEM arrives with its newlines + // written as \n. Only unescape when the value looks like it + // needs it, so a deployment that already passes a real multiline + // value through its own secret manager is left alone. + cfg.ApplePrivateKey = os.Getenv("APPLE_PRIVATE_KEY") + if strings.Contains(cfg.ApplePrivateKey, `\n`) { + cfg.ApplePrivateKey = strings.ReplaceAll(cfg.ApplePrivateKey, `\n`, "\n") + } + + // Second factors are optional too, and for the same reason: a + // deployment that hasn't set ENCRYPTION_KEY should still run fine for + // password-only auth. main.go only wires the TOTP/WebAuthn stores when + // the key is present, and cryden then reports those methods as + // unavailable (404, same shape as an unconfigured OAuth provider) + // rather than the server refusing to start. + cfg.EncryptionKey = os.Getenv("ENCRYPTION_KEY") + cfg.TOTPIssuerName = os.Getenv("TOTP_ISSUER_NAME") + cfg.WebAuthnRPID = os.Getenv("WEBAUTHN_RP_ID") + cfg.WebAuthnRPDisplayName = os.Getenv("WEBAUTHN_RP_DISPLAY_NAME") + if origins := os.Getenv("WEBAUTHN_RP_ORIGINS"); origins != "" { + for _, o := range strings.Split(origins, ",") { + cfg.WebAuthnRPOrigins = append(cfg.WebAuthnRPOrigins, strings.TrimSpace(o)) + } + } return cfg, nil } diff --git a/docs/development/CURRENT-STATE.md b/docs/development/CURRENT-STATE.md index a58fdc5..f2a7488 100644 --- a/docs/development/CURRENT-STATE.md +++ b/docs/development/CURRENT-STATE.md @@ -5,11 +5,22 @@ A thin HTTP wrapper around cryden v2.5.0 (bumped from v2.1.0 in Tier 0, see below). Routes cover signup, login, refresh, logout/logout-all, sessions list/revoke, change password, delete account, email -verification and email change, and OAuth (Google/GitHub configured -today; the router itself is provider-agnostic via a `{provider}` path -param, so adding a provider is mostly config plus one switch-statement -case in `httpapi/oauth_handlers.go`, not a router change — see `NEXT.md` -Tier 1). +verification and email change, and OAuth — Google, GitHub, Microsoft, +Discord, GitLab and Apple configured today (the router itself is +provider-agnostic via a `{provider}` path param, so a provider is one +switch-statement case in `httpapi/oauth_handlers.go` plus its env vars, +not a router change; Apple is the one that does not fit that shape and +has its own `httpapi/apple.go` — see `NEXT.md` Tier 1). + +Tier 1 also added the second-factor surface: TOTP enroll/confirm/ +disable, passkey registration/list/delete, magic-link request/complete, +recovery-code generation, and the three public completion endpoints a +paused login uses. No authentication logic was added to this repo for +any of it — each handler is an engine call plus this repo's envelope; +the engine still owns hashing, token rotation, lockout and +second-factor state. TOTP, WebAuthn and recovery codes are optional per +deployment, gated on `ENCRYPTION_KEY` (see `main.go`), and an +unconfigured method answers `404`, never a startup failure. Two independent rate-limit layers: cryden's own per-user engine-level limiter, and this repo's own coarse per-IP edge limiter @@ -75,7 +86,86 @@ cryden — see `CODEX.md`'s ownership section for why. tiers gate behind. The first real use of `RequireAdmin` will be whichever Tier 4/5 endpoint lands first. -## Tier 1 through 5 +## Tier 1 — auth methods: DONE + +Built on `feat/tier1-auth-methods` (its own branch, per `CODEX.md`'s +one-branch-per-tier rule), in this order: + +- `migrations/004`-`008` — cryden's `0003`-`0007` copied in, renumbered + to this repo's sequence, header line keeping the original cryden + number so the source stays traceable. Verbatim below the header. +- **TOTP** (`httpapi/totp_handlers.go`): `POST /v1/totp/enroll` + (returns the `otpauth://` URL), `/confirm`, `/disable` (password + re-confirmation), and the public `POST /v1/login/totp` that finishes a + paused login. +- **Passkeys** (`httpapi/passkey_handlers.go`): register begin/finish, + list, delete-by-credential-ID (password re-confirmation), plus the + public login begin/finish pair. Ceremony options and the browser's + credential response are passed through as raw JSON — an object, not a + JSON-encoded string — because that is what `navigator.credentials` + produces and consumes. +- **Magic link** (`httpapi/magiclink_handlers.go` + a new + `notify.MagicLinkSender` in `email_sender.go`): request (always the + same `200` either way — it never creates accounts, and anything else + would enumerate emails) and complete. cryden keeps `MagicLinkSender` + separate from `EmailSender` on purpose, so this is a new small type, + not a second method on the console sender. +- **Recovery codes** (`httpapi/recovery_handlers.go`): generate (the + raw codes are shown exactly once and the response says so) and the + public login completion. +- **Paused logins** (`httpapi/second_factor.go`): a correct password is + no longer always enough, so `/v1/login`, the OAuth callback and + `/v1/magic-link/complete` now answer `200` with + `second_factor_required` + `pending_token` + `methods` instead of + letting `*auth.ErrSecondFactorRequired` fall through to a 500. This + response shape was not specified in `NEXT.md` and is this tier's one + real design decision — see `PROGRESS.md`. +- **More OAuth providers**: Microsoft, Discord, GitLab — same + authorization-code shape as Google/GitHub, one case each. The + code-for-token exchange moved to a POST form body (RFC 6749 §4.1.3) + because Microsoft and Discord require it and Google/GitHub accept it, + replacing the previous query-string form rather than branching per + provider. +- **Apple** (`httpapi/apple.go`): the one provider that is not another + switch case. Its client secret is an ES256 JWT signed per exchange + with the console's `.p8` key, and its identity comes from the token + response's `id_token`, verified against Apple's JWKS (issuer, + audience, expiry, RS256) with the key set cached and refetched on an + unknown `kid`. Authorization uses `response_mode=query` so the + existing GET callback route is unchanged. All four `APPLE_*` values + are required; a partial config reads as unavailable. +- **Error mapping** (`httpapi/errors.go`): every new engine error mapped + once there — the eight per-method errors, the four "not configured" + sentinels (`404`, matching `oauth_provider_not_configured`), and the + Apple verification failure. +- **Two pre-existing bugs found in passing and fixed** (their own + commit): `auth.ErrPasswordPolicyViolation` and + `auth.ErrPasswordBreached` had no `mapError` case, so a signup or + password change that broke the configured policy answered `500 + internal_error`; both are now `400`, with the policy error's broken + rule codes reaching the client in an optional `details` array. The + tracked env file was also renamed `.env.exampl` → `.env.example` to + match what the README has always told you to copy. +- `httpapi/errors_test.go` and `httpapi/apple_test.go` — the first test + files in this repo. The mapping/`writeErr` behaviour, the paused-login + response shape, and Apple's signing plus id_token verification + (accepted case and seven rejection cases, including an `alg: none` + token) are covered; that is deliberately the largest slice verifiable + without a database or Apple credentials. + +**Verification gap, disclosed rather than glossed over:** `go build +./...`, `go vet ./...` and `go test ./...` are clean on this branch +(Go 1.25.0, cryden v2.5.0 from the local module cache), and `gofmt -l` +is empty. The DB-backed smoke test was **not** run — this sandbox has +no Postgres and no network — so the end-to-end paths are still owed a +first run against a real database before Tier 1 counts as verified the +way cryden's own features are. Two paths additionally cannot be +verified here at all: the WebAuthn ceremonies (need a real browser +authenticator) and Apple (needs real Apple credentials; what is tested +offline is the signing and the id_token verification, against a local +JWKS). + +## Tier 2 through 5 Not started. See `NEXT.md` for the full, ordered, specced-in-detail queue. diff --git a/docs/development/NEXT.md b/docs/development/NEXT.md index c923d67..12890c9 100644 --- a/docs/development/NEXT.md +++ b/docs/development/NEXT.md @@ -9,11 +9,24 @@ genuinely unspecified, make the most reasonable call consistent with `CODEX.md`'s ownership rules and note the assumption in `PROGRESS.md`. Tier 0 and Tier 0.5 are done — see `CURRENT-STATE.md`. +Tier 1 is done — see the status note under Tier 1 and `PROGRESS.md`'s +2026-09-14 entries for how far it is verified. --- ## Tier 1 — auth methods +> **Status: every sub-item below is built, Apple included, on +> `feat/tier1-auth-methods`.** `go build`/`go vet`/`go test` are clean +> and the new mapping/`writeErr` behaviour is unit-tested, including +> Apple's client-secret signing and its id_token verification +> (signature, audience, issuer, expiry, algorithm) against a local JWKS. +> What is still owed: a first DB-backed smoke-test run (no Postgres in +> this sandbox), a live Apple round trip (no Apple credentials here), and +> the WebAuthn ceremonies, which need a real browser authenticator. +> `PROGRESS.md` says all of that plainly, per `CODEX.md`'s verification +> rule, rather than counting green unit tests as end-to-end coverage. + Each of these mirrors an existing engine feature that already has a full smoke-test-verified implementation in cryden. The work here is almost entirely translation: engine call in, HTTP request/response @@ -28,6 +41,23 @@ as the existing `001`/`002` copies. Check cryden's own `store/postgres/migrations/` for the exact source content; don't reconstruct from memory. +`004`-`008` are copied; the TOTP/passkey/recovery-code endpoints, +the two magic-link endpoints, the shared paused-login response and the +three mechanical OAuth providers are built. Notes worth keeping: + +- TOTP, WebAuthn and recovery codes are gated on `ENCRYPTION_KEY` in + `main.go` (cryden refuses to build an engine with either store set and + no key). Unconfigured means `404` per request, not a refusal to start. +- A login that pauses for a second factor had to become a `200` + (`second_factor_required` + `pending_token` + `methods`) — repo-wide + that is the shape `/v1/login`, the OAuth callback and + `/v1/magic-link/complete` all use now. This was not spelled out in the + original spec below; it is the one place the spec's "translation only" + framing needed a real decision, and it is recorded in `PROGRESS.md`. +- The code-for-token exchange moved to a POST form body (RFC 6749 + §4.1.3) because Microsoft and Discord require it; Google and GitHub + accept it, so there is one path rather than a per-provider branch. + ### TOTP - `POST /v1/totp/enroll` (auth required) → `cryden.EnrollTOTP`, return the `otpauth://` URL for the client to render as a QR code. @@ -91,6 +121,22 @@ Google/GitHub already use. again. Budget real time for this one; don't estimate it at the same size as the other three. +**Apple: done** (its own commit, `httpapi/apple.go` + `apple_test.go`). +It needed exactly what was foreseen here: an ES256 client-secret JWT +signed per exchange with the console key, and the identity read from the +token response's `id_token` after verifying it against Apple's JWKS. +Two details were decided rather than assumed, and are recorded in +`PROGRESS.md`: + +- `response_mode=query` so the existing GET callback route works + unchanged. The consequence is that Apple's one-time `user` payload + (name, first authorization only) is not captured — this repo stores the + id_token's email, not names. +- No `nonce` parameter. Apple requires one for the hybrid/implicit flow; + this is the authorization-code flow, where the code is single-use and + bound to this client and the redirect already carries a CSRF `state` + cookie. Worth revisiting only if a hybrid flow is ever added. + --- ## Tier 2 — mostly config, one endpoint diff --git a/docs/development/PROGRESS.md b/docs/development/PROGRESS.md index f44ae09..c886e03 100644 --- a/docs/development/PROGRESS.md +++ b/docs/development/PROGRESS.md @@ -39,3 +39,161 @@ Next: Tier 1 (auth methods), each on its own branch per `CODEX.md`. Copying cryden's migrations `0003`-`0007` into this repo (renumbered continuing from `003_operators`) is the first sub-step, before any TOTP/WebAuthn/magic-link/recovery-code endpoint work starts. + +## 2026-09-14 — Tier 1 (auth methods) except Apple + +Branch `feat/tier1-auth-methods`, per `CODEX.md`'s one-branch-per-tier +rule. First session in this repo with a working Go toolchain: Go 1.25.0 +plus cryden v2.5.0 and every dependency already in the module cache, +so the caveat the Tier 0 entry left open is closed — `go mod tidy` +changed nothing, and `go build ./...`, `go vet ./...`, `go test ./...` +and `gofmt -l` are all clean on this branch. That is the whole of what +was verified: **the DB-backed smoke test was not run** (no Postgres and +no network in this sandbox) and neither were the WebAuthn ceremonies, +which need a real browser authenticator. Those still owe a first run +against a real database. Saying that plainly here rather than counting +green builds as "verified end to end", per `CODEX.md`. + +Built, in commit order: + +- `chore: copy cryden 0003-0007 migrations as 004-008` — verbatim below + the header line, which keeps cryden's original number for + traceability while the filename continues this repo's sequence. +- `feat: add TOTP, passkey, recovery-code and magic-link endpoints` — + the whole second-factor surface, plus the shared paused-login + response and the new error mappings. TOTP/WebAuthn/recovery-code + stores are gated on `ENCRYPTION_KEY` in `main.go`; an unconfigured + method answers `404`, the same shape an unconfigured OAuth provider + already used. +- `feat: add Microsoft, Discord and GitLab OAuth providers` — one case + each, plus the token exchange moved onto a POST form body. +- `test: cover second-factor error mapping and the paused-login + response` — `httpapi/errors_test.go`, the first `_test.go` files in + this repo, deliberately limited to what needs no database. + +Decisions and assumptions, none blocking: + +- **A paused login is a `200`, not an error.** `NEXT.md` specified the + completion endpoints but not what `/v1/login` should answer once a + second factor exists. cryden reports that with + `*auth.ErrSecondFactorRequired` (a struct carrying the pending token + and enrolled methods), and unmapped that would have been a `500`. + Chosen: `200` with + `{"second_factor_required": true, "pending_token": ..., "methods": [...]}`, + written in exactly one place (`httpapi/second_factor.go`) and reused + by the OAuth callback and `/v1/magic-link/complete`, since all three + can pause. +- **Token exchange uses a POST form body now.** The existing code put + the code-for-token parameters in the query string; Microsoft and + Discord require the RFC 6749 §4.1.3 body form and would not have + worked otherwise. Google and GitHub accept the body too, so this + replaced the branch-free query version instead of adding a per- + provider special case. +- **Not-configured features are `404`, matching + `oauth_provider_not_configured`** — a client can hide the option + rather than report a server fault. +- **Recovery codes are wired whenever TOTP/WebAuthn are** (i.e. + whenever `ENCRYPTION_KEY` is set), because they are a fallback for + whichever factor is enrolled and are meaningless without one. +- **Partial WebAuthn config logs a startup warning and leaves passkeys + off** rather than half-starting. All three `WEBAUTHN_*` values are + required, and a browser ceremony is a bad place to discover one is + missing. +- **The console magic-link sender's clickable URL is a dev-time + guess** (`BASE_URL + "/magic-link?token=..."`). cryden hands over only + the raw token and owns no routing; a real deployment points this at + its own frontend route. Logged as an assumption here rather than + quietly pretending this repo owns that path. +- **Apple is deliberately still open** and is the only unfinished part + of Tier 1. It is not a fourth mechanical provider: the client secret + is a self-signed ES256 JWT (so a private key in config), the email + arrives inside a signed `id_token` that has to be verified against + Apple's JWKS, and it cannot be smoke-tested without real Apple + credentials. Recorded in `NEXT.md` with what it needs. + +Noticed while working, not fixed (out of scope for this tier, flagged +rather than silently patched): + +- `httpapi/errors.go` has no case for `auth.ErrPasswordPolicyViolation` + or `auth.ErrPasswordBreached`, so a signup or password change that + violates the configured policy currently answers + `500 internal_error` instead of a `400` the client can act on. Real + and user-facing; worth its own small fix. +- `README.md` tells you to `cp .env.example .env`, but the tracked file + is named `.env.exampl`. Left as-is (renaming touches tooling outside + this session's scope), noted so it does not keep getting copied + forward. + +Next: finish Tier 1's Apple provider (its own commit, and it will need +real credentials before it can be smoke-tested), then a first DB-backed +smoke-test run of everything above, then Tier 2. + +## 2026-09-14 (later) — the two flagged issues, then Apple + +Same branch, four more commits. Tier 1 is now complete, Apple included. + +**Fixed the two things flagged in the entry above**, each its own commit: + +- `fix: map password-policy and breached-password errors to 400` — + `auth.ErrPasswordPolicyViolation` and `auth.ErrPasswordBreached` now + map to `400`/`password_policy_violation` and `400`/`password_breached` + instead of falling through to `500 internal_error`. The policy error + keeps its struct case and `writeErr` reads the broken-rule codes off + the error into an optional `details` array — the one addition to the + error envelope, and it appears on no other error (both halves are + pinned by tests, so an existing client sees a byte-identical shape). +- `chore: rename .env.exampl to .env.example` — renamed rather than + rewriting the README, since the README's name is the conventional one + and `.gitignore` only ignores `.env` itself. + +**Apple** (`feat: add Sign in with Apple`, `httpapi/apple.go` + +`apple_test.go` + `golang-jwt` promoted from indirect to direct): + +- Client secret is an ES256 JWT signed per exchange with the console + `.p8` key (10-minute expiry, `kid` header, `iss`=team, `sub`=client + ID, `aud`=Apple). `APPLE_CLIENT_ID`/`TEAM_ID`/`KEY_ID`/`PRIVATE_KEY` + are all required; a partial config reads as unavailable, same as any + other unconfigured provider. `APPLE_PRIVATE_KEY` accepts a `.env`-style + `\n`-escaped PEM and leaves a real multiline value alone. +- No userinfo endpoint: the identity comes from the token response's + `id_token`, verified against Apple's JWKS (RS256 only, issuer, + audience, expiry, `kid` lookup) with the key set cached for an hour + and refetched once on an unknown `kid` so a rotation is picked up. + An unverified decode would have let anyone who can reach the callback + mint an account, so this is the part that got the most test attention. +- `exchangeCode` now takes the client secret as an argument and returns + both the access token and the id_token, which lets Apple reuse the + existing POST plumbing instead of duplicating it. The authorization + redirect query is now built in one `authQuery` helper used by both the + login and linking flows, so a provider-specific parameter cannot be + added to one and forgotten in the other. + +Assumptions made while building Apple (recorded, not silent): + +- **`response_mode=query`**, so the existing GET callback route works + unchanged. Consequence: Apple's one-time `user` payload (the name it + sends only on a first authorization, and only under `form_post`) is + not captured — this repo stores the id_token's email, never names, + which is all `LoginWithOAuth` takes anyway. +- **No `nonce`.** Apple requires one for the hybrid/implicit flow; this + is the authorization-code flow, where the code is single-use, bound to + this client, and the redirect already carries a CSRF `state` cookie + verified against a signed cookie. Revisit only if a hybrid flow is + ever added. +- **Apple's email is required**, like every other provider here — if the + id_token has no email claim the login fails with the existing + `oauth_email_not_available` rather than inventing an identity. + +Verification: `go build ./...`, `go vet ./...` and `go test ./...` clean, +`gofmt -l` empty. `apple_test.go` covers the generated secret (parses as +ES256, correct `kid`/`iss`/`aud`/`sub`, unexpired; RSA and non-PEM keys +rejected) and id_token verification (accepted when signed by the served +key; rejected for wrong audience, wrong issuer, expiry, different +signing key, unknown `kid`, missing subject, and an `alg: none` token). +Still **not** verified here: a live Apple round trip (no credentials, no +network), the DB-backed smoke test (no Postgres), and the WebAuthn +ceremonies (no browser authenticator). Those remain the first things to +run on a real deployment. + +Next: Tier 2, on its own branch per `CODEX.md` — and before or alongside +it, the first DB-backed smoke-test run of everything in Tier 1. diff --git a/email_sender.go b/email_sender.go index 687826c..d3b9607 100644 --- a/email_sender.go +++ b/email_sender.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "net/url" ) // consoleEmailSender is a dev stand-in implementing notify.EmailSender. @@ -14,3 +15,29 @@ func (s *consoleEmailSender) SendVerification(ctx context.Context, to string, ra log.Printf("[EMAIL] Verification token for %s: %s", to, rawToken) return nil } + +// consoleMagicLinkSender is the magic-link equivalent of +// consoleEmailSender. It implements cryden's notify.MagicLinkSender — +// deliberately a separate interface on cryden's side, so this is a new +// type here rather than a second method on consoleEmailSender (see +// cryden's notify/magic_link_sender.go for why the engine kept them +// apart: "click to log in" is a different message from "confirm your +// new email"). +type consoleMagicLinkSender struct { + // BaseURL is this api deployment's own public URL. cryden hands over + // only the raw token — the engine has no concept of your routing, so + // the clickable link is assembled here. Real deployments should point + // this at their own frontend route that reads ?token= and POSTs it to + // /v1/magic-link/complete; the path below is a dev-time guess, not a + // contract this repo owns. + BaseURL string +} + +func (s *consoleMagicLinkSender) SendMagicLink(ctx context.Context, to string, rawToken string) error { + if s.BaseURL == "" { + log.Printf("[MAGIC LINK] Token for %s: %s (BASE_URL unset, no clickable link)", to, rawToken) + return nil + } + log.Printf("[MAGIC LINK] For %s: %s/magic-link?token=%s", to, s.BaseURL, url.QueryEscape(rawToken)) + return nil +} diff --git a/go.mod b/go.mod index f066778..369ede9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/crydensync/cryden/v2 v2.5.0 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/lib/pq v1.12.3 ) @@ -14,7 +15,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/webauthn v0.18.0 // indirect github.com/go-webauthn/x v0.3.0 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect diff --git a/httpapi/apple.go b/httpapi/apple.go new file mode 100644 index 0000000..d7a9a79 --- /dev/null +++ b/httpapi/apple.go @@ -0,0 +1,209 @@ +package httpapi + +import ( + "context" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "net/http" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// appleIssuer is both the `iss` Apple puts in an id_token and the `aud` +// our self-signed client secret must carry — Apple is the audience of +// the secret, we are the audience of the id_token. +const appleIssuer = "https://appleid.apple.com" + +// appleClientSecretTTL is how long a generated client secret is valid. +// Apple's own limit is six months; ten minutes is short because the +// secret is generated per exchange anyway, so there is nothing to gain +// from a long-lived one — it just means a leaked secret is useless +// almost immediately. +const appleClientSecretTTL = 10 * time.Minute + +// appleJWKSURL is a var rather than a const so tests can point it at a +// local server: signature verification is the part of this file worth +// testing, and it cannot be tested against Apple's real endpoint from a +// sandbox with no network. +var appleJWKSURL = appleIssuer + "/auth/keys" + +// appleKeyCacheTTL bounds how long a fetched key set is reused. Apple +// rotates signing keys rarely and publishes no cache lifetime we honor, +// so an hour is short enough to pick up a rotation without re-fetching +// per login. +const appleKeyCacheTTL = time.Hour + +type appleKeyCache struct { + mu sync.Mutex + keys map[string]*rsa.PublicKey + fetchedAt time.Time +} + +var appleKeys appleKeyCache + +// appleClientSecret builds the ES256 JWT Apple wants in place of a +// static client secret. This is the whole reason Apple is not another +// mechanical provider case: every other provider hands you a string, +// Apple hands you a key and expects you to sign. +func appleClientSecret(p oauthProvider) (string, error) { + if p.appleTeamID == "" || p.appleKeyID == "" || p.applePrivateKey == "" { + return "", errOAuthProviderNotConfigured + } + + key, err := parseApplePrivateKey(p.applePrivateKey) + if err != nil { + return "", err + } + + now := time.Now() + tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ + "iss": p.appleTeamID, + "iat": now.Unix(), + "exp": now.Add(appleClientSecretTTL).Unix(), + "aud": appleIssuer, + "sub": p.clientID, + }) + tok.Header["kid"] = p.appleKeyID + return tok.SignedString(key) +} + +// parseApplePrivateKey reads the PKCS#8 .p8 file Apple's developer +// console hands out. A non-EC key is rejected here rather than left to +// fail at signing time with a less obvious error. +func parseApplePrivateKey(pemValue string) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemValue)) + if block == nil { + return nil, fmt.Errorf("httpapi: apple private key is not PEM-encoded") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("httpapi: parsing apple private key: %w", err) + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("httpapi: apple private key is %T, want an EC (P-256) key", parsed) + } + return key, nil +} + +// verifyAppleIDToken checks an id_token against Apple's published +// signing keys and returns the account's stable identifier and email. +// Apple is the only provider here with no userinfo endpoint: the +// identity travels inside this signed JWT, so it is verified rather +// than merely decoded — an unverified parse would let anyone who can +// reach our callback mint an account. +func verifyAppleIDToken(ctx context.Context, idToken, clientID string) (sub, email string, err error) { + claims := jwt.MapClaims{} + _, err = jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + return appleSigningKey(ctx, kid) + }, + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithIssuer(appleIssuer), + jwt.WithAudience(clientID), + jwt.WithExpirationRequired(), + ) + if err != nil { + return "", "", fmt.Errorf("%w: %v", errOAuthIdentityVerificationFailed, err) + } + + sub, _ = claims["sub"].(string) + email, _ = claims["email"].(string) + if sub == "" { + return "", "", errOAuthIdentityVerificationFailed + } + return sub, email, nil +} + +// appleSigningKey returns the RSA key for kid, fetching (and caching) +// Apple's key set as needed. A cache miss for a kid we already fetched +// forces one refetch, which is how a key rotation is picked up without +// waiting out the TTL. +func appleSigningKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + if kid == "" { + return nil, errOAuthIdentityVerificationFailed + } + + appleKeys.mu.Lock() + defer appleKeys.mu.Unlock() + + _, known := appleKeys.keys[kid] + if known && time.Since(appleKeys.fetchedAt) < appleKeyCacheTTL { + return appleKeys.keys[kid], nil + } + + keys, err := fetchAppleKeys(ctx) + if err != nil { + return nil, err + } + appleKeys.keys = keys + appleKeys.fetchedAt = time.Now() + + key, ok := keys[kid] + if !ok { + return nil, errOAuthIdentityVerificationFailed + } + return key, nil +} + +// fetchAppleKeys reads Apple's JWKS. Only RSA keys are accepted — +// Apple signs id_tokens with RS256, and anything else in the set is not +// something this flow will ever validate against. +func fetchAppleKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, appleJWKSURL, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("httpapi: apple key set request failed: status %d", resp.StatusCode) + } + + var body struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, err + } + + out := make(map[string]*rsa.PublicKey, len(body.Keys)) + for _, k := range body.Keys { + if k.Kty != "RSA" || k.Kid == "" { + continue + } + nBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.N, "=")) + if err != nil { + return nil, fmt.Errorf("httpapi: decoding apple key modulus: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(k.E, "=")) + if err != nil { + return nil, fmt.Errorf("httpapi: decoding apple key exponent: %w", err) + } + out[k.Kid] = &rsa.PublicKey{ + N: new(big.Int).SetBytes(nBytes), + E: int(new(big.Int).SetBytes(eBytes).Int64()), + } + } + if len(out) == 0 { + return nil, errOAuthIdentityVerificationFailed + } + return out, nil +} diff --git a/httpapi/apple_test.go b/httpapi/apple_test.go new file mode 100644 index 0000000..dea2d02 --- /dev/null +++ b/httpapi/apple_test.go @@ -0,0 +1,270 @@ +package httpapi + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/crydensync/api/config" +) + +const ( + appleTestClientID = "com.example.web" + appleTestTeamID = "TEAM123456" + appleTestKeyID = "KEY1234567" + appleTestChildID = "apple-kid-1" +) + +func pemForECDSA(t *testing.T) (*ecdsa.PrivateKey, string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating EC key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshaling EC key: %v", err) + } + return key, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +func pemForRSA(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generating RSA key: %v", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatalf("marshaling RSA key: %v", err) + } + return key, string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})) +} + +func appleTestProvider(pem string) oauthProvider { + return oauthProvider{ + name: "apple", + clientID: appleTestClientID, + appleTeamID: appleTestTeamID, + appleKeyID: appleTestKeyID, + applePrivateKey: pem, + } +} + +// TestAppleClientSecretIsVerifiable pins the shape Apple requires of the +// self-signed secret: ES256, the console's key ID in the header, and the +// team/client/issuer claims Apple validates against. +func TestAppleClientSecretIsVerifiable(t *testing.T) { + key, pemStr := pemForECDSA(t) + + secret, err := appleClientSecret(appleTestProvider(pemStr)) + if err != nil { + t.Fatalf("appleClientSecret: %v", err) + } + + parsed, err := jwt.Parse(secret, func(tok *jwt.Token) (any, error) { + return key.Public(), nil + }, jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(appleTestTeamID), jwt.WithAudience(appleIssuer)) + if err != nil { + t.Fatalf("parsing generated secret: %v", err) + } + if kid, _ := parsed.Header["kid"].(string); kid != appleTestKeyID { + t.Fatalf("kid header = %q, want %q", kid, appleTestKeyID) + } + if alg, _ := parsed.Header["alg"].(string); alg != "ES256" { + t.Fatalf("alg = %q, want ES256", alg) + } + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + t.Fatalf("claims type = %T", parsed.Claims) + } + if sub, _ := claims["sub"].(string); sub != appleTestClientID { + t.Fatalf("sub claim = %q, want the client ID %q", sub, appleTestClientID) + } + if exp, ok := claims["exp"].(float64); !ok || time.Unix(int64(exp), 0).Before(time.Now()) { + t.Fatalf("exp claim missing or already expired: %v", claims["exp"]) + } +} + +// TestApplePrivateKeyMustBeEC keeps a wrong key type from surfacing as a +// confusing signing failure later. +func TestApplePrivateKeyMustBeEC(t *testing.T) { + _, rsaPEM := pemForRSA(t) + if _, err := appleClientSecret(appleTestProvider(rsaPEM)); err == nil { + t.Fatal("expected an RSA key to be rejected, got nil error") + } + if _, err := appleClientSecret(appleTestProvider("not a pem")); err == nil { + t.Fatal("expected a non-PEM value to be rejected, got nil error") + } +} + +// appleJWKS serves a one-key JWKS for the given RSA public key, so the +// verification path can be exercised without reaching Apple. +func appleJWKS(t *testing.T, kid string, pub *rsa.PublicKey) *httptest.Server { + t.Helper() + body, err := json.Marshal(map[string]any{ + "keys": []map[string]string{{ + "kty": "RSA", + "kid": kid, + "use": "sig", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()), + }}, + }) + if err != nil { + t.Fatalf("marshaling JWKS: %v", err) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(body) + })) + t.Cleanup(srv.Close) + + originalURL := appleJWKSURL + appleJWKSURL = srv.URL + appleKeys = appleKeyCache{} + t.Cleanup(func() { + appleJWKSURL = originalURL + appleKeys = appleKeyCache{} + }) + return srv +} + +func appleIDToken(t *testing.T, key *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { + t.Helper() + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok.Header["kid"] = kid + signed, err := tok.SignedString(key) + if err != nil { + t.Fatalf("signing id_token: %v", err) + } + return signed +} + +func appleBaseClaims() jwt.MapClaims { + now := time.Now() + return jwt.MapClaims{ + "iss": appleIssuer, + "aud": appleTestClientID, + "sub": "001234.abcdef0123456789.0900", + "email": "user@privaterelay.appleid.com", + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + } +} + +// TestVerifyAppleIDToken is the property that matters most in this file: +// the identity comes from a signature-verified JWT, not a decoded one. +func TestVerifyAppleIDToken(t *testing.T) { + key, _ := pemForRSA(t) + appleJWKS(t, appleTestChildID, &key.PublicKey) + token := appleIDToken(t, key, appleTestChildID, appleBaseClaims()) + + sub, email, err := verifyAppleIDToken(t.Context(), token, appleTestClientID) + if err != nil { + t.Fatalf("verifyAppleIDToken: %v", err) + } + if sub != "001234.abcdef0123456789.0900" || email != "user@privaterelay.appleid.com" { + t.Fatalf("got sub=%q email=%q", sub, email) + } +} + +func TestVerifyAppleIDTokenRejects(t *testing.T) { + key, _ := pemForRSA(t) + appleJWKS(t, appleTestChildID, &key.PublicKey) + + other, _ := pemForRSA(t) + + cases := map[string]string{ + "wrong audience": appleIDToken(t, key, appleTestChildID, jwt.MapClaims{ + "iss": appleIssuer, "aud": "com.someone.else", "sub": "x", "email": "u@example.com", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + }), + "wrong issuer": appleIDToken(t, key, appleTestChildID, jwt.MapClaims{ + "iss": "https://evil.example", "aud": appleTestClientID, "sub": "x", "email": "u@example.com", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + }), + "expired": appleIDToken(t, key, appleTestChildID, jwt.MapClaims{ + "iss": appleIssuer, "aud": appleTestClientID, "sub": "x", "email": "u@example.com", + "iat": time.Now().Add(-2 * time.Hour).Unix(), "exp": time.Now().Add(-time.Hour).Unix(), + }), + "signed by the wrong key": appleIDToken(t, other, appleTestChildID, appleBaseClaims()), + "unknown key id": appleIDToken(t, key, "some-other-kid", appleBaseClaims()), + "no subject": appleIDToken(t, key, appleTestChildID, jwt.MapClaims{ + "iss": appleIssuer, "aud": appleTestClientID, "email": "u@example.com", + "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), + }), + } + + for name, token := range cases { + t.Run(name, func(t *testing.T) { + _, _, err := verifyAppleIDToken(t.Context(), token, appleTestClientID) + if !errors.Is(err, errOAuthIdentityVerificationFailed) { + t.Fatalf("err = %v, want errOAuthIdentityVerificationFailed", err) + } + }) + } +} + +// TestVerifyAppleIDTokenRejectsNoneAlg covers the classic JWT footgun: +// an unsigned token must never be accepted, whatever its claims say. +func TestVerifyAppleIDTokenRejectsNoneAlg(t *testing.T) { + key, _ := pemForRSA(t) + appleJWKS(t, appleTestChildID, &key.PublicKey) + + tok := jwt.NewWithClaims(jwt.SigningMethodNone, appleBaseClaims()) + unsigned, err := tok.SignedString(jwt.UnsafeAllowNoneSignatureType) + if err != nil { + t.Fatalf("building unsigned token: %v", err) + } + if _, _, err := verifyAppleIDToken(t.Context(), unsigned, appleTestClientID); !errors.Is(err, errOAuthIdentityVerificationFailed) { + t.Fatalf("err = %v, want errOAuthIdentityVerificationFailed", err) + } +} + +// TestAppleProviderRequiresFullConfig pins the gate: Apple's signing +// material is configuration in a way no other provider's is, so a +// half-configured Apple must read as unavailable rather than fail at the +// first login attempt. +func TestAppleProviderRequiresFullConfig(t *testing.T) { + full := config.Config{ + AppleClientID: appleTestClientID, + AppleTeamID: appleTestTeamID, + AppleKeyID: appleTestKeyID, + ApplePrivateKey: "-----BEGIN PRIVATE KEY-----\nAA==\n-----END PRIVATE KEY-----", + } + + enabled := &OAuthHandlers{Config: full} + if _, ok := enabled.provider("apple"); !ok { + t.Fatal("expected apple to be available when all four values are set") + } + + cases := map[string]config.Config{ + "no client id": {AppleTeamID: full.AppleTeamID, AppleKeyID: full.AppleKeyID, ApplePrivateKey: full.ApplePrivateKey}, + "no team id": {AppleClientID: full.AppleClientID, AppleKeyID: full.AppleKeyID, ApplePrivateKey: full.ApplePrivateKey}, + "no key id": {AppleClientID: full.AppleClientID, AppleTeamID: full.AppleTeamID, ApplePrivateKey: full.ApplePrivateKey}, + "no private key": {AppleClientID: full.AppleClientID, AppleTeamID: full.AppleTeamID, AppleKeyID: full.AppleKeyID}, + } + for name, cfg := range cases { + t.Run(name, func(t *testing.T) { + h := &OAuthHandlers{Config: cfg} + if _, ok := h.provider("apple"); ok { + t.Fatal("expected apple to be unavailable with incomplete config") + } + }) + } +} diff --git a/httpapi/auth_handlers.go b/httpapi/auth_handlers.go index 5f82af7..1411af0 100644 --- a/httpapi/auth_handlers.go +++ b/httpapi/auth_handlers.go @@ -53,6 +53,11 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) { tokens, err := cryden.Login(r.Context(), h.Engine, req.Email, req.Password, CallerIP(r), UserAgent(r)) if err != nil { + // An account with TOTP or a passkey enrolled pauses here instead + // of erroring — see second_factor.go. + if writeTokensOrPause(w, err) { + return + } writeErr(w, err) return } diff --git a/httpapi/errors.go b/httpapi/errors.go index 47a75b2..47a5be9 100644 --- a/httpapi/errors.go +++ b/httpapi/errors.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/crydensync/cryden/v2" "github.com/crydensync/cryden/v2/auth" "github.com/crydensync/cryden/v2/store" "github.com/crydensync/cryden/v2/token" @@ -48,6 +49,7 @@ var errOAuthProviderNotConfigured = errors.New("oauth provider not configured") var errOAuthStateMismatch = errors.New("oauth state parameter missing or mismatched") var errOAuthEmailNotAvailable = errors.New("oauth provider did not return a usable email address") var errOAuthLinkNotConfigured = errors.New("oauth linking is not available: server is missing a signing secret") +var errOAuthIdentityVerificationFailed = errors.New("could not verify the identity the provider returned") var errOAuthLinkSessionMissing = errors.New("oauth link session missing, expired, or tampered with — please retry") func mapError(err error) apiError { @@ -64,6 +66,8 @@ func mapError(err error) apiError { return apiError{http.StatusBadRequest, "oauth_state_mismatch", "oauth state parameter missing or mismatched — please retry the login"} case errors.Is(err, errOAuthEmailNotAvailable): return apiError{http.StatusBadRequest, "oauth_email_not_available", "could not retrieve a usable email address from the provider"} + case errors.Is(err, errOAuthIdentityVerificationFailed): + return apiError{http.StatusBadRequest, "oauth_identity_verification_failed", "could not verify the identity the provider returned — please try again"} case errors.Is(err, errOAuthLinkNotConfigured): return apiError{http.StatusInternalServerError, "oauth_link_not_configured", "oauth linking is not available on this deployment"} case errors.Is(err, errOAuthLinkSessionMissing): @@ -95,11 +99,48 @@ func mapError(err error) apiError { return apiError{http.StatusUnauthorized, "invalid_access_token", "access token is invalid or expired"} case errors.Is(err, auth.ErrOAuthIdentityAlreadyLinked): return apiError{http.StatusConflict, "oauth_identity_already_linked", "this provider account is already linked to a different user"} + case errors.Is(err, auth.ErrTOTPNotEnabled): + return apiError{http.StatusBadRequest, "totp_not_enabled", "TOTP is not enabled for this account"} + case errors.Is(err, auth.ErrTOTPAlreadyEnabled): + return apiError{http.StatusConflict, "totp_already_enabled", "TOTP is already enabled for this account"} + case errors.Is(err, auth.ErrInvalidTOTPCode): + return apiError{http.StatusUnauthorized, "invalid_totp_code", "that code is invalid or has expired"} + case errors.Is(err, auth.ErrInvalidPendingLogin): + return apiError{http.StatusUnauthorized, "invalid_pending_login", "this login attempt has expired — please log in again"} + case errors.Is(err, auth.ErrNoPasskeysEnrolled): + return apiError{http.StatusBadRequest, "no_passkeys_enrolled", "no passkeys are registered for this account"} + case errors.Is(err, auth.ErrInvalidWebAuthnResponse): + return apiError{http.StatusUnauthorized, "invalid_passkey_response", "the passkey response could not be verified — please try again"} + case errors.Is(err, auth.ErrInvalidCeremonyToken): + return apiError{http.StatusBadRequest, "invalid_ceremony_token", "this passkey ceremony has expired — please start again"} + case errors.Is(err, auth.ErrPasswordBreached): + return apiError{http.StatusBadRequest, "password_breached", "this password has appeared in a known data breach and cannot be used"} + // The four "not configured" sentinels below mean this deployment has + // not enabled that feature, not that the caller did anything wrong. + // 404 rather than 500 so a client can hide the option instead of + // reporting a server fault, matching oauth_provider_not_configured. + case errors.Is(err, cryden.ErrTOTPNotConfigured): + return apiError{http.StatusNotFound, "totp_not_configured", "TOTP is not enabled on this deployment"} + case errors.Is(err, cryden.ErrWebAuthnNotConfigured): + return apiError{http.StatusNotFound, "passkeys_not_configured", "passkeys are not enabled on this deployment"} + case errors.Is(err, cryden.ErrMagicLinkNotConfigured): + return apiError{http.StatusNotFound, "magic_link_not_configured", "magic-link login is not enabled on this deployment"} + case errors.Is(err, cryden.ErrRecoveryCodesNotConfigured): + return apiError{http.StatusNotFound, "recovery_codes_not_configured", "recovery codes are not enabled on this deployment"} default: // Struct-typed errors (not plain sentinels) need errors.As, // not errors.Is — ErrOAuthEmailConflict carries Email and // Provider that the client needs, so it can't just be a case // in the switch above like the sentinel errors. + // Struct-typed for the same reason as ErrOAuthEmailConflict + // below: it carries every violated rule at once (stable codes + // like "min_length"), and writeErr reads them back off the error + // itself — see its details handling, which is why nothing needs + // to be added to this table for them to reach the client. + var policy *auth.ErrPasswordPolicyViolation + if errors.As(err, &policy) { + return apiError{http.StatusBadRequest, "password_policy_violation", "password does not meet the required policy"} + } var conflict *auth.ErrOAuthEmailConflict if errors.As(err, &conflict) { return apiError{ diff --git a/httpapi/errors_test.go b/httpapi/errors_test.go new file mode 100644 index 0000000..dca0833 --- /dev/null +++ b/httpapi/errors_test.go @@ -0,0 +1,151 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/crydensync/cryden/v2" + "github.com/crydensync/cryden/v2/auth" +) + +// TestMapErrorSecondFactor covers the errors a client can actually act +// on programmatically — the codes here are the contract, so they are +// pinned rather than left to whatever the switch happens to say. +func TestMapErrorSecondFactor(t *testing.T) { + cases := []struct { + err error + wantStatus int + wantCode string + }{ + {auth.ErrTOTPNotEnabled, http.StatusBadRequest, "totp_not_enabled"}, + {auth.ErrTOTPAlreadyEnabled, http.StatusConflict, "totp_already_enabled"}, + {auth.ErrInvalidTOTPCode, http.StatusUnauthorized, "invalid_totp_code"}, + {auth.ErrInvalidPendingLogin, http.StatusUnauthorized, "invalid_pending_login"}, + {auth.ErrNoPasskeysEnrolled, http.StatusBadRequest, "no_passkeys_enrolled"}, + {auth.ErrInvalidWebAuthnResponse, http.StatusUnauthorized, "invalid_passkey_response"}, + {auth.ErrInvalidCeremonyToken, http.StatusBadRequest, "invalid_ceremony_token"}, + {cryden.ErrTOTPNotConfigured, http.StatusNotFound, "totp_not_configured"}, + {cryden.ErrWebAuthnNotConfigured, http.StatusNotFound, "passkeys_not_configured"}, + {cryden.ErrMagicLinkNotConfigured, http.StatusNotFound, "magic_link_not_configured"}, + {cryden.ErrRecoveryCodesNotConfigured, http.StatusNotFound, "recovery_codes_not_configured"}, + } + + for _, tc := range cases { + t.Run(tc.wantCode, func(t *testing.T) { + got := mapError(tc.err) + if got.Status != tc.wantStatus || got.Code != tc.wantCode { + t.Fatalf("mapError(%v) = %d/%s, want %d/%s", tc.err, got.Status, got.Code, tc.wantStatus, tc.wantCode) + } + }) + } +} + +// TestMapErrorWrappedStillMapped is the property that makes the single +// mapping site worth having: an error that travelled through a wrap +// still resolves to the same stable code. +func TestMapErrorWrappedStillMapped(t *testing.T) { + wrapped := fmt.Errorf("complete login: %w", auth.ErrInvalidTOTPCode) + if got := mapError(wrapped); got.Code != "invalid_totp_code" { + t.Fatalf("wrapped error mapped to %q, want invalid_totp_code", got.Code) + } +} + +// TestPauseForSecondFactor pins the paused-login response shape: a 200 +// (nothing failed) carrying the pending token and the enrolled methods, +// never a null array. +func TestPauseForSecondFactor(t *testing.T) { + rec := httptest.NewRecorder() + handled := writeTokensOrPause(rec, &auth.ErrSecondFactorRequired{PendingToken: "pending-123"}) + if !handled { + t.Fatal("expected the second-factor error to be handled") + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var body struct { + Data secondFactorDTO `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if !body.Data.SecondFactorRequired || body.Data.PendingToken != "pending-123" { + t.Fatalf("unexpected pause body: %+v", body.Data) + } + if body.Data.Methods == nil { + t.Fatal("methods must be an empty array, never null") + } +} + +// TestPauseForSecondFactorIgnoresOtherErrors makes sure an ordinary +// failure is left for writeErr rather than being swallowed as a pause. +func TestPauseForSecondFactorIgnoresOtherErrors(t *testing.T) { + rec := httptest.NewRecorder() + if writeTokensOrPause(rec, errors.New("boom")) { + t.Fatal("a non-second-factor error must not be handled as a pause") + } + if rec.Body.Len() != 0 { + t.Fatalf("expected no response body written, got %q", rec.Body.String()) + } +} + +// TestMapErrorPasswordPolicy covers the two password errors that used to +// fall through to a 500: a violating password is the caller's mistake to +// fix, not a server fault. +func TestMapErrorPasswordPolicy(t *testing.T) { + policyErr := &auth.ErrPasswordPolicyViolation{Violations: []string{"min_length", "require_digit"}} + for _, err := range []error{policyErr, fmt.Errorf("signup: %w", policyErr)} { + if got := mapError(err); got.Status != http.StatusBadRequest || got.Code != "password_policy_violation" { + t.Fatalf("mapError(%v) = %d/%s, want 400/password_policy_violation", err, got.Status, got.Code) + } + } + if got := mapError(auth.ErrPasswordBreached); got.Status != http.StatusBadRequest || got.Code != "password_breached" { + t.Fatalf("mapError(ErrPasswordBreached) = %d/%s, want 400/password_breached", got.Status, got.Code) + } +} + +// TestWriteErrPolicyDetails pins the one additive field in the error +// envelope: the violated rule codes, unchanged and machine-readable. +func TestWriteErrPolicyDetails(t *testing.T) { + rec := httptest.NewRecorder() + writeErr(rec, &auth.ErrPasswordPolicyViolation{Violations: []string{"min_length", "require_digit"}}) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + var body struct { + Error struct { + Code string `json:"code"` + Details []string `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decoding response: %v", err) + } + if body.Error.Code != "password_policy_violation" { + t.Fatalf("code = %q", body.Error.Code) + } + if len(body.Error.Details) != 2 || body.Error.Details[0] != "min_length" || body.Error.Details[1] != "require_digit" { + t.Fatalf("details = %v, want the engine's rule codes in order", body.Error.Details) + } +} + +// TestWriteErrOmitsDetailsWhenAbsent is the other half of that promise: +// every error that existed before details did stays byte-for-byte the +// same shape, so no existing client sees a new key. +func TestWriteErrOmitsDetailsWhenAbsent(t *testing.T) { + rec := httptest.NewRecorder() + writeErr(rec, auth.ErrInvalidCredentials) + + var raw map[string]map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("decoding response: %v", err) + } + if _, present := raw["error"]["details"]; present { + t.Fatalf("unexpected details key in %v", rec.Body.String()) + } +} diff --git a/httpapi/magiclink_handlers.go b/httpapi/magiclink_handlers.go new file mode 100644 index 0000000..df628df --- /dev/null +++ b/httpapi/magiclink_handlers.go @@ -0,0 +1,58 @@ +package httpapi + +import ( + "net/http" + + "github.com/crydensync/cryden/v2" +) + +type MagicLinkHandlers struct { + Engine *cryden.Engine +} + +// Request — no auth, and deliberately answers the same way whether or +// not the email belongs to an account: this does not create accounts, +// and anything else would let a caller enumerate registered emails one +// request at a time. cryden returns nil for an unknown address (nothing +// was sent) and only propagates a real delivery failure for an address +// that does exist. +func (h *MagicLinkHandlers) Request(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + if err := cryden.RequestMagicLink(r.Context(), h.Engine, req.Email, CallerIP(r)); err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{}) +} + +// Complete — no auth. The raw token from the emailed link is the proof +// of authorization. Like password login, this can pause for a second +// factor: an account with TOTP or a passkey enrolled gets the same +// second_factor_required response here as it would after a correct +// password. +func (h *MagicLinkHandlers) Complete(w http.ResponseWriter, r *http.Request) { + var req struct { + Token string `json:"token"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + tokens, err := cryden.CompleteMagicLink(r.Context(), h.Engine, req.Token, CallerIP(r), UserAgent(r)) + if err != nil { + if writeTokensOrPause(w, err) { + return + } + writeErr(w, err) + return + } + writeData(w, http.StatusOK, toTokensDTO(tokens)) +} diff --git a/httpapi/oauth_handlers.go b/httpapi/oauth_handlers.go index a20cd39..1e0ef93 100644 --- a/httpapi/oauth_handlers.go +++ b/httpapi/oauth_handlers.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/crydensync/cryden/v2" "github.com/crydensync/cryden/v2/auth" @@ -49,6 +50,15 @@ type oauthProvider struct { tokenURL string userInfoURL string scope string + // extraAuthParams are merged into the authorization redirect's query. + // Only Apple needs one (it is the only provider whose response mode + // this handler has to pin down); everyone else leaves it empty. + extraAuthParams url.Values + // Apple's signing material — empty for every other provider, which + // use a static clientSecret instead. See apple.go. + appleTeamID string + appleKeyID string + applePrivateKey string } type OAuthHandlers struct { @@ -84,11 +94,102 @@ func (h *OAuthHandlers) provider(name string) (oauthProvider, bool) { userInfoURL: "https://api.github.com/user", scope: "read:user user:email", }, true + case "microsoft": + if h.Config.MicrosoftClientID == "" || h.Config.MicrosoftClientSecret == "" { + return oauthProvider{}, false + } + return oauthProvider{ + name: "microsoft", + clientID: h.Config.MicrosoftClientID, + clientSecret: h.Config.MicrosoftClientSecret, + authURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + tokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + userInfoURL: "https://graph.microsoft.com/v1.0/me", + // "common" rather than a single tenant so personal accounts + // and any org's work accounts both work without per-tenant + // configuration. User.Read is what makes the access token + // audience-valid for the Graph /me call below. + scope: "openid email profile User.Read", + }, true + case "discord": + if h.Config.DiscordClientID == "" || h.Config.DiscordClientSecret == "" { + return oauthProvider{}, false + } + return oauthProvider{ + name: "discord", + clientID: h.Config.DiscordClientID, + clientSecret: h.Config.DiscordClientSecret, + authURL: "https://discord.com/oauth2/authorize", + tokenURL: "https://discord.com/api/oauth2/token", + userInfoURL: "https://discord.com/api/users/@me", + scope: "identify email", + }, true + case "gitlab": + if h.Config.GitLabClientID == "" || h.Config.GitLabClientSecret == "" { + return oauthProvider{}, false + } + return oauthProvider{ + name: "gitlab", + clientID: h.Config.GitLabClientID, + clientSecret: h.Config.GitLabClientSecret, + authURL: "https://gitlab.com/oauth/authorize", + tokenURL: "https://gitlab.com/oauth/token", + userInfoURL: "https://gitlab.com/api/v4/user", + scope: "read_user", + }, true + case "apple": + // All four values are required: Apple's "client secret" is a JWT + // this repo signs with AppleKeyID/ApplePrivateKey for the team + // named by AppleTeamID, so a half-configured Apple is not a + // provider that half works, it is one that cannot sign at all. + if h.Config.AppleClientID == "" || h.Config.AppleTeamID == "" || h.Config.AppleKeyID == "" || h.Config.ApplePrivateKey == "" { + return oauthProvider{}, false + } + return oauthProvider{ + name: "apple", + clientID: h.Config.AppleClientID, + // Deliberately no clientSecret — see apple.go's + // appleClientSecret, which signs a fresh one per exchange. + authURL: appleIssuer + "/auth/authorize", + tokenURL: appleIssuer + "/auth/token", + // Apple has no userinfo endpoint: the identity arrives in the + // token response's signed id_token instead (see + // exchangeAndFetchAppleIdentity). + userInfoURL: "", + scope: "name email", + // query, not form_post: this handler's callback is a GET + // redirect, and query mode keeps that route unchanged. The + // name/email Apple only ever sends on a first authorization + // arrives in the `user` form field under form_post, which + // this repo does not need — it stores the id_token's email. + extraAuthParams: url.Values{"response_mode": {"query"}}, + appleTeamID: h.Config.AppleTeamID, + appleKeyID: h.Config.AppleKeyID, + applePrivateKey: h.Config.ApplePrivateKey, + }, true default: return oauthProvider{}, false } } +// authQuery builds the authorization redirect's query. Kept as one +// function rather than inline in both flows so a provider-specific +// parameter (see oauthProvider.extraAuthParams) cannot be added to one +// flow and forgotten in the other. +func authQuery(p oauthProvider, redirectURI, state string) url.Values { + q := url.Values{ + "client_id": {p.clientID}, + "redirect_uri": {redirectURI}, + "response_type": {"code"}, + "scope": {p.scope}, + "state": {state}, + } + for k, values := range p.extraAuthParams { + q[k] = values + } + return q +} + func (h *OAuthHandlers) callbackURL(providerName string) string { return h.Config.BaseURL + "/v1/oauth/" + providerName + "/callback" } @@ -118,14 +219,7 @@ func (h *OAuthHandlers) Start(w http.ResponseWriter, r *http.Request, providerNa MaxAge: 600, // 10 minutes — plenty for a consent-screen round trip }) - q := url.Values{ - "client_id": {p.clientID}, - "redirect_uri": {h.callbackURL(p.name)}, - "response_type": {"code"}, - "scope": {p.scope}, - "state": {state}, - } - http.Redirect(w, r, p.authURL+"?"+q.Encode(), http.StatusFound) + http.Redirect(w, r, p.authURL+"?"+authQuery(p, h.callbackURL(p.name), state).Encode(), http.StatusFound) } // Callback receives the provider's redirect, exchanges the code, @@ -161,6 +255,12 @@ func (h *OAuthHandlers) Callback(w http.ResponseWriter, r *http.Request, provide tokens, err := cryden.LoginWithOAuth(r.Context(), h.Engine, p.name, externalID, email, CallerIP(r), UserAgent(r)) if err != nil { + // An account with a second factor enrolled pauses here too — an + // OAuth login is still a login, so it goes through the same gate + // and reports the pause the same way (see second_factor.go). + if writeTokensOrPause(w, err) { + return + } var conflict *auth.ErrOAuthEmailConflict if errors.As(err, &conflict) { // The confirmed decision: never auto-link. Surface this @@ -218,14 +318,7 @@ func (h *OAuthHandlers) LinkStart(w http.ResponseWriter, r *http.Request, provid MaxAge: 600, }) - q := url.Values{ - "client_id": {p.clientID}, - "redirect_uri": {h.linkCallbackURL(p.name)}, - "response_type": {"code"}, - "scope": {p.scope}, - "state": {state}, - } - http.Redirect(w, r, p.authURL+"?"+q.Encode(), http.StatusFound) + http.Redirect(w, r, p.authURL+"?"+authQuery(p, h.linkCallbackURL(p.name), state).Encode(), http.StatusFound) } // LinkCallback receives the provider's redirect for the linking flow. @@ -414,7 +507,14 @@ func clearStateCookie(w http.ResponseWriter) { // above it is either request-shaped (redirect/state) or calls into // the engine. func exchangeAndFetchIdentity(r *http.Request, p oauthProvider, redirectURI, code string) (externalID, email string, err error) { - tokenResp, err := exchangeCode(r, p, redirectURI, code) + // Apple is the one provider that does not follow the others' shape: + // its client secret is signed per exchange, and there is no userinfo + // call to make afterwards — the identity is in the token response. + if p.name == "apple" { + return exchangeAndFetchAppleIdentity(r, p, redirectURI, code) + } + + tokenResp, err := exchangeCode(r, p, redirectURI, code, p.clientSecret) if err != nil { return "", "", err } @@ -423,7 +523,7 @@ func exchangeAndFetchIdentity(r *http.Request, p oauthProvider, redirectURI, cod if err != nil { return "", "", err } - req.Header.Set("Authorization", "Bearer "+tokenResp) + req.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", err @@ -461,57 +561,146 @@ func exchangeAndFetchIdentity(r *http.Request, p oauthProvider, redirectURI, cod // address instead comes from /user/emails, which needs // the same token and the same scope this handler already // requests (user:email). - email, err := fetchGitHubPrimaryEmail(r, tokenResp) + email, err := fetchGitHubPrimaryEmail(r, tokenResp.AccessToken) if err != nil { return "", "", err } return fmt.Sprintf("%d", info.ID), email, nil } return fmt.Sprintf("%d", info.ID), info.Email, nil + case "microsoft": + var info struct { + ID string `json:"id"` + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` + } + if err := json.Unmarshal(body, &info); err != nil { + return "", "", err + } + // mail is the real address but is null for many personal + // accounts; userPrincipalName is the fallback Microsoft + // itself documents for exactly that case. + email := info.Mail + if email == "" { + email = info.UserPrincipalName + } + if email == "" { + return "", "", errOAuthEmailNotAvailable + } + return info.ID, email, nil + case "discord": + var info struct { + ID string `json:"id"` + Email string `json:"email"` + } + if err := json.Unmarshal(body, &info); err != nil { + return "", "", err + } + if info.Email == "" { + return "", "", errOAuthEmailNotAvailable + } + return info.ID, info.Email, nil + case "gitlab": + var info struct { + ID int64 `json:"id"` + Email string `json:"email"` + } + if err := json.Unmarshal(body, &info); err != nil { + return "", "", err + } + if info.Email == "" { + // /api/v4/user only returns the primary address because + // this handler asks for read_user; anything else means the + // account has no usable address, not that we should invent + // one from the username. + return "", "", errOAuthEmailNotAvailable + } + return fmt.Sprintf("%d", info.ID), info.Email, nil default: return "", "", errOAuthProviderNotConfigured } } +// exchangeAndFetchAppleIdentity is Apple's version of the step above. +// Apple is the only provider whose client secret is not a static string +// (it is an ES256 JWT signed here) and the only one with no userinfo +// endpoint (the id_token in the token response carries the identity, and +// has to be verified against Apple's signing keys rather than decoded — +// see verifyAppleIDToken). +func exchangeAndFetchAppleIdentity(r *http.Request, p oauthProvider, redirectURI, code string) (externalID, email string, err error) { + secret, err := appleClientSecret(p) + if err != nil { + return "", "", err + } + + tokenResp, err := exchangeCode(r, p, redirectURI, code, secret) + if err != nil { + return "", "", err + } + if tokenResp.IDToken == "" { + return "", "", errOAuthIdentityVerificationFailed + } + + sub, email, err := verifyAppleIDToken(r.Context(), tokenResp.IDToken, p.clientID) + if err != nil { + return "", "", err + } + if email == "" { + return "", "", errOAuthEmailNotAvailable + } + return sub, email, nil +} + +// oauthTokenResponse is what a provider's token endpoint gives back. +// Only these two fields are ever read: the access token for the one +// immediate userinfo call, and Apple's id_token. Nothing here is stored +// — this API keeps no provider tokens. +type oauthTokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` +} + // exchangeCode trades the authorization code for a provider access -// token. Returns just the token string — this handler only ever needs -// it to make the one immediate userinfo call, never stores it. -func exchangeCode(r *http.Request, p oauthProvider, redirectURI, code string) (string, error) { +// token. clientSecret is passed in rather than read off p because Apple +// derives one per exchange; every other provider passes p.clientSecret. +func exchangeCode(r *http.Request, p oauthProvider, redirectURI, code, clientSecret string) (oauthTokenResponse, error) { form := url.Values{ "client_id": {p.clientID}, - "client_secret": {p.clientSecret}, + "client_secret": {clientSecret}, "code": {code}, "redirect_uri": {redirectURI}, "grant_type": {"authorization_code"}, } - req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, p.tokenURL, nil) + // RFC 6749 §4.1.3 puts these parameters in the POST body, and that + // is what Microsoft and Discord require — query parameters are not + // accepted there. Google and GitHub accept the body form too, so + // there is one code path rather than a per-provider branch. + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, p.tokenURL, strings.NewReader(form.Encode())) if err != nil { - return "", err + return oauthTokenResponse{}, err } - req.URL.RawQuery = form.Encode() // both providers accept this as query or form body; query keeps this dependency-free + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { - return "", err + return oauthTokenResponse{}, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return oauthTokenResponse{}, err } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("httpapi: oauth token exchange failed: status %d", resp.StatusCode) + return oauthTokenResponse{}, fmt.Errorf("httpapi: oauth token exchange failed: status %d", resp.StatusCode) } - var tokenResp struct { - AccessToken string `json:"access_token"` - } + var tokenResp oauthTokenResponse if err := json.Unmarshal(body, &tokenResp); err != nil { - return "", err + return oauthTokenResponse{}, err } if tokenResp.AccessToken == "" { - return "", fmt.Errorf("httpapi: oauth token exchange returned no access_token") + return oauthTokenResponse{}, fmt.Errorf("httpapi: oauth token exchange returned no access_token") } - return tokenResp.AccessToken, nil + return tokenResp, nil } diff --git a/httpapi/passkey_handlers.go b/httpapi/passkey_handlers.go new file mode 100644 index 0000000..fbcda63 --- /dev/null +++ b/httpapi/passkey_handlers.go @@ -0,0 +1,168 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/crydensync/cryden/v2" +) + +type PasskeyHandlers struct { + Engine *cryden.Engine +} + +// passkeyDTO keeps cryden's storage detail out of the API response and +// the field names in this repo's snake_case convention. CredentialID is +// base64url-encoded by cryden itself, matching how credential IDs travel +// in the WebAuthn spec — pass it back verbatim to delete. +type passkeyDTO struct { + CredentialID string `json:"credential_id"` + Nickname string `json:"nickname"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` +} + +// RegisterBegin — auth required. Returns the ceremony options to hand to +// navigator.credentials.create() and the ceremony token that must come +// back to RegisterFinish unmodified. The options are emitted as raw JSON +// so the client receives them as an object, not a JSON-encoded string it +// would have to parse twice. +func (h *PasskeyHandlers) RegisterBegin(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromContext(r) + optionsJSON, ceremonyToken, err := cryden.BeginRegisterPasskey(r.Context(), h.Engine, userID) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]any{ + "public_key": json.RawMessage(optionsJSON), + "ceremony_token": ceremonyToken, + }) +} + +// RegisterFinish — auth required. `credential` is the raw JSON the +// browser produced in navigator.credentials.create(); it is passed +// through to the engine unmodified (any shape checking the engine needs +// to do, it does itself). +func (h *PasskeyHandlers) RegisterFinish(w http.ResponseWriter, r *http.Request) { + var req struct { + CeremonyToken string `json:"ceremony_token"` + Credential json.RawMessage `json:"credential"` + Nickname string `json:"nickname"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + if req.CeremonyToken == "" { + writeBadRequest(w, "ceremony_token is required") + return + } + if len(req.Credential) == 0 { + writeBadRequest(w, "credential is required") + return + } + + userID := UserIDFromContext(r) + if err := cryden.FinishRegisterPasskey(r.Context(), h.Engine, userID, req.CeremonyToken, req.Credential, req.Nickname); err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "passkey registered"}) +} + +// List — auth required. +func (h *PasskeyHandlers) List(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromContext(r) + passkeys, err := cryden.ListPasskeys(r.Context(), h.Engine, userID) + if err != nil { + writeErr(w, err) + return + } + + out := make([]passkeyDTO, 0, len(passkeys)) + for _, p := range passkeys { + dto := passkeyDTO{ + CredentialID: p.CredentialID, + Nickname: p.Nickname, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + } + if p.LastUsedAt != nil { + dto.LastUsedAt = p.LastUsedAt.Format(time.RFC3339) + } + out = append(out, dto) + } + writeData(w, http.StatusOK, out) +} + +// Delete — auth required. credentialID comes from the URL path (wired in +// router.go); the current password is re-confirmation, same reasoning as +// DisableTOTP. +func (h *PasskeyHandlers) Delete(w http.ResponseWriter, r *http.Request, credentialID string) { + var req struct { + Password string `json:"password"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + userID := UserIDFromContext(r) + if err := cryden.DeletePasskey(r.Context(), h.Engine, userID, credentialID, req.Password); err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "passkey removed"}) +} + +// LoginBegin — no auth. Starts the passkey half of a login that +// /v1/login paused, using the pending token from that response. +func (h *PasskeyHandlers) LoginBegin(w http.ResponseWriter, r *http.Request) { + var req struct { + PendingToken string `json:"pending_token"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + optionsJSON, ceremonyToken, err := cryden.BeginWebAuthnLogin(r.Context(), h.Engine, req.PendingToken) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]any{ + "public_key": json.RawMessage(optionsJSON), + "ceremony_token": ceremonyToken, + }) +} + +// LoginFinish — no auth. `credential` is the raw JSON the browser +// produced in navigator.credentials.get(). +func (h *PasskeyHandlers) LoginFinish(w http.ResponseWriter, r *http.Request) { + var req struct { + PendingToken string `json:"pending_token"` + CeremonyToken string `json:"ceremony_token"` + Credential json.RawMessage `json:"credential"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + if req.PendingToken == "" || req.CeremonyToken == "" { + writeBadRequest(w, "pending_token and ceremony_token are required") + return + } + if len(req.Credential) == 0 { + writeBadRequest(w, "credential is required") + return + } + + tokens, err := cryden.CompleteLoginWithWebAuthn(r.Context(), h.Engine, req.PendingToken, req.CeremonyToken, req.Credential, CallerIP(r), UserAgent(r)) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, toTokensDTO(tokens)) +} diff --git a/httpapi/recovery_handlers.go b/httpapi/recovery_handlers.go new file mode 100644 index 0000000..de165f2 --- /dev/null +++ b/httpapi/recovery_handlers.go @@ -0,0 +1,56 @@ +package httpapi + +import ( + "net/http" + + "github.com/crydensync/cryden/v2" +) + +type RecoveryHandlers struct { + Engine *cryden.Engine +} + +// recoveryCodesNotice is echoed in the response body because the raw +// codes below exist in exactly one place — this response. cryden stores +// only their hashes and can never show them again. +const recoveryCodesNotice = "these codes are shown only once and each one works a single time — store them somewhere safe now, they cannot be retrieved later" + +// Generate — auth required. Replaces any existing batch. Requires a +// confirmed TOTP secret or a registered passkey already on the account +// (there is nothing for a fallback code to fall back to otherwise). +func (h *RecoveryHandlers) Generate(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromContext(r) + codes, err := cryden.GenerateRecoveryCodes(r.Context(), h.Engine, userID) + if err != nil { + writeErr(w, err) + return + } + if codes == nil { + codes = []string{} + } + writeData(w, http.StatusOK, map[string]any{ + "codes": codes, + "notice": recoveryCodesNotice, + }) +} + +// Login — no auth. Completes a login that /v1/login (or a magic-link or +// OAuth login) paused with a second_factor_required response, using one +// of the account's recovery codes instead of TOTP or a passkey. +func (h *RecoveryHandlers) Login(w http.ResponseWriter, r *http.Request) { + var req struct { + PendingToken string `json:"pending_token"` + Code string `json:"code"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + tokens, err := cryden.CompleteLoginWithRecoveryCode(r.Context(), h.Engine, req.PendingToken, req.Code, CallerIP(r), UserAgent(r)) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, toTokensDTO(tokens)) +} diff --git a/httpapi/response.go b/httpapi/response.go index 67e3524..86308f7 100644 --- a/httpapi/response.go +++ b/httpapi/response.go @@ -2,8 +2,11 @@ package httpapi import ( "encoding/json" + "errors" "log" "net/http" + + "github.com/crydensync/cryden/v2/auth" ) func writeData(w http.ResponseWriter, status int, data any) { @@ -20,11 +23,19 @@ func writeErr(w http.ResponseWriter, err error) { if apiErr.Status == http.StatusInternalServerError { log.Printf("internal error: %v", err) } + errBody := map[string]any{"code": apiErr.Code, "message": apiErr.Message} + // A password-policy violation carries every broken rule as stable + // strings, deliberately so a client can render them together instead + // of parsing prose. Read off the error rather than threaded through + // mapError's (status, code, message) triple, which every other error + // still fits exactly as before. + var policy *auth.ErrPasswordPolicyViolation + if errors.As(err, &policy) && len(policy.Violations) > 0 { + errBody["details"] = policy.Violations + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(apiErr.Status) - json.NewEncoder(w).Encode(map[string]any{ - "error": map[string]string{"code": apiErr.Code, "message": apiErr.Message}, - }) + json.NewEncoder(w).Encode(map[string]any{"error": errBody}) } // writeBadRequest is for request-parsing failures — these never come diff --git a/httpapi/router.go b/httpapi/router.go index 1a23d1f..f78da72 100644 --- a/httpapi/router.go +++ b/httpapi/router.go @@ -17,6 +17,10 @@ func NewRouter(engine *cryden.Engine, db *sql.DB, cfg config.Config) http.Handle email := &EmailHandlers{Engine: engine} health := &HealthHandler{DB: db} oauth := &OAuthHandlers{Engine: engine, Config: cfg} + totp := &TOTPHandlers{Engine: engine} + passkeys := &PasskeyHandlers{Engine: engine} + magicLink := &MagicLinkHandlers{Engine: engine} + recovery := &RecoveryHandlers{Engine: engine} mux := http.NewServeMux() @@ -27,6 +31,17 @@ func NewRouter(engine *cryden.Engine, db *sql.DB, cfg config.Config) http.Handle mux.HandleFunc("POST /v1/email/confirm-change", email.ConfirmChange) mux.HandleFunc("GET /v1/health", health.Health) + // Second-factor login completion — public for the same reason + // /v1/login is: this IS how the caller gets authenticated. Each of + // these takes the pending_token from a login that paused with a + // second_factor_required response. + mux.HandleFunc("POST /v1/login/totp", totp.Login) + mux.HandleFunc("POST /v1/login/passkey/begin", passkeys.LoginBegin) + mux.HandleFunc("POST /v1/login/passkey/finish", passkeys.LoginFinish) + mux.HandleFunc("POST /v1/login/recovery-code", recovery.Login) + mux.HandleFunc("POST /v1/magic-link/request", magicLink.Request) + mux.HandleFunc("POST /v1/magic-link/complete", magicLink.Complete) + // OAuth — Start and Callback are public (they're the login/signup // path itself, same as /v1/login). Link requires auth since it // attaches an identity to an already-authenticated user. @@ -62,5 +77,24 @@ func NewRouter(engine *cryden.Engine, db *sql.DB, cfg config.Config) http.Handle mux.HandleFunc("POST /v1/email/request-change", RequireAuth(engine, email.RequestChange)) + // Second-factor enrollment and management. TOTP and passkeys are + // optional per deployment (see main.go's ENCRYPTION_KEY gate): an + // unconfigured one answers 404 from mapError, the same shape an + // unconfigured OAuth provider already uses — the routes exist either + // way, so a client never has to discover availability from a routing + // table it can't see. + mux.HandleFunc("POST /v1/totp/enroll", RequireAuth(engine, totp.Enroll)) + mux.HandleFunc("POST /v1/totp/confirm", RequireAuth(engine, totp.Confirm)) + mux.HandleFunc("POST /v1/totp/disable", RequireAuth(engine, totp.Disable)) + + mux.HandleFunc("POST /v1/passkeys/register/begin", RequireAuth(engine, passkeys.RegisterBegin)) + mux.HandleFunc("POST /v1/passkeys/register/finish", RequireAuth(engine, passkeys.RegisterFinish)) + mux.HandleFunc("GET /v1/passkeys", RequireAuth(engine, passkeys.List)) + mux.HandleFunc("DELETE /v1/passkeys/{credentialID}", RequireAuth(engine, func(w http.ResponseWriter, r *http.Request) { + passkeys.Delete(w, r, r.PathValue("credentialID")) + })) + + mux.HandleFunc("POST /v1/recovery-codes/generate", RequireAuth(engine, recovery.Generate)) + return mux } diff --git a/httpapi/second_factor.go b/httpapi/second_factor.go new file mode 100644 index 0000000..2eba9e9 --- /dev/null +++ b/httpapi/second_factor.go @@ -0,0 +1,45 @@ +package httpapi + +import ( + "errors" + "net/http" + + "github.com/crydensync/cryden/v2/auth" +) + +// secondFactorDTO is the shape returned when a correct password (or a +// magic-link token) is not on its own enough to finish a login. It is a +// 200, not an error: nothing failed, the caller just has one more step +// to complete. `pending_token` proves only "this caller already +// supplied a correct first factor" — it is not an access or refresh +// token and does nothing on any other endpoint. +type secondFactorDTO struct { + SecondFactorRequired bool `json:"second_factor_required"` + PendingToken string `json:"pending_token"` + Methods []string `json:"methods"` +} + +// writeTokensOrPause is the single place the paused-login case is +// resolved. Every login-shaped endpoint (password, OAuth callback, +// magic link) can pause for a second factor — cryden reports that with +// *auth.ErrSecondFactorRequired rather than by returning tokens — so +// handling it once here keeps those handlers from each inventing their +// own response shape. Returns true if it wrote a response. +func writeTokensOrPause(w http.ResponseWriter, err error) bool { + var secondFactor *auth.ErrSecondFactorRequired + if errors.As(err, &secondFactor) { + methods := secondFactor.Methods + if methods == nil { + // Never emit `null` for a list — an empty array is what a + // client can iterate without a nil check. + methods = []string{} + } + writeData(w, http.StatusOK, secondFactorDTO{ + SecondFactorRequired: true, + PendingToken: secondFactor.PendingToken, + Methods: methods, + }) + return true + } + return false +} diff --git a/httpapi/totp_handlers.go b/httpapi/totp_handlers.go new file mode 100644 index 0000000..507739f --- /dev/null +++ b/httpapi/totp_handlers.go @@ -0,0 +1,85 @@ +package httpapi + +import ( + "net/http" + + "github.com/crydensync/cryden/v2" +) + +type TOTPHandlers struct { + Engine *cryden.Engine +} + +// Enroll — auth required. Starts TOTP enrollment and returns the +// otpauth:// URL for the client to render as a QR code. The secret does +// not gate login until Confirm succeeds. +func (h *TOTPHandlers) Enroll(w http.ResponseWriter, r *http.Request) { + userID := UserIDFromContext(r) + otpauthURL, err := cryden.EnrollTOTP(r.Context(), h.Engine, userID) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{"otpauth_url": otpauthURL}) +} + +// Confirm — auth required. Activates a pending enrollment once the user +// proves they captured the secret by submitting one valid code. +func (h *TOTPHandlers) Confirm(w http.ResponseWriter, r *http.Request) { + var req struct { + Code string `json:"code"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + userID := UserIDFromContext(r) + if err := cryden.ConfirmTOTP(r.Context(), h.Engine, userID, req.Code); err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "totp confirmed"}) +} + +// Disable — auth required. Requires the current password as +// re-confirmation, same reasoning as change-password and delete-account: +// a stolen access token alone should not be able to weaken an account's +// own auth requirements. +func (h *TOTPHandlers) Disable(w http.ResponseWriter, r *http.Request) { + var req struct { + Password string `json:"password"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + userID := UserIDFromContext(r) + if err := cryden.DisableTOTP(r.Context(), h.Engine, userID, req.Password); err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "totp disabled"}) +} + +// Login — no auth. Completes a login /v1/login paused with a +// second_factor_required response, using the pending token and a code +// from the user's authenticator app. +func (h *TOTPHandlers) Login(w http.ResponseWriter, r *http.Request) { + var req struct { + PendingToken string `json:"pending_token"` + Code string `json:"code"` + } + if err := decodeJSON(r, &req); err != nil { + writeBadRequest(w, "invalid request body") + return + } + + tokens, err := cryden.CompleteLoginWithTOTP(r.Context(), h.Engine, req.PendingToken, req.Code, CallerIP(r), UserAgent(r)) + if err != nil { + writeErr(w, err) + return + } + writeData(w, http.StatusOK, toTokensDTO(tokens)) +} diff --git a/main.go b/main.go index e820d42..35c9f5e 100644 --- a/main.go +++ b/main.go @@ -34,15 +34,16 @@ func main() { operators := operator.NewStore(db) - engine, err := cryden.New(cryden.Config{ - JWTSecret: cfg.JWTSecret, - Users: postgres.NewUserStore(db), - Sessions: postgres.NewSessionStore(db), - Audit: postgres.NewAuditStore(db), - Verifications: postgres.NewVerificationStore(db), - EmailSender: &consoleEmailSender{}, // dev stand-in — see email_sender.go - AccessTokenTTL: cfg.AccessTokenTTL, - OAuth: postgres.NewOAuthStore(db), + engineCfg := cryden.Config{ + JWTSecret: cfg.JWTSecret, + Users: postgres.NewUserStore(db), + Sessions: postgres.NewSessionStore(db), + Audit: postgres.NewAuditStore(db), + Verifications: postgres.NewVerificationStore(db), + EmailSender: &consoleEmailSender{}, // dev stand-in — see email_sender.go + MagicLinkSender: &consoleMagicLinkSender{BaseURL: cfg.BaseURL}, // dev stand-in — see email_sender.go + AccessTokenTTL: cfg.AccessTokenTTL, + OAuth: postgres.NewOAuthStore(db), // Attaches a "role" claim for console operators only — an // ordinary end user's token gets no extra claims at all, not @@ -58,7 +59,37 @@ func main() { } return map[string]any{"role": role}, nil }), - }) + } + + // Second factors are all-or-nothing on ENCRYPTION_KEY: cryden refuses + // to build an engine with a TOTP or WebAuthn store set and no + // encryption key, and a half-configured deployment would be worse than + // one that simply reports those methods as unavailable. Unavailable is + // reported per-request (404, same shape as an unconfigured OAuth + // provider), never as a refusal to start. + if cfg.EncryptionKey != "" { + engineCfg.EncryptionKey = cfg.EncryptionKey + engineCfg.TOTPIssuerName = cfg.TOTPIssuerName + engineCfg.TOTP = postgres.NewTOTPStore(db) + // Recovery codes are a fallback for whichever second factor is + // enrolled, so they are only wired in when one can exist. + engineCfg.RecoveryCodes = postgres.NewRecoveryCodeStore(db) + + if cfg.WebAuthnRPID != "" && cfg.WebAuthnRPDisplayName != "" && len(cfg.WebAuthnRPOrigins) > 0 { + engineCfg.WebAuthn = postgres.NewWebAuthnStore(db) + engineCfg.WebAuthnRPID = cfg.WebAuthnRPID + engineCfg.WebAuthnRPDisplayName = cfg.WebAuthnRPDisplayName + engineCfg.WebAuthnRPOrigins = cfg.WebAuthnRPOrigins + } else if cfg.WebAuthnRPID != "" || cfg.WebAuthnRPDisplayName != "" || len(cfg.WebAuthnRPOrigins) > 0 { + // Partial WebAuthn config is a deployment mistake worth + // saying out loud: passkeys stay off until all three are + // set, rather than half-working in a way that only ever + // shows up as a ceremony failure in the browser. + log.Printf("WARNING: WebAuthn disabled — passkeys need WEBAUTHN_RP_ID, WEBAUTHN_RP_DISPLAY_NAME and WEBAUTHN_RP_ORIGINS all set") + } + } + + engine, err := cryden.New(engineCfg) if err != nil { log.Fatalf("failed to construct cryden engine: %v", err) } diff --git a/migrations/004_totp_secrets.down.sql b/migrations/004_totp_secrets.down.sql new file mode 100644 index 0000000..b605e3a --- /dev/null +++ b/migrations/004_totp_secrets.down.sql @@ -0,0 +1,3 @@ +-- 0003_totp_secrets.down.sql + +DROP TABLE totp_secrets; diff --git a/migrations/004_totp_secrets.up.sql b/migrations/004_totp_secrets.up.sql new file mode 100644 index 0000000..ce10fff --- /dev/null +++ b/migrations/004_totp_secrets.up.sql @@ -0,0 +1,13 @@ +-- 0003_totp_secrets.up.sql + +CREATE TABLE totp_secrets ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + -- Encrypted (AES-256-GCM), never plaintext, never hashed — the + -- engine must recover the original secret to validate a code + -- against it, so hashing (as used for passwords) doesn't apply. + encrypted_secret TEXT NOT NULL, + -- NULL until the user proves possession with one valid code. + -- An unconfirmed secret must never gate a login. + confirmed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/migrations/005_webauthn_credentials.down.sql b/migrations/005_webauthn_credentials.down.sql new file mode 100644 index 0000000..1ad3565 --- /dev/null +++ b/migrations/005_webauthn_credentials.down.sql @@ -0,0 +1,3 @@ +-- 0004_webauthn_credentials.down.sql + +DROP TABLE webauthn_credentials; diff --git a/migrations/005_webauthn_credentials.up.sql b/migrations/005_webauthn_credentials.up.sql new file mode 100644 index 0000000..8821a98 --- /dev/null +++ b/migrations/005_webauthn_credentials.up.sql @@ -0,0 +1,22 @@ +-- 0004_webauthn_credentials.up.sql + +CREATE TABLE webauthn_credentials ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Denormalized out of credential_data purely so it's indexable — + -- matching a credential during login, excluding it during + -- re-registration, without deserializing every row first. + credential_id BYTEA NOT NULL, + -- JSON-marshaled webauthn.Credential from the go-webauthn library, + -- stored as a blob rather than decomposed into columns — that + -- struct gains fields as the library evolves, and a blob avoids + -- this schema drifting out of sync with it. + credential_data JSONB NOT NULL, + -- User-supplied label ("MacBook Touch ID"), purely presentational. + nickname TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ, + UNIQUE (credential_id) +); + +CREATE INDEX idx_webauthn_credentials_user_id ON webauthn_credentials(user_id); diff --git a/migrations/006_recovery_codes.down.sql b/migrations/006_recovery_codes.down.sql new file mode 100644 index 0000000..4113379 --- /dev/null +++ b/migrations/006_recovery_codes.down.sql @@ -0,0 +1,3 @@ +-- 0005_recovery_codes.down.sql + +DROP TABLE recovery_codes; diff --git a/migrations/006_recovery_codes.up.sql b/migrations/006_recovery_codes.up.sql new file mode 100644 index 0000000..bce66e6 --- /dev/null +++ b/migrations/006_recovery_codes.up.sql @@ -0,0 +1,16 @@ +-- 0005_recovery_codes.up.sql + +CREATE TABLE recovery_codes ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- SHA-256, not bcrypt — a recovery code is a high-entropy random + -- value generated by the engine, not a user-chosen secret, so + -- there's no weak-guessing risk a slow hash would defend against. + -- Globally unique on its own (random, high-entropy), so it's the + -- primary key directly rather than introducing a separate id + -- column just to have one. + code_hash TEXT PRIMARY KEY, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_recovery_codes_user_id ON recovery_codes(user_id); diff --git a/migrations/007_login_attempts.down.sql b/migrations/007_login_attempts.down.sql new file mode 100644 index 0000000..322993e --- /dev/null +++ b/migrations/007_login_attempts.down.sql @@ -0,0 +1,3 @@ +-- 0006_login_attempts.down.sql + +DROP TABLE login_attempts; diff --git a/migrations/007_login_attempts.up.sql b/migrations/007_login_attempts.up.sql new file mode 100644 index 0000000..f0fbcf5 --- /dev/null +++ b/migrations/007_login_attempts.up.sql @@ -0,0 +1,36 @@ +-- 0006_login_attempts.up.sql + +CREATE TABLE login_attempts ( + id UUID PRIMARY KEY, + -- Nullable, and ON DELETE SET NULL rather than CASCADE: a deleted + -- account's attempt rows still carry real evidence about the IP + -- that targeted it, which is exactly what per-IP velocity needs. + -- Matching audit_events, not sessions/recovery_codes. + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL CHECK (outcome IN ('success', 'failure')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Every read of this table is an aggregate over a time window, never a +-- full scan for a human to page through — that difference from +-- audit_events is the entire reason this table exists separately, so +-- the indexes are what justify it. + +-- Per-user failure velocity (CountFailuresForUser). +CREATE INDEX idx_login_attempts_user_failures + ON login_attempts(user_id, created_at DESC) + WHERE outcome = 'failure'; + +-- Per-IP failure velocity (CountFailuresForIP), counted across every +-- account one IP targeted, including unknown-email attempts where +-- user_id IS NULL. +CREATE INDEX idx_login_attempts_ip_failures + ON login_attempts(ip, created_at DESC) + WHERE outcome = 'failure'; + +-- Known-IP/known-device baseline (ListRecentSuccesses). +CREATE INDEX idx_login_attempts_user_successes + ON login_attempts(user_id, created_at DESC) + WHERE outcome = 'success'; diff --git a/migrations/008_api_keys.down.sql b/migrations/008_api_keys.down.sql new file mode 100644 index 0000000..456c4fa --- /dev/null +++ b/migrations/008_api_keys.down.sql @@ -0,0 +1,3 @@ +-- 0007_api_keys.down.sql + +DROP TABLE api_keys; diff --git a/migrations/008_api_keys.up.sql b/migrations/008_api_keys.up.sql new file mode 100644 index 0000000..36efe9d --- /dev/null +++ b/migrations/008_api_keys.up.sql @@ -0,0 +1,39 @@ +-- 0007_api_keys.up.sql + +CREATE TABLE api_keys ( + id UUID PRIMARY KEY, + -- ON DELETE CASCADE, not SET NULL: a key with no owner would + -- authenticate as nobody, so it must die with the account. That is + -- the opposite of audit_events/login_attempts, whose rows are + -- evidence about a deleted account and keep their value without it. + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- Presentational label ('CI deploy bot'), and the non-secret + -- fragment of the key ('ck_a1b2c3d4') a management UI shows in + -- place of the key. Neither is unique; neither gates anything. + name TEXT NOT NULL DEFAULT '', + prefix TEXT NOT NULL DEFAULT '', + -- SHA-256 of the whole raw key, not bcrypt. A key is crypto/rand + -- output rather than a human-chosen secret, so there is nothing for + -- a slow hash to defend against — and this column is read on every + -- machine request, where bcrypt's cost would be paid per call. The + -- UNIQUE constraint is also the index that read uses. + key_hash TEXT NOT NULL UNIQUE, + -- Host-defined permission strings as a JSON array, denormalised + -- into the row rather than given a join table. Authentication reads + -- this on every request and must stay one indexed lookup; a scope + -- is also opaque to the engine, so there is nothing to query it by. + scopes JSONB, + -- NULL means a key that never expires, which is the default. + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- Updated coarsely, not per request (see auth.AuthenticateAPIKey): + -- this answers 'is anything still using this key?', and a write per + -- request would make one row the hottest lock in the schema. + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +-- Per-user listing of live keys, the only read here that is not by +-- key_hash. Partial, matching idx_sessions_user_active: revoked keys +-- are never listed. +CREATE INDEX idx_api_keys_user_active ON api_keys(user_id) WHERE revoked_at IS NULL;