@spfn/auth 0.2.0-beta.87 → 0.2.0-beta.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +384 -18
  2. package/dist/{authenticate-C7w-pLOR.d.ts → authenticate-DK-hLSOW.d.ts} +154 -5
  3. package/dist/client-proof.d.ts +122 -2
  4. package/dist/client-proof.js +366 -5
  5. package/dist/client-proof.js.map +1 -1
  6. package/dist/config.d.ts +36 -0
  7. package/dist/config.js +18 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/errors.d.ts +93 -2
  10. package/dist/errors.js +57 -0
  11. package/dist/errors.js.map +1 -1
  12. package/dist/index.d.ts +32 -3
  13. package/dist/index.js +60 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/nextjs/server.d.ts +2 -2
  16. package/dist/server.d.ts +126 -11
  17. package/dist/server.js +527 -106
  18. package/dist/server.js.map +1 -1
  19. package/dist/{session-CGxgH3C9.d.ts → session-CFK4BT25.d.ts} +1 -1
  20. package/dist/{types-1BMx0OX1.d.ts → types-CD95yudz.d.ts} +15 -1
  21. package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
  22. package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
  23. package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
  24. package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
  25. package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
  26. package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
  27. package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
  28. package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
  29. package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
  30. package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
  31. package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
  32. package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
  33. package/package.json +3 -6
  34. package/migrations/meta/0000_snapshot.json +0 -1632
  35. package/migrations/meta/0001_snapshot.json +0 -1660
  36. package/migrations/meta/0002_snapshot.json +0 -1660
  37. package/migrations/meta/0003_snapshot.json +0 -1689
  38. package/migrations/meta/0004_snapshot.json +0 -1721
  39. package/migrations/meta/0005_snapshot.json +0 -1721
  40. package/migrations/meta/0006_snapshot.json +0 -1921
  41. package/migrations/meta/0007_snapshot.json +0 -1916
  42. package/migrations/meta/_journal.json +0 -62
  43. /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
  44. /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
  45. /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
  46. /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
  47. /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
  48. /package/migrations/{0005_lethal_lifeguard.sql → 20260629103209_lethal_lifeguard/migration.sql} +0 -0
  49. /package/migrations/{0006_easy_hardball.sql → 20260709073531_easy_hardball/migration.sql} +0 -0
  50. /package/migrations/{0007_glossy_major_mapleleaf.sql → 20260714081434_glossy_major_mapleleaf/migration.sql} +0 -0
package/README.md CHANGED
@@ -1,9 +1,27 @@
1
- # @spfn/auth — Authentication, OAuth, and RBAC for SPFN
1
+ # @spfn/auth
2
2
 
3
- Asymmetric client-signed JWT auth (ES256/RS256), OTP verification, OAuth 2.0 (pluggable
4
- provider registry; Google, GitHub, Kakao, and Naver built in), session cookies for Next.js, and runtime RBAC.
5
- Routes are exposed under the `/_auth/*` namespace and reached through a type-safe `authApi`
6
- client. Requires `@spfn/core`; Next.js is an optional peer (`^15 || ^16`).
3
+ > **Two applications' worth of auth, in one package**
4
+
5
+ Nothing ships until people can sign in. `@spfn/auth` clears that gate twice over — once
6
+ for the people who use your product, and once for the people who operate it.
7
+
8
+ - **For your users** — registration, password and OTP login, social sign-in, sessions,
9
+ registered devices, and account deletion with a recovery window.
10
+ - **For your operators** — admin accounts seeded from the environment, roles and
11
+ permissions enforced on every route, invitations, and role administration your
12
+ superadmins can change at runtime.
13
+
14
+ The second half is what usually becomes a second application: an admin dashboard with its
15
+ own auth, its own screens, and its own maintenance, growing for as long as the product
16
+ does. Attach [`@spfn/mcp`](../mcp/README.md) instead and those operations become tools an
17
+ AI agent runs, gated by the same roles — see
18
+ [Can I operate the app without building an admin dashboard?](#can-i-operate-the-app-without-building-an-admin-dashboard).
19
+
20
+ Underneath: asymmetric client-signed JWTs (ES256/RS256), OTP verification, OAuth 2.0
21
+ through a pluggable provider registry (Google, GitHub, Kakao and Naver built in), session
22
+ cookies for Next.js, and runtime RBAC. Routes mount under `/_auth/*` and are reached
23
+ through a typed `authApi` client. Requires `@spfn/core`; Next.js is an optional peer
24
+ (`^16.2.11`).
7
25
 
8
26
  ## Install
9
27
 
@@ -31,9 +49,9 @@ import { createClientProofDevHandler } from '@spfn/auth/client-proof'; // SERVE
31
49
  > Database entities (`users`, `userPublicKeys`, …) and all services/repositories are exported
32
50
  > from `@spfn/auth/server`, **not** from the root `@spfn/auth`.
33
51
 
34
- ## Setup (4 wiring points)
52
+ ## How do I add auth to an SPFN app?
35
53
 
36
- Auth needs four edits in the consuming app. All four are required for the flow to work end to end.
54
+ Four edits in the consuming app. All four are required for the flow to work end to end.
37
55
 
38
56
  ### 1. Lifecycle — `server.config.ts`
39
57
 
@@ -101,7 +119,7 @@ import { authApi } from '@spfn/auth';
101
119
  const session = await authApi.getAuthSession.call({}); // → GET /_auth/session
102
120
  ```
103
121
 
104
- ## Environment variables
122
+ ## Which environment variables do I need?
105
123
 
106
124
  Set across **two files** by audience. Server-only secrets go in `.env.server`; values the
107
125
  Next.js runtime needs (session cookie crypto) go in `.env.local`. Names only below — supply
@@ -131,6 +149,8 @@ real secret values out of band, never commit them.
131
149
  | `SPFN_AUTH_GITHUB_SCOPES` / `_REDIRECT_URI` | `.env.server` | — | default scopes `read:user,user:email`; callback `/_auth/oauth/github/callback` |
132
150
  | `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated client IDs accepted as native id_token audience (iOS/Android/web); enables Google native sign-in |
133
151
  | `SPFN_AUTH_APPLE_CLIENT_IDS` | `.env.server` | — | comma-separated Apple client IDs (bundle ID / Services ID); enables Apple native sign-in |
152
+ | `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated Kakao app keys accepted as native id_token audience (native app key); `SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted, so either one enables Kakao native sign-in |
153
+ | `SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS` | `.env.server` | — | comma-separated Naver client IDs accepted as native id_token audience. `SPFN_AUTH_NAVER_CLIENT_ID` is also accepted, so this is only needed for a separate app application |
134
154
  | `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
135
155
  | `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
136
156
  | `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
@@ -155,13 +175,15 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
155
175
 
156
176
  | `authApi` method | HTTP | Auth | Purpose |
157
177
  |------------------|------|------|---------|
158
- | `checkAccountExists` | POST `/_auth/exists` | public | email/phone existence check |
159
178
  | `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
160
179
  | `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
161
180
  | `register` | POST `/_auth/register` | public | create user + register public key |
162
181
  | `login` | POST `/_auth/login` | public | password login + new session key |
163
182
  | `logout` | POST `/_auth/logout` | yes | revoke current key |
164
183
  | `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
184
+ | `listKeys` | POST `/_auth/keys/list` | yes | the caller's registered devices — see [Registered devices](#registered-devices-key-management) |
185
+ | `revokeKey` | POST `/_auth/keys/revoke` | yes | sign one device out |
186
+ | `revokeAllKeys` | POST `/_auth/keys/revoke-all` | yes | sign every device out (spares the caller by default) |
165
187
  | `changePassword` | PUT `/_auth/password` | yes | change password |
166
188
  | `getAuthSession` | GET `/_auth/session` | yes | current session/user |
167
189
  | `issueOneTimeToken` | POST | yes | short-lived token (e.g. SSE handshake) |
@@ -173,11 +195,89 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
173
195
  | `listRoles` / `createAdminRole` / `updateAdminRole` / `deleteAdminRole` / `updateUserRole` | — | superadmin | admin RBAC management |
174
196
  | OAuth routes | — | — | see OAuth section |
175
197
 
198
+ There is deliberately **no account-existence endpoint**. `POST /_auth/exists` was removed
199
+ because it answered "does this account exist" directly, which is user enumeration; the
200
+ login path is timing-equalized for the same reason. Do not reintroduce one without
201
+ revisiting that decision.
202
+
176
203
  Auth uses **asymmetric, client-signed JWTs**: the client generates an ES256/RS256 keypair,
177
204
  sends the public key on register/login, signs request JWTs locally, and the server verifies
178
205
  with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
179
206
  Keys expire after 90 days — rotate with `rotateKey`.
180
207
 
208
+ ### Registered devices (key management)
209
+
210
+ Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
211
+ `listKeys` / `revokeKey` / `revokeAllKeys` are what let the account owner see what accumulated and
212
+ cut off anything they no longer recognise.
213
+
214
+ ```typescript
215
+ const { keys } = await authApi.listKeys.call({ body: {} });
216
+ // → [{ keyId, deviceName?, platform?, algorithm, fingerprintPrefix, createdAtMillis,
217
+ // lastUsedAtMillis?, expiresAtMillis?, isExpired, isActive, revokedAtMillis? }]
218
+
219
+ await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
220
+ ```
221
+
222
+ Every moment is epoch milliseconds, not an ISO string — one representation across the whole
223
+ surface, so a generated Swift or Kotlin client reads an integer instead of choosing a date
224
+ formatter. This changed in mobile contract 0.5.0; an app still reading `createdAt` moves to
225
+ `createdAtMillis`.
226
+
227
+ `algorithm` is the `KeyAlgorithm` enum from contract 0.6.0 rather than a bare string — the routes
228
+ have always constrained it to those values, and the contract had been understating the server. The
229
+ declared values are the ones the server accepts and sends **now**: one can be added, and one can be
230
+ withdrawn for a weakness found later, so a generated client should be built to meet a value it does
231
+ not recognise rather than assume the set is closed.
232
+
233
+ ```typescript
234
+ await authApi.revokeKey.call({ body: { keyId } }); // → { keyId, selfRevoked }
235
+ await authApi.revokeAllKeys.call({ body: {} }); // other devices only
236
+ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
237
+ ```
238
+
239
+ > **All three are POST with their arguments in the body, deliberately.** The mobile auth
240
+ > profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
241
+ > bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
242
+ > client and server could disagree on the signed string over percent-encoding, a trailing
243
+ > slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
244
+ > naming the cause. Every operation in the contract is shaped this way.
245
+
246
+ - **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
247
+ The list exists to recognise a device and point at it; the full fingerprint is what a native
248
+ sign-in sends as its nonce, not a label.
249
+ - **`isExpired` is computed, not stored.** Nothing flips `isActive` when the TTL runs out —
250
+ `authenticate` refuses the key at request time. A list that showed such a key as simply active
251
+ would report something the server does not act on.
252
+ - **Revoking your own key is allowed.** It is this device's sign-out, which `logout` already does.
253
+ `selfRevoked` in the response tells the two cases apart.
254
+ - **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
255
+ "sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
256
+ only as a side effect of changing a password, which nobody does for that reason.
257
+ - **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
258
+ the answer is only ever "not yours" and reveals nothing about other accounts.
259
+ - **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
260
+ request with no cache in front of it.
261
+ - **`includeRevoked: true` shows what was already cut off**, with `revokedAt`. The default is only
262
+ keys that can still sign.
263
+
264
+ Every path that registers a key (`register`, `login`, `rotateKey`, native OAuth) accepts optional
265
+ `deviceName` (≤64 chars) and `platform` (`ios` / `android` / `web` / `desktop`). Both are display
266
+ only — nothing is authorized by them — and both are absent on keys registered before they existed.
267
+ Rotation carries the replaced key's label over unless the client sends a new one.
268
+
269
+ All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
270
+ `auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
271
+ rotation.
272
+
273
+ A `keyId` is **single-use for its lifetime**: it is unique across all users and is never reissued
274
+ once revoked. A client that logs out, rotates, or is revoked must generate a **fresh keypair and
275
+ `keyId`** for its next sign-in — resending the old one is refused with
276
+ `KeyIdAlreadyRegisteredError` (409), on every path that registers a key. Re-registering a key that
277
+ is still active is the one
278
+ exception: it stays a no-op success, so repeated logins from the same device keep working, and an
279
+ expired-but-active key has its expiry extended by the sign-in that proved the identity again.
280
+
181
281
  ### Writing protected routes (route DSL)
182
282
 
183
283
  This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
@@ -361,22 +461,154 @@ its own Bearer client token by signing with the on-device private key (the same
361
461
  server-verifies model as the rest of auth).
362
462
 
363
463
  Enable per provider by declaring the accepted audiences: `SPFN_AUTH_GOOGLE_NATIVE_CLIENT_IDS` for
364
- Google (the web `SPFN_AUTH_GOOGLE_CLIENT_ID` is also accepted) and `SPFN_AUTH_APPLE_CLIENT_IDS` for
365
- Apple. Apple is native-only here its web OAuth (code-exchange) methods throw.
464
+ Google (the web `SPFN_AUTH_GOOGLE_CLIENT_ID` is also accepted), `SPFN_AUTH_APPLE_CLIENT_IDS` for
465
+ Apple, and `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` for Kakao (the REST API key in
466
+ `SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted). Apple is native-only here — its web OAuth
467
+ (code-exchange) methods throw.
366
468
 
367
469
  ```typescript
368
470
  await authApi.oauthNative.call({
369
- params: { provider: 'apple' }, // or 'google'
471
+ params: { provider: 'apple' }, // or 'google', 'kakao'
370
472
  body: { idToken, nonce, publicKey, keyId, fingerprint, algorithm: 'ES256', profile: { name } },
371
473
  });
372
474
  // → { userId, keyId, isNewUser }; client then signs its own ES256 Bearer token with keyId
373
475
  ```
374
476
 
477
+ Every refusal names itself. The response body carries `error.code` — the server's error class
478
+ name — alongside the usual `__type`, so a client that has no TypeScript error registry can still
479
+ tell the eleven ways this call fails apart:
480
+
481
+ | `error.code` | HTTP | What the client does |
482
+ | --- | --- | --- |
483
+ | `ValidationError` | 400 | fix the request body |
484
+ | `NativeSignInUnsupportedError` | 400 | hide that provider's native button — server configuration |
485
+ | `NonceKeyBindingError` | 400 | send `nonce === fingerprint` |
486
+ | `InvalidKeyFingerprintError` | 400 | send the SHA-256 of the submitted key |
487
+ | `UnverifiedEmailLinkError` | 400 | send the user to verify that address |
488
+ | `InvalidSocialTokenError` | 401 | obtain a fresh id_token |
489
+ | `AccountDisabledError` | 403 | show the account status |
490
+ | `AccountPendingDeletionError` | 403 | offer restore |
491
+ | `KeyIdAlreadyRegisteredError` | 409 | generate a new keyId and retry |
492
+ | `TooManyRequestsError` | 429 | **the only retry-the-same-request code** |
493
+ | `Error` | 500 | generic failure |
494
+
375
495
  The `nonce` is the **raw** nonce the client used; Apple hashes it (SHA-256) into the token, so send
376
- the raw value for either provider. `profile.name` captures the name Apple returns only on first
496
+ the raw value for any provider. `profile.name` captures the name Apple returns only on first
377
497
  sign-in. Trade-off: skipping code exchange means no Apple refresh token / server-side revoke —
378
498
  revoke SPFN access by revoking the registered key instead.
379
499
 
500
+ > **The nonce must be the `fingerprint` of the key being registered.** Since contract 0.4.0 the
501
+ > server refuses the call when `nonce !== fingerprint`, or when that fingerprint is not the
502
+ > SHA-256 of the submitted `publicKey`'s DER bytes. So the client does not mint a random nonce —
503
+ > it asks the provider for a token bound to the key it is about to enroll:
504
+ >
505
+ > ```typescript
506
+ > const fingerprint = sha256Hex(derBytesOf(publicKey)); // lowercase hex, 64 chars
507
+ > const nonce = fingerprint; // what the provider echoes back
508
+ > // Apple only: put sha256Hex(nonce) in the authorization request — Apple hashes what it receives
509
+ > ```
510
+ >
511
+ > Why: an `id_token` is a bearer credential. It is not bound to the channel it came over, so
512
+ > verifying it alone means whoever holds one valid token can enroll **their own** key on **someone
513
+ > else's** account — by extracting the app key from a real app binary, from a rooted device, or
514
+ > from a leaked log. The web OAuth flow is not exposed this way: there the public key travels
515
+ > inside encrypted `state` whose nonce must match the browser's CSRF cookie. Deriving the nonce
516
+ > from the key gives the native path the same binding, because a stolen token carries the victim's
517
+ > fingerprint and cannot be re-paired with an attacker's key. Re-submitting the victim's own key
518
+ > stays possible and is worthless — the attacker has no matching private key.
519
+ >
520
+ > Naver's trailing-`A` problem (below) is satisfied for free: a SHA-256 hex digest is lowercase.
521
+
522
+ > **Generate the nonce as lowercase hex, not base64.** Naver drops a trailing `A` from a base64url
523
+ > nonce before putting it in the id_token. A 16-byte base64url value ends in one of `A Q g w` —
524
+ > its last character carries only 2 bits of data plus 4 bits of padding — so a base64 nonce fails
525
+ > verification for roughly one sign-in in four, intermittently and with nothing in the logs
526
+ > pointing at the cause.
527
+ >
528
+ > The trigger is the character `A`, not the encoding as such. **Uppercase hex ends in `A` once in
529
+ > sixteen and breaks the same way**; lowercase hex (`0-9a-f`) has no `A` in its alphabet, so it
530
+ > cannot hit the case at all. Nonce comparison is exact by design (`jwks-verify.ts`) — accepting a
531
+ > truncated value would also accept any other nonce sharing those first characters — so the fix
532
+ > belongs on the client. Confirmed on Naver; not yet measured on the other providers, and
533
+ > lowercase hex is safe for all of them.
534
+
535
+ #### The optional `accessToken`
536
+
537
+ `accessToken` is the provider access token from the same sign-in. It is **optional and
538
+ provider-specific** — the server never requires it, and a client that omits it still signs in.
539
+
540
+ Send it only when a provider's id_token cannot establish the user's **email**, which is identity
541
+ data: `createOrLinkUser` matches an existing account by verified email. Display-side profile
542
+ (name, avatar) is deliberately *not* a reason to send it — that belongs to the app, not to auth.
543
+
544
+ | Provider | Send `accessToken`? | Why |
545
+ |---|---|---|
546
+ | Google | No | id_token carries `email` + `email_verified` |
547
+ | Apple | No | same, and Apple relay addresses are already the authoritative value |
548
+ | Kakao | **Optional, recommended** | id_token carries `email` but no `email_verified`; without it the address is stored unverified |
549
+ | Naver | **Optional, recommended** | id_token carries no profile claim at all; userinfo returns the address, which carries no verification flag (see below) |
550
+
551
+ Whatever the provider, the server trusts a lookup made with this token only after the identity it
552
+ returns matches the id_token's `sub`. A mismatch, or a failed lookup, is treated as if the token
553
+ had not been sent.
554
+
555
+ **Kakao.** Enable OpenID Connect in the Kakao developer console and request the `openid` scope, or
556
+ the SDK returns no `idToken`. One Kakao app issues several keys (native app key, REST API key), and
557
+ the `aud` claim is whichever key obtained the token — so list the native app key and let the REST
558
+ API key be accepted alongside it. The `sub` (회원번호) is per-app, not per-key, so web and app
559
+ sign-ins resolve to the same user.
560
+
561
+ Kakao's id_token carries `email` but no `email_verified`, so the identity comes back **unverified**
562
+ and the account is created with a null email. To match the web flow's strength, send the
563
+ `accessToken` the SDK returned in the same sign-in as an optional body field: the server then reads
564
+ `is_email_valid` / `is_email_verified` from `/v2/user/me`. That token is client-supplied, so the
565
+ lookup is trusted only when its 회원번호 equals the id_token's `sub`; a mismatch or a failed lookup
566
+ leaves the email unverified and the sign-in still succeeds.
567
+
568
+ ```typescript
569
+ await authApi.oauthNative.call({
570
+ params: { provider: 'kakao' },
571
+ body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
572
+ });
573
+ ```
574
+
575
+ **Naver.** Naver runs two login surfaces. The web redirect flow uses `/oauth2.0/*`, which is plain
576
+ OAuth2 and issues no id_token; native verification uses the OIDC surface at `/oauth2/*`. The
577
+ `SPFN_AUTH_NAVER_CLIENT_ID` you already have is accepted as the audience — one Naver application
578
+ has a single client ID covering its web and app environments — so
579
+ `SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS` is only needed when the app registers a separate application.
580
+
581
+ Naver's native SDK cannot produce an id_token: it is pinned to `/oauth2.0/*` and its authorize
582
+ request has no `scope` parameter at all. The app therefore obtains the id_token through a browser
583
+ flow (`ASWebAuthenticationSession` / Custom Tab) against `/oauth2/authorize?scope=openid` with PKCE
584
+ — `token_endpoint_auth_methods_supported` includes `none`, so no client secret is needed. The
585
+ server contract is the same whichever way the token was obtained.
586
+
587
+ The id_token carries `iss`, `aud`, `azp`, `sub`, `nonce`, `jti`, `iat`, `exp` — no email, no name,
588
+ no picture, even when the application marks email as required. Send `accessToken` to fill it: the
589
+ server reads `/v1/nid/me`, whose `id` is the same pairwise value as the id_token's `sub`, and
590
+ treats a returned address as verified (the same rule the web flow uses). `sub` being pairwise helps
591
+ here — a token from another application resolves to a different `sub` and is rejected by the match.
592
+
593
+ That verified verdict rests on one fact and it is worth stating plainly, because `createOrLinkUser`
594
+ links a social identity to an existing account on a verified address alone. The `/v1/nid/me`
595
+ response carries **no** verification flag — unlike Kakao, which reports `is_email_valid` and
596
+ `is_email_verified` and is checked against both. What Naver guarantees instead is at change time:
597
+ moving the contact email requires a code sent to the new address, so the returned value is an
598
+ address the user has proven they control. It is **not** a stable identifier: the user can change it,
599
+ one address can be shared by up to six Naver IDs, and it may be absent entirely. `providerUserId` is
600
+ the only key that identifies the account.
601
+
602
+ ```typescript
603
+ await authApi.oauthNative.call({
604
+ params: { provider: 'naver' },
605
+ body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
606
+ });
607
+ ```
608
+
609
+ Without `accessToken` a Naver sign-in has no email at all, so every user is created fresh and never
610
+ links to an existing account.
611
+
380
612
  ### Custom providers
381
613
 
382
614
  Implement `OAuthProvider` and register it. `SOCIAL_PROVIDERS` is `['google','apple','github','kakao','naver','superself']`. Implement the optional `verifyNativeIdToken(idToken, { nonce })` to support native id_token sign-in.
@@ -430,7 +662,7 @@ receives the same account/token context and owns its key rotation policy.
430
662
  - `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
431
663
  update any `switch(provider)` in subscribers.
432
664
 
433
- ## Sessions (Next.js)
665
+ ## How do I read the session in a Next.js page?
434
666
 
435
667
  Sessions are HttpOnly cookies encrypted with `SPFN_AUTH_SESSION_SECRET` (JWE), holding the
436
668
  client private key + `keyId` (`SessionData`: `{ userId, privateKey, keyId, algorithm }`). The
@@ -464,7 +696,7 @@ export default async function AdminPage()
464
696
  Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
465
697
  `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
466
698
 
467
- ## RBAC
699
+ ## How do I define roles and permissions?
468
700
 
469
701
  Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
470
702
  `auth:self:manage`, `user:read|write|delete|invite`, `rbac:role:manage`, `rbac:permission:manage`.
@@ -484,6 +716,55 @@ Programmatic checks (server): `hasPermission`, `hasAnyPermission`, `hasAllPermis
484
716
  `deleteRole`, `setRolePermissions`, `addPermissionToRole`, `removePermissionFromRole`,
485
717
  `getAllRoles`, `getRoleByName`, `getRolePermissions`.
486
718
 
719
+ ## Can I operate the app without building an admin dashboard?
720
+
721
+ Yes, and that is the point of the operator half of this package. The day after you deploy,
722
+ someone has to refund an order, look up a user, publish a change, retry a failed job. The
723
+ usual answer is to build screens for each of those. `@spfn/auth` already knows who your
724
+ operators are and which of them may do what; [`@spfn/mcp`](../mcp/README.md) turns those
725
+ operations into tools an AI agent can run, so the screens never get built.
726
+
727
+ The connection is app code, deliberately. `@spfn/mcp` does not read this package's RBAC on
728
+ its own — it asks you for a `validateToken` and a `listTools`, and those are where auth's
729
+ answers go:
730
+
731
+ ```typescript
732
+ import { createMcpRoute } from '@spfn/mcp/server';
733
+ import { hasPermission, getUserRole } from '@spfn/auth/server';
734
+
735
+ // one required permission per tool — the same permission names your routes check
736
+ const allTools = [
737
+ { name: 'orders.refund', permission: 'order:refund', /* … */ },
738
+ { name: 'content.publish', permission: 'post:publish', /* … */ },
739
+ ];
740
+
741
+ export const mcpRouter = createMcpRoute({
742
+ appUrl: 'https://app.example.com',
743
+ serverInfo: { name: 'example-app', version: '1.0.0' },
744
+
745
+ validateToken: async (token, resource) => verifyAccessToken(token, resource),
746
+
747
+ resolveContext: async (auth) => ({
748
+ userId: auth.userId,
749
+ role: await getUserRole(auth.userId),
750
+ }),
751
+
752
+ listTools: async (ctx) =>
753
+ {
754
+ const allowed = await Promise.all(
755
+ allTools.map(t => hasPermission(ctx.userId, t.permission)),
756
+ );
757
+
758
+ return allTools.filter((_, i) => allowed[i]);
759
+ },
760
+ });
761
+ ```
762
+
763
+ Two rules keep this safe. **Expose operations, not tables** — `orders.refund` carries an
764
+ authorization rule; a generic `db.query` carries none. And **check the permission inside
765
+ the handler too**, not only in `listTools`: hiding a tool from the list is discovery
766
+ control, not authorization.
767
+
487
768
  ## Events
488
769
 
489
770
  `@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
@@ -515,7 +796,8 @@ is the recommended error. The hook receives the same `metadata` the app supplied
515
796
  `register` / OAuth start / the invitation — never credentials.
516
797
 
517
798
  ```typescript
518
- import { configureAuth, RegistrationRejectedError } from '@spfn/auth/server';
799
+ import { configureAuth } from '@spfn/auth/server';
800
+ import { RegistrationRejectedError } from '@spfn/auth/errors';
519
801
 
520
802
  configureAuth({
521
803
  beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
@@ -584,6 +866,44 @@ HTTP status).
584
866
  construction or through the `/control/register-key` hook; the private half never reaches
585
867
  the server. No persistence — a production enrollment/rotation story is phase 2.
586
868
 
869
+ ### The contract version on the wire (contract 0.6.0)
870
+
871
+ A client compiled and shipped separately from the server cannot be fixed by redeploying. Until
872
+ 0.6.0 a mismatch between what that client was generated against and what the server serves
873
+ surfaced as an undecodable body: the app looked broken and nothing said why.
874
+
875
+ Both ends now say what they are.
876
+
877
+ | Header | Direction | Sent by |
878
+ |--------|-----------|---------|
879
+ | `x-spfn-client-kind` | request | every client — `web`, `ios` or `android` |
880
+ | `x-spfn-client-version` | request | the client's own release: a store version, or a bundle build |
881
+ | `x-spfn-client-contract-version` | request | `ios` and `android` only |
882
+ | `x-spfn-server-contract-version` | response | the server, on every response including a refusal |
883
+ | `x-spfn-supported-contract-range` | response | the server, likewise |
884
+
885
+ ```typescript
886
+ import { createClientVersionMiddleware } from '@spfn/auth/client-proof';
887
+
888
+ // Mount before authentication: enrollment and login carry no proof, and they are
889
+ // where a stale client arrives first.
890
+ app.use('*', createClientVersionMiddleware());
891
+ ```
892
+
893
+ - **`web` states no contract version**, because a browser bundle is deployed with the server that
894
+ serves it and has no second version to reconcile. It is exempt by construction, not by leniency.
895
+ - **An `ios` or `android` client that states no contract version, or one outside the range, is
896
+ refused** `CONTRACT_UNSUPPORTED` (409) with the usual envelope.
897
+ - **A request naming no kind passes** — a curl, a health probe, a server-to-server call is not a
898
+ deployed client this rule is about.
899
+ - **None of it enters the proof input.** These are diagnostic; `PROOF_INPUT_FIELDS` is unchanged.
900
+ - **The server states facts and stops there.** Comparing the announced range against its own version
901
+ and deciding a user should see an update prompt is the client's judgment, made in the client. The
902
+ server has no way to make an app update and does not pretend to.
903
+
904
+ Response header names are deliberately distinct from the request ones: a proxy that echoes a request
905
+ header into the response would otherwise make the client's own version look like the server's.
906
+
587
907
  ### Usage — dev surface (mobile integration target)
588
908
 
589
909
  The fastest path: run the packaged dev handler, which already serves the three contract
@@ -746,6 +1066,50 @@ Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)`
746
1066
  named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
747
1067
  router) double-registers the same job name against pg-boss instead of overriding it.
748
1068
 
1069
+ ## FAQ
1070
+
1071
+ **How do I add one social provider?**
1072
+ Set its two environment variables. Google, GitHub, Kakao and Naver each turn on when their
1073
+ client ID and secret are both present — there is no separate registration step. Then
1074
+ register the callback URL in that provider's console, and read the next answer before you
1075
+ deploy.
1076
+
1077
+ **Social login worked locally and broke after deploying. Why?**
1078
+ Almost always the callback origin. The CSRF check is a double-submit against a host-only
1079
+ cookie set on your **web app** host, so the provider must return to the web app origin, and
1080
+ the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
1081
+ callback 404s — including in local dev. Details in
1082
+ [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1083
+
1084
+ **Does the server hold my users' private keys?**
1085
+ No. The client generates an ES256/RS256 keypair, sends only the public key on register or
1086
+ login, and signs each request itself. The server verifies with the stored public key. Keys
1087
+ expire after 90 days; `rotateKey` renews one.
1088
+
1089
+ **Does signing in on a new device sign the old one out?**
1090
+ No, and that is on purpose — keys are per-device and accumulate. `listKeys` shows the
1091
+ account owner what accumulated, `revokeKey` cuts one off, `revokeAllKeys` cuts off
1092
+ everything but the caller.
1093
+
1094
+ **How long does a session last?**
1095
+ `SPFN_AUTH_SESSION_TTL`, seven days by default. It accepts `7d`, `12h`, `45m`.
1096
+
1097
+ **Is account deletion immediate?**
1098
+ No. A request moves the account to `pending_deletion`, revokes every session key, and
1099
+ schedules the purge for 30 days later by default. The user can cancel with their
1100
+ credentials during that window. Two things need your attention: the purge sweep is a job
1101
+ you register explicitly (`.jobs(authJobRouter)`), and a purged account's email becomes
1102
+ reusable immediately. See [Account Deletion & Recovery](#account-deletion--recovery).
1103
+
1104
+ **Can an admin delete a user's account?**
1105
+ Yes, through `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
1106
+ and `purgeUserService(userId)`. The package exports the services; you own the route and its
1107
+ authorization.
1108
+
1109
+ **Where do my admin accounts come from?**
1110
+ The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1111
+ verified, active, and required to change their password on first login.
1112
+
749
1113
  ## Pitfalls & anti-patterns
750
1114
 
751
1115
  - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
@@ -831,7 +1195,9 @@ const session = await authApi.getAuthSession.call({});
831
1195
 
832
1196
  ## Related
833
1197
 
834
- - `@spfn/core` — route DSL (`route`, `defineRouter`), `createApi`, env (`@spfn/core/env`),
835
- errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
1198
+ - [`@spfn/core`](../core/README.md) — route DSL (`route`, `defineRouter`), `createApi`, env
1199
+ (`@spfn/core/env`), errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
1200
+ - [`@spfn/mcp`](../mcp/README.md) — exposes operations as MCP tools, so the operator half of
1201
+ this package needs no admin dashboard.
836
1202
  - `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
837
1203
  - Full guide: `docs/guides/authentication.md`.