Skip to content

Commit 6801475

Browse files
committed
docs: finish Tier 1's docs (Apple plus the two fixes)
README now lists apple among the providers, documents its four env values and the two real differences (self-signed ES256 secret, identity from a verified id_token); the env example gains the APPLE_* block. NEXT.md drops the "except Apple" caveat and records the two Apple decisions (response_mode=query, no nonce) plus what is still owed: a DB-backed smoke-test run, a live Apple round trip, and the WebAuthn ceremonies. CURRENT-STATE.md gains the Apple/what-is-tested section and the two bug fixes, and its verification-gap paragraph now names exactly which paths this sandbox could not exercise. PROGRESS.md gains the continuation entry.
1 parent fac0190 commit 6801475

5 files changed

Lines changed: 158 additions & 41 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ DISCORD_CLIENT_SECRET=
1818
GITLAB_CLIENT_ID=
1919
GITLAB_CLIENT_SECRET=
2020

21+
# Apple needs all four (its "secret" is a JWT this API signs with the .p8
22+
# key, so there is no static secret). Write the key's newlines as \n.
23+
APPLE_CLIENT_ID=
24+
APPLE_TEAM_ID=
25+
APPLE_KEY_ID=
26+
APPLE_PRIVATE_KEY=
27+
2128
# Second factors. ENCRYPTION_KEY is required for TOTP and/or passkeys and
2229
# encrypts TOTP secrets at rest — treat it like JWT_SECRET. Leave it unset
2330
# to run password-only; the second-factor endpoints then answer 404

README.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,30 @@ GITHUB_CLIENT_SECRET=...
3232

3333
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.
3434

35-
Supported providers are `google`, `github`, `microsoft`, `discord` and `gitlab` — all the same authorization-code shape, so each is one case in `httpapi/oauth_handlers.go` plus its env vars:
35+
Supported providers are `google`, `github`, `microsoft`, `discord`, `gitlab` and `apple`.
36+
37+
`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:
3638

3739
```
3840
MICROSOFT_CLIENT_ID=... MICROSOFT_CLIENT_SECRET=...
3941
DISCORD_CLIENT_ID=... DISCORD_CLIENT_SECRET=...
4042
GITLAB_CLIENT_ID=... GITLAB_CLIENT_SECRET=...
4143
```
4244

43-
Apple is **not** wired up yet — its client "secret" is a short-lived JWT you sign with a key from Apple's developer console rather than a static string, and the email arrives inside a signed `id_token` instead of from a userinfo call. It needs its own piece of work, not another case in that switch.
45+
Apple is the one that does not fit that shape, and it is the only provider needing more than an ID and a secret:
46+
47+
```
48+
APPLE_CLIENT_ID=com.example.web # your Services ID, not the app bundle ID
49+
APPLE_TEAM_ID=XXXXXXXXXX
50+
APPLE_KEY_ID=XXXXXXXXXX
51+
APPLE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIG...\n-----END PRIVATE KEY-----
52+
```
53+
54+
- 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.
55+
- 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.
56+
- 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.
57+
58+
All four `APPLE_*` values are required; a partially configured Apple is simply unavailable, like any other unconfigured provider.
4459

4560
## Second factors
4661

@@ -129,7 +144,7 @@ POST /v1/login/passkey/finish (completes a paused login)
129144
POST /v1/login/recovery-code (completes a paused login)
130145
```
131146

132-
`{provider}` is `google`, `github`, `microsoft`, `discord` or `gitlab`.
147+
`{provider}` is `google`, `github`, `microsoft`, `discord`, `gitlab` or `apple`.
133148
The two OAuth flows are separate
134149
on purpose:
135150
- `/oauth/{provider}``/oauth/{provider}/callback` is login/signup —

docs/development/CURRENT-STATE.md

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ A thin HTTP wrapper around cryden v2.5.0 (bumped from v2.1.0 in Tier
66
0, see below). Routes cover signup, login, refresh, logout/logout-all,
77
sessions list/revoke, change password, delete account, email
88
verification and email change, and OAuth — Google, GitHub, Microsoft,
9-
Discord and GitLab configured today (the router itself is
9+
Discord, GitLab and Apple configured today (the router itself is
1010
provider-agnostic via a `{provider}` path param, so a provider is one
1111
switch-statement case in `httpapi/oauth_handlers.go` plus its env vars,
12-
not a router change; Apple is the one provider that does not fit that
13-
shape and is still open — see `NEXT.md` Tier 1).
12+
not a router change; Apple is the one that does not fit that shape and
13+
has its own `httpapi/apple.go` — see `NEXT.md` Tier 1).
1414

1515
Tier 1 also added the second-factor surface: TOTP enroll/confirm/
1616
disable, passkey registration/list/delete, magic-link request/complete,
@@ -86,7 +86,7 @@ cryden — see `CODEX.md`'s ownership section for why.
8686
tiers gate behind. The first real use of `RequireAdmin` will be
8787
whichever Tier 4/5 endpoint lands first.
8888

89-
## Tier 1 — auth methods (all but Apple): DONE
89+
## Tier 1 — auth methods: DONE
9090

9191
Built on `feat/tier1-auth-methods` (its own branch, per `CODEX.md`'s
9292
one-branch-per-tier rule), in this order:
@@ -126,28 +126,44 @@ one-branch-per-tier rule), in this order:
126126
because Microsoft and Discord require it and Google/GitHub accept it,
127127
replacing the previous query-string form rather than branching per
128128
provider.
129+
- **Apple** (`httpapi/apple.go`): the one provider that is not another
130+
switch case. Its client secret is an ES256 JWT signed per exchange
131+
with the console's `.p8` key, and its identity comes from the token
132+
response's `id_token`, verified against Apple's JWKS (issuer,
133+
audience, expiry, RS256) with the key set cached and refetched on an
134+
unknown `kid`. Authorization uses `response_mode=query` so the
135+
existing GET callback route is unchanged. All four `APPLE_*` values
136+
are required; a partial config reads as unavailable.
129137
- **Error mapping** (`httpapi/errors.go`): every new engine error mapped
130-
once there — the eight per-method errors and the four "not
131-
configured" sentinels (`404`, matching
132-
`oauth_provider_not_configured`).
133-
- `httpapi/errors_test.go` — the first test files in this repo: the new
134-
`(status, code)` mappings (including when wrapped) and the paused-
135-
login response shape. Chosen deliberately as the largest slice
136-
verifiable without a database.
138+
once there — the eight per-method errors, the four "not configured"
139+
sentinels (`404`, matching `oauth_provider_not_configured`), and the
140+
Apple verification failure.
141+
- **Two pre-existing bugs found in passing and fixed** (their own
142+
commit): `auth.ErrPasswordPolicyViolation` and
143+
`auth.ErrPasswordBreached` had no `mapError` case, so a signup or
144+
password change that broke the configured policy answered `500
145+
internal_error`; both are now `400`, with the policy error's broken
146+
rule codes reaching the client in an optional `details` array. The
147+
tracked env file was also renamed `.env.exampl``.env.example` to
148+
match what the README has always told you to copy.
149+
- `httpapi/errors_test.go` and `httpapi/apple_test.go` — the first test
150+
files in this repo. The mapping/`writeErr` behaviour, the paused-login
151+
response shape, and Apple's signing plus id_token verification
152+
(accepted case and seven rejection cases, including an `alg: none`
153+
token) are covered; that is deliberately the largest slice verifiable
154+
without a database or Apple credentials.
137155

138156
**Verification gap, disclosed rather than glossed over:** `go build
139157
./...`, `go vet ./...` and `go test ./...` are clean on this branch
140158
(Go 1.25.0, cryden v2.5.0 from the local module cache), and `gofmt -l`
141159
is empty. The DB-backed smoke test was **not** run — this sandbox has
142-
no Postgres and no network — so the end-to-end paths (especially the
143-
WebAuthn ceremonies, which need a real browser authenticator) are
144-
still owed a first run against a real database before Tier 1 counts as
145-
verified the way cryden's own features are.
146-
147-
**Apple is the one open part of Tier 1** — not a fourth mechanical
148-
provider case: its client secret is a self-signed short-lived JWT, and
149-
the email arrives inside a signed `id_token` that has to be verified
150-
against Apple's JWKS. Left open deliberately rather than half-built.
160+
no Postgres and no network — so the end-to-end paths are still owed a
161+
first run against a real database before Tier 1 counts as verified the
162+
way cryden's own features are. Two paths additionally cannot be
163+
verified here at all: the WebAuthn ceremonies (need a real browser
164+
authenticator) and Apple (needs real Apple credentials; what is tested
165+
offline is the signing and the id_token verification, against a local
166+
JWKS).
151167

152168
## Tier 2 through 5
153169

docs/development/NEXT.md

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,23 @@ genuinely unspecified, make the most reasonable call consistent with
99
`CODEX.md`'s ownership rules and note the assumption in `PROGRESS.md`.
1010

1111
Tier 0 and Tier 0.5 are done — see `CURRENT-STATE.md`.
12-
Tier 1 is done **except Apple** — see the status note under Tier 1 and
13-
`PROGRESS.md`'s 2026-09-14 entry for exactly what is left.
12+
Tier 1 is done — see the status note under Tier 1 and `PROGRESS.md`'s
13+
2026-09-14 entries for how far it is verified.
1414

1515
---
1616

1717
## Tier 1 — auth methods
1818

19-
> **Status: every sub-item below is built and on `feat/tier1-auth-methods`
20-
> except Apple**, which is deliberately still open (see the Apple bullet
21-
> at the end of this section — it is not another mechanical provider
22-
> case). The engine-side pieces are wired and `go build`/`go test` are
23-
> clean; the DB-backed smoke test has not been run here (no Postgres in
24-
> this sandbox), so a first run against a real database is still owed
25-
> before this counts as verified end to end — `PROGRESS.md` says so
26-
> plainly, per `CODEX.md`'s verification rule.
19+
> **Status: every sub-item below is built, Apple included, on
20+
> `feat/tier1-auth-methods`.** `go build`/`go vet`/`go test` are clean
21+
> and the new mapping/`writeErr` behaviour is unit-tested, including
22+
> Apple's client-secret signing and its id_token verification
23+
> (signature, audience, issuer, expiry, algorithm) against a local JWKS.
24+
> What is still owed: a first DB-backed smoke-test run (no Postgres in
25+
> this sandbox), a live Apple round trip (no Apple credentials here), and
26+
> the WebAuthn ceremonies, which need a real browser authenticator.
27+
> `PROGRESS.md` says all of that plainly, per `CODEX.md`'s verification
28+
> rule, rather than counting green unit tests as end-to-end coverage.
2729
2830
Each of these mirrors an existing engine feature that already has a
2931
full smoke-test-verified implementation in cryden. The work here is
@@ -119,14 +121,21 @@ Google/GitHub already use.
119121
again. Budget real time for this one; don't estimate it at the same
120122
size as the other three.
121123

122-
**Still open in Tier 1: Apple only.** The other three providers ship in
123-
`feat/tier1-auth-methods`. Apple needs: an ES256 client-secret JWT
124-
signed with a console key (so a private key in config, not a static
125-
secret), reading the email from the token endpoint's signed `id_token`
126-
(which means verifying it against Apple's JWKS, not parsing it
127-
unverified), and the callback-URL shape Apple requires. Its own branch
128-
or its own commit, and it cannot be smoke-test-verified without real
129-
Apple credentials — say so rather than implying otherwise.
124+
**Apple: done** (its own commit, `httpapi/apple.go` + `apple_test.go`).
125+
It needed exactly what was foreseen here: an ES256 client-secret JWT
126+
signed per exchange with the console key, and the identity read from the
127+
token response's `id_token` after verifying it against Apple's JWKS.
128+
Two details were decided rather than assumed, and are recorded in
129+
`PROGRESS.md`:
130+
131+
- `response_mode=query` so the existing GET callback route works
132+
unchanged. The consequence is that Apple's one-time `user` payload
133+
(name, first authorization only) is not captured — this repo stores the
134+
id_token's email, not names.
135+
- No `nonce` parameter. Apple requires one for the hybrid/implicit flow;
136+
this is the authorization-code flow, where the code is single-use and
137+
bound to this client and the redirect already carries a CSRF `state`
138+
cookie. Worth revisiting only if a hybrid flow is ever added.
130139

131140
---
132141

docs/development/PROGRESS.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,73 @@ rather than silently patched):
127127
Next: finish Tier 1's Apple provider (its own commit, and it will need
128128
real credentials before it can be smoke-tested), then a first DB-backed
129129
smoke-test run of everything above, then Tier 2.
130+
131+
## 2026-09-14 (later) — the two flagged issues, then Apple
132+
133+
Same branch, four more commits. Tier 1 is now complete, Apple included.
134+
135+
**Fixed the two things flagged in the entry above**, each its own commit:
136+
137+
- `fix: map password-policy and breached-password errors to 400`
138+
`auth.ErrPasswordPolicyViolation` and `auth.ErrPasswordBreached` now
139+
map to `400`/`password_policy_violation` and `400`/`password_breached`
140+
instead of falling through to `500 internal_error`. The policy error
141+
keeps its struct case and `writeErr` reads the broken-rule codes off
142+
the error into an optional `details` array — the one addition to the
143+
error envelope, and it appears on no other error (both halves are
144+
pinned by tests, so an existing client sees a byte-identical shape).
145+
- `chore: rename .env.exampl to .env.example` — renamed rather than
146+
rewriting the README, since the README's name is the conventional one
147+
and `.gitignore` only ignores `.env` itself.
148+
149+
**Apple** (`feat: add Sign in with Apple`, `httpapi/apple.go` +
150+
`apple_test.go` + `golang-jwt` promoted from indirect to direct):
151+
152+
- Client secret is an ES256 JWT signed per exchange with the console
153+
`.p8` key (10-minute expiry, `kid` header, `iss`=team, `sub`=client
154+
ID, `aud`=Apple). `APPLE_CLIENT_ID`/`TEAM_ID`/`KEY_ID`/`PRIVATE_KEY`
155+
are all required; a partial config reads as unavailable, same as any
156+
other unconfigured provider. `APPLE_PRIVATE_KEY` accepts a `.env`-style
157+
`\n`-escaped PEM and leaves a real multiline value alone.
158+
- No userinfo endpoint: the identity comes from the token response's
159+
`id_token`, verified against Apple's JWKS (RS256 only, issuer,
160+
audience, expiry, `kid` lookup) with the key set cached for an hour
161+
and refetched once on an unknown `kid` so a rotation is picked up.
162+
An unverified decode would have let anyone who can reach the callback
163+
mint an account, so this is the part that got the most test attention.
164+
- `exchangeCode` now takes the client secret as an argument and returns
165+
both the access token and the id_token, which lets Apple reuse the
166+
existing POST plumbing instead of duplicating it. The authorization
167+
redirect query is now built in one `authQuery` helper used by both the
168+
login and linking flows, so a provider-specific parameter cannot be
169+
added to one and forgotten in the other.
170+
171+
Assumptions made while building Apple (recorded, not silent):
172+
173+
- **`response_mode=query`**, so the existing GET callback route works
174+
unchanged. Consequence: Apple's one-time `user` payload (the name it
175+
sends only on a first authorization, and only under `form_post`) is
176+
not captured — this repo stores the id_token's email, never names,
177+
which is all `LoginWithOAuth` takes anyway.
178+
- **No `nonce`.** Apple requires one for the hybrid/implicit flow; this
179+
is the authorization-code flow, where the code is single-use, bound to
180+
this client, and the redirect already carries a CSRF `state` cookie
181+
verified against a signed cookie. Revisit only if a hybrid flow is
182+
ever added.
183+
- **Apple's email is required**, like every other provider here — if the
184+
id_token has no email claim the login fails with the existing
185+
`oauth_email_not_available` rather than inventing an identity.
186+
187+
Verification: `go build ./...`, `go vet ./...` and `go test ./...` clean,
188+
`gofmt -l` empty. `apple_test.go` covers the generated secret (parses as
189+
ES256, correct `kid`/`iss`/`aud`/`sub`, unexpired; RSA and non-PEM keys
190+
rejected) and id_token verification (accepted when signed by the served
191+
key; rejected for wrong audience, wrong issuer, expiry, different
192+
signing key, unknown `kid`, missing subject, and an `alg: none` token).
193+
Still **not** verified here: a live Apple round trip (no credentials, no
194+
network), the DB-backed smoke test (no Postgres), and the WebAuthn
195+
ceremonies (no browser authenticator). Those remain the first things to
196+
run on a real deployment.
197+
198+
Next: Tier 2, on its own branch per `CODEX.md` — and before or alongside
199+
it, the first DB-backed smoke-test run of everything in Tier 1.

0 commit comments

Comments
 (0)