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

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 +333 -18
  2. package/dist/{authenticate-C7w-pLOR.d.ts → authenticate-ZssfqJ7d.d.ts} +150 -9
  3. package/dist/client-proof.d.ts +1 -1
  4. package/dist/client-proof.js +30 -0
  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 +36 -7
  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 +195 -80
  17. package/dist/server.js +515 -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,76 @@ 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, createdAt,
217
+ // lastUsedAt?, expiresAt?, isExpired, isActive, revokedAt? }]
218
+
219
+ await authApi.listKeys.call({ body: { includeRevoked: true } }); // also what was cut off
220
+
221
+ await authApi.revokeKey.call({ body: { keyId } }); // → { keyId, selfRevoked }
222
+ await authApi.revokeAllKeys.call({ body: {} }); // other devices only
223
+ await authApi.revokeAllKeys.call({ body: { includeCurrent: true } }); // everything
224
+ ```
225
+
226
+ > **All three are POST with their arguments in the body, deliberately.** The mobile auth
227
+ > profile (clientProofV1) signs the request body, and `canonical-json` fixes exactly how those
228
+ > bytes are written. A `GET` has no body to sign, and a value in the path has no such rule —
229
+ > client and server could disagree on the signed string over percent-encoding, a trailing
230
+ > slash, or a proxy rewrite alone, and the request would be refused with nothing in the logs
231
+ > naming the cause. Every operation in the contract is shaped this way.
232
+
233
+ - **The public key never leaves the server**, and the fingerprint is truncated to 8 characters.
234
+ The list exists to recognise a device and point at it; the full fingerprint is what a native
235
+ sign-in sends as its nonce, not a label.
236
+ - **`isExpired` is computed, not stored.** Nothing flips `isActive` when the TTL runs out —
237
+ `authenticate` refuses the key at request time. A list that showed such a key as simply active
238
+ would report something the server does not act on.
239
+ - **Revoking your own key is allowed.** It is this device's sign-out, which `logout` already does.
240
+ `selfRevoked` in the response tells the two cases apart.
241
+ - **`revokeAllKeys` spares the calling device unless you ask otherwise**, so the common case is
242
+ "sign out my other devices". `includeCurrent: true` is the full sign-out — until now reachable
243
+ only as a side effect of changing a password, which nobody does for that reason.
244
+ - **A key id you do not own answers 404** (`KeyNotFoundError`). Every lookup is scoped by user, so
245
+ the answer is only ever "not yours" and reveals nothing about other accounts.
246
+ - **Revocation takes effect immediately.** `authenticate` reads the key from the database on every
247
+ request with no cache in front of it.
248
+ - **`includeRevoked: true` shows what was already cut off**, with `revokedAt`. The default is only
249
+ keys that can still sign.
250
+
251
+ Every path that registers a key (`register`, `login`, `rotateKey`, native OAuth) accepts optional
252
+ `deviceName` (≤64 chars) and `platform` (`ios` / `android` / `web` / `desktop`). Both are display
253
+ only — nothing is authorized by them — and both are absent on keys registered before they existed.
254
+ Rotation carries the replaced key's label over unless the client sends a new one.
255
+
256
+ All three are in the mobile contract (0.4.1) as `auth.keys.list` / `auth.keys.revoke` /
257
+ `auth.keys.revokeAll`, so a generated mobile client reaches them the same way it reaches key
258
+ rotation.
259
+
260
+ A `keyId` is **single-use for its lifetime**: it is unique across all users and is never reissued
261
+ once revoked. A client that logs out, rotates, or is revoked must generate a **fresh keypair and
262
+ `keyId`** for its next sign-in — resending the old one is refused with
263
+ `KeyIdAlreadyRegisteredError` (409), on every path that registers a key. Re-registering a key that
264
+ is still active is the one
265
+ exception: it stays a no-op success, so repeated logins from the same device keep working, and an
266
+ expired-but-active key has its expiry extended by the sign-in that proved the identity again.
267
+
181
268
  ### Writing protected routes (route DSL)
182
269
 
183
270
  This is the current SPFN route DSL — `route.<method>().input().use().skip().handler()` registered
@@ -361,22 +448,154 @@ its own Bearer client token by signing with the on-device private key (the same
361
448
  server-verifies model as the rest of auth).
362
449
 
363
450
  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.
451
+ Google (the web `SPFN_AUTH_GOOGLE_CLIENT_ID` is also accepted), `SPFN_AUTH_APPLE_CLIENT_IDS` for
452
+ Apple, and `SPFN_AUTH_KAKAO_NATIVE_CLIENT_IDS` for Kakao (the REST API key in
453
+ `SPFN_AUTH_KAKAO_CLIENT_ID` is also accepted). Apple is native-only here — its web OAuth
454
+ (code-exchange) methods throw.
366
455
 
367
456
  ```typescript
368
457
  await authApi.oauthNative.call({
369
- params: { provider: 'apple' }, // or 'google'
458
+ params: { provider: 'apple' }, // or 'google', 'kakao'
370
459
  body: { idToken, nonce, publicKey, keyId, fingerprint, algorithm: 'ES256', profile: { name } },
371
460
  });
372
461
  // → { userId, keyId, isNewUser }; client then signs its own ES256 Bearer token with keyId
373
462
  ```
374
463
 
464
+ Every refusal names itself. The response body carries `error.code` — the server's error class
465
+ name — alongside the usual `__type`, so a client that has no TypeScript error registry can still
466
+ tell the eleven ways this call fails apart:
467
+
468
+ | `error.code` | HTTP | What the client does |
469
+ | --- | --- | --- |
470
+ | `ValidationError` | 400 | fix the request body |
471
+ | `NativeSignInUnsupportedError` | 400 | hide that provider's native button — server configuration |
472
+ | `NonceKeyBindingError` | 400 | send `nonce === fingerprint` |
473
+ | `InvalidKeyFingerprintError` | 400 | send the SHA-256 of the submitted key |
474
+ | `UnverifiedEmailLinkError` | 400 | send the user to verify that address |
475
+ | `InvalidSocialTokenError` | 401 | obtain a fresh id_token |
476
+ | `AccountDisabledError` | 403 | show the account status |
477
+ | `AccountPendingDeletionError` | 403 | offer restore |
478
+ | `KeyIdAlreadyRegisteredError` | 409 | generate a new keyId and retry |
479
+ | `TooManyRequestsError` | 429 | **the only retry-the-same-request code** |
480
+ | `Error` | 500 | generic failure |
481
+
375
482
  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
483
+ the raw value for any provider. `profile.name` captures the name Apple returns only on first
377
484
  sign-in. Trade-off: skipping code exchange means no Apple refresh token / server-side revoke —
378
485
  revoke SPFN access by revoking the registered key instead.
379
486
 
487
+ > **The nonce must be the `fingerprint` of the key being registered.** Since contract 0.4.0 the
488
+ > server refuses the call when `nonce !== fingerprint`, or when that fingerprint is not the
489
+ > SHA-256 of the submitted `publicKey`'s DER bytes. So the client does not mint a random nonce —
490
+ > it asks the provider for a token bound to the key it is about to enroll:
491
+ >
492
+ > ```typescript
493
+ > const fingerprint = sha256Hex(derBytesOf(publicKey)); // lowercase hex, 64 chars
494
+ > const nonce = fingerprint; // what the provider echoes back
495
+ > // Apple only: put sha256Hex(nonce) in the authorization request — Apple hashes what it receives
496
+ > ```
497
+ >
498
+ > Why: an `id_token` is a bearer credential. It is not bound to the channel it came over, so
499
+ > verifying it alone means whoever holds one valid token can enroll **their own** key on **someone
500
+ > else's** account — by extracting the app key from a real app binary, from a rooted device, or
501
+ > from a leaked log. The web OAuth flow is not exposed this way: there the public key travels
502
+ > inside encrypted `state` whose nonce must match the browser's CSRF cookie. Deriving the nonce
503
+ > from the key gives the native path the same binding, because a stolen token carries the victim's
504
+ > fingerprint and cannot be re-paired with an attacker's key. Re-submitting the victim's own key
505
+ > stays possible and is worthless — the attacker has no matching private key.
506
+ >
507
+ > Naver's trailing-`A` problem (below) is satisfied for free: a SHA-256 hex digest is lowercase.
508
+
509
+ > **Generate the nonce as lowercase hex, not base64.** Naver drops a trailing `A` from a base64url
510
+ > nonce before putting it in the id_token. A 16-byte base64url value ends in one of `A Q g w` —
511
+ > its last character carries only 2 bits of data plus 4 bits of padding — so a base64 nonce fails
512
+ > verification for roughly one sign-in in four, intermittently and with nothing in the logs
513
+ > pointing at the cause.
514
+ >
515
+ > The trigger is the character `A`, not the encoding as such. **Uppercase hex ends in `A` once in
516
+ > sixteen and breaks the same way**; lowercase hex (`0-9a-f`) has no `A` in its alphabet, so it
517
+ > cannot hit the case at all. Nonce comparison is exact by design (`jwks-verify.ts`) — accepting a
518
+ > truncated value would also accept any other nonce sharing those first characters — so the fix
519
+ > belongs on the client. Confirmed on Naver; not yet measured on the other providers, and
520
+ > lowercase hex is safe for all of them.
521
+
522
+ #### The optional `accessToken`
523
+
524
+ `accessToken` is the provider access token from the same sign-in. It is **optional and
525
+ provider-specific** — the server never requires it, and a client that omits it still signs in.
526
+
527
+ Send it only when a provider's id_token cannot establish the user's **email**, which is identity
528
+ data: `createOrLinkUser` matches an existing account by verified email. Display-side profile
529
+ (name, avatar) is deliberately *not* a reason to send it — that belongs to the app, not to auth.
530
+
531
+ | Provider | Send `accessToken`? | Why |
532
+ |---|---|---|
533
+ | Google | No | id_token carries `email` + `email_verified` |
534
+ | Apple | No | same, and Apple relay addresses are already the authoritative value |
535
+ | Kakao | **Optional, recommended** | id_token carries `email` but no `email_verified`; without it the address is stored unverified |
536
+ | Naver | **Optional, recommended** | id_token carries no profile claim at all; userinfo returns the address, which carries no verification flag (see below) |
537
+
538
+ Whatever the provider, the server trusts a lookup made with this token only after the identity it
539
+ returns matches the id_token's `sub`. A mismatch, or a failed lookup, is treated as if the token
540
+ had not been sent.
541
+
542
+ **Kakao.** Enable OpenID Connect in the Kakao developer console and request the `openid` scope, or
543
+ the SDK returns no `idToken`. One Kakao app issues several keys (native app key, REST API key), and
544
+ the `aud` claim is whichever key obtained the token — so list the native app key and let the REST
545
+ API key be accepted alongside it. The `sub` (회원번호) is per-app, not per-key, so web and app
546
+ sign-ins resolve to the same user.
547
+
548
+ Kakao's id_token carries `email` but no `email_verified`, so the identity comes back **unverified**
549
+ and the account is created with a null email. To match the web flow's strength, send the
550
+ `accessToken` the SDK returned in the same sign-in as an optional body field: the server then reads
551
+ `is_email_valid` / `is_email_verified` from `/v2/user/me`. That token is client-supplied, so the
552
+ lookup is trusted only when its 회원번호 equals the id_token's `sub`; a mismatch or a failed lookup
553
+ leaves the email unverified and the sign-in still succeeds.
554
+
555
+ ```typescript
556
+ await authApi.oauthNative.call({
557
+ params: { provider: 'kakao' },
558
+ body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
559
+ });
560
+ ```
561
+
562
+ **Naver.** Naver runs two login surfaces. The web redirect flow uses `/oauth2.0/*`, which is plain
563
+ OAuth2 and issues no id_token; native verification uses the OIDC surface at `/oauth2/*`. The
564
+ `SPFN_AUTH_NAVER_CLIENT_ID` you already have is accepted as the audience — one Naver application
565
+ has a single client ID covering its web and app environments — so
566
+ `SPFN_AUTH_NAVER_NATIVE_CLIENT_IDS` is only needed when the app registers a separate application.
567
+
568
+ Naver's native SDK cannot produce an id_token: it is pinned to `/oauth2.0/*` and its authorize
569
+ request has no `scope` parameter at all. The app therefore obtains the id_token through a browser
570
+ flow (`ASWebAuthenticationSession` / Custom Tab) against `/oauth2/authorize?scope=openid` with PKCE
571
+ — `token_endpoint_auth_methods_supported` includes `none`, so no client secret is needed. The
572
+ server contract is the same whichever way the token was obtained.
573
+
574
+ The id_token carries `iss`, `aud`, `azp`, `sub`, `nonce`, `jti`, `iat`, `exp` — no email, no name,
575
+ no picture, even when the application marks email as required. Send `accessToken` to fill it: the
576
+ server reads `/v1/nid/me`, whose `id` is the same pairwise value as the id_token's `sub`, and
577
+ treats a returned address as verified (the same rule the web flow uses). `sub` being pairwise helps
578
+ here — a token from another application resolves to a different `sub` and is rejected by the match.
579
+
580
+ That verified verdict rests on one fact and it is worth stating plainly, because `createOrLinkUser`
581
+ links a social identity to an existing account on a verified address alone. The `/v1/nid/me`
582
+ response carries **no** verification flag — unlike Kakao, which reports `is_email_valid` and
583
+ `is_email_verified` and is checked against both. What Naver guarantees instead is at change time:
584
+ moving the contact email requires a code sent to the new address, so the returned value is an
585
+ address the user has proven they control. It is **not** a stable identifier: the user can change it,
586
+ one address can be shared by up to six Naver IDs, and it may be absent entirely. `providerUserId` is
587
+ the only key that identifies the account.
588
+
589
+ ```typescript
590
+ await authApi.oauthNative.call({
591
+ params: { provider: 'naver' },
592
+ body: { idToken, nonce, accessToken, publicKey, keyId, fingerprint, algorithm: 'ES256' },
593
+ });
594
+ ```
595
+
596
+ Without `accessToken` a Naver sign-in has no email at all, so every user is created fresh and never
597
+ links to an existing account.
598
+
380
599
  ### Custom providers
381
600
 
382
601
  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 +649,7 @@ receives the same account/token context and owns its key rotation policy.
430
649
  - `auth.login` / `auth.register` events now carry any `SOCIAL_PROVIDERS` value in `provider` —
431
650
  update any `switch(provider)` in subscribers.
432
651
 
433
- ## Sessions (Next.js)
652
+ ## How do I read the session in a Next.js page?
434
653
 
435
654
  Sessions are HttpOnly cookies encrypted with `SPFN_AUTH_SESSION_SECRET` (JWE), holding the
436
655
  client private key + `keyId` (`SessionData`: `{ userId, privateKey, keyId, algorithm }`). The
@@ -464,7 +683,7 @@ export default async function AdminPage()
464
683
  Also exported: `getAuthSessionData`, `getUserRole`, `getUserPermissions`, `hasAnyRole`,
465
684
  `hasAnyPermission`, the OAuth pending-session helpers, and `createOAuthCallbackHandler`.
466
685
 
467
- ## RBAC
686
+ ## How do I define roles and permissions?
468
687
 
469
688
  Built-in roles: `superadmin` (priority 100), `admin` (80), `user` (10). Built-in permissions:
470
689
  `auth:self:manage`, `user:read|write|delete|invite`, `rbac:role:manage`, `rbac:permission:manage`.
@@ -484,6 +703,55 @@ Programmatic checks (server): `hasPermission`, `hasAnyPermission`, `hasAllPermis
484
703
  `deleteRole`, `setRolePermissions`, `addPermissionToRole`, `removePermissionFromRole`,
485
704
  `getAllRoles`, `getRoleByName`, `getRolePermissions`.
486
705
 
706
+ ## Can I operate the app without building an admin dashboard?
707
+
708
+ Yes, and that is the point of the operator half of this package. The day after you deploy,
709
+ someone has to refund an order, look up a user, publish a change, retry a failed job. The
710
+ usual answer is to build screens for each of those. `@spfn/auth` already knows who your
711
+ operators are and which of them may do what; [`@spfn/mcp`](../mcp/README.md) turns those
712
+ operations into tools an AI agent can run, so the screens never get built.
713
+
714
+ The connection is app code, deliberately. `@spfn/mcp` does not read this package's RBAC on
715
+ its own — it asks you for a `validateToken` and a `listTools`, and those are where auth's
716
+ answers go:
717
+
718
+ ```typescript
719
+ import { createMcpRoute } from '@spfn/mcp/server';
720
+ import { hasPermission, getUserRole } from '@spfn/auth/server';
721
+
722
+ // one required permission per tool — the same permission names your routes check
723
+ const allTools = [
724
+ { name: 'orders.refund', permission: 'order:refund', /* … */ },
725
+ { name: 'content.publish', permission: 'post:publish', /* … */ },
726
+ ];
727
+
728
+ export const mcpRouter = createMcpRoute({
729
+ appUrl: 'https://app.example.com',
730
+ serverInfo: { name: 'example-app', version: '1.0.0' },
731
+
732
+ validateToken: async (token, resource) => verifyAccessToken(token, resource),
733
+
734
+ resolveContext: async (auth) => ({
735
+ userId: auth.userId,
736
+ role: await getUserRole(auth.userId),
737
+ }),
738
+
739
+ listTools: async (ctx) =>
740
+ {
741
+ const allowed = await Promise.all(
742
+ allTools.map(t => hasPermission(ctx.userId, t.permission)),
743
+ );
744
+
745
+ return allTools.filter((_, i) => allowed[i]);
746
+ },
747
+ });
748
+ ```
749
+
750
+ Two rules keep this safe. **Expose operations, not tables** — `orders.refund` carries an
751
+ authorization rule; a generic `db.query` carries none. And **check the permission inside
752
+ the handler too**, not only in `listTools`: hiding a tool from the list is discovery
753
+ control, not authorization.
754
+
487
755
  ## Events
488
756
 
489
757
  `@spfn/auth` emits decoupled events (via `@spfn/core/event`). Subscribe for welcome emails,
@@ -515,7 +783,8 @@ is the recommended error. The hook receives the same `metadata` the app supplied
515
783
  `register` / OAuth start / the invitation — never credentials.
516
784
 
517
785
  ```typescript
518
- import { configureAuth, RegistrationRejectedError } from '@spfn/auth/server';
786
+ import { configureAuth } from '@spfn/auth/server';
787
+ import { RegistrationRejectedError } from '@spfn/auth/errors';
519
788
 
520
789
  configureAuth({
521
790
  beforeRegister: async ({ channel, provider, email, phone, metadata }) =>
@@ -746,6 +1015,50 @@ Register **only one** of `authJobRouter` / `createAuthDeletionJobRouter(...)`
746
1015
  named `auth.deletion.purge`, so registering both (e.g. the static export *and* a custom-cron
747
1016
  router) double-registers the same job name against pg-boss instead of overriding it.
748
1017
 
1018
+ ## FAQ
1019
+
1020
+ **How do I add one social provider?**
1021
+ Set its two environment variables. Google, GitHub, Kakao and Naver each turn on when their
1022
+ client ID and secret are both present — there is no separate registration step. Then
1023
+ register the callback URL in that provider's console, and read the next answer before you
1024
+ deploy.
1025
+
1026
+ **Social login worked locally and broke after deploying. Why?**
1027
+ Almost always the callback origin. The CSRF check is a double-submit against a host-only
1028
+ cookie set on your **web app** host, so the provider must return to the web app origin, and
1029
+ the app must forward `/_auth/*` to the API with a Next.js rewrite. Without that rewrite the
1030
+ callback 404s — including in local dev. Details in
1031
+ [OAuth callback origin](#oauth-callback-origin-web-app-host--rewrite).
1032
+
1033
+ **Does the server hold my users' private keys?**
1034
+ No. The client generates an ES256/RS256 keypair, sends only the public key on register or
1035
+ login, and signs each request itself. The server verifies with the stored public key. Keys
1036
+ expire after 90 days; `rotateKey` renews one.
1037
+
1038
+ **Does signing in on a new device sign the old one out?**
1039
+ No, and that is on purpose — keys are per-device and accumulate. `listKeys` shows the
1040
+ account owner what accumulated, `revokeKey` cuts one off, `revokeAllKeys` cuts off
1041
+ everything but the caller.
1042
+
1043
+ **How long does a session last?**
1044
+ `SPFN_AUTH_SESSION_TTL`, seven days by default. It accepts `7d`, `12h`, `45m`.
1045
+
1046
+ **Is account deletion immediate?**
1047
+ No. A request moves the account to `pending_deletion`, revokes every session key, and
1048
+ schedules the purge for 30 days later by default. The user can cancel with their
1049
+ credentials during that window. Two things need your attention: the purge sweep is a job
1050
+ you register explicitly (`.jobs(authJobRouter)`), and a purged account's email becomes
1051
+ reusable immediately. See [Account Deletion & Recovery](#account-deletion--recovery).
1052
+
1053
+ **Can an admin delete a user's account?**
1054
+ Yes, through `requestAccountDeletionService(userId, { requestedBy: 'admin', immediate })`
1055
+ and `purgeUserService(userId)`. The package exports the services; you own the route and its
1056
+ authorization.
1057
+
1058
+ **Where do my admin accounts come from?**
1059
+ The environment, seeded on startup by `createAuthLifecycle()`. Seeded accounts are email
1060
+ verified, active, and required to change their password on first login.
1061
+
749
1062
  ## Pitfalls & anti-patterns
750
1063
 
751
1064
  - **"relation \"auth.users\" does not exist" — tables come from bundled migrations, not push.**
@@ -831,7 +1144,9 @@ const session = await authApi.getAuthSession.call({});
831
1144
 
832
1145
  ## Related
833
1146
 
834
- - `@spfn/core` — route DSL (`route`, `defineRouter`), `createApi`, env (`@spfn/core/env`),
835
- errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
1147
+ - [`@spfn/core`](../core/README.md) — route DSL (`route`, `defineRouter`), `createApi`, env
1148
+ (`@spfn/core/env`), errors (`ErrorRegistry`), db (`Transactional`), events, jobs.
1149
+ - [`@spfn/mcp`](../mcp/README.md) — exposes operations as MCP tools, so the operator half of
1150
+ this package needs no admin dashboard.
836
1151
  - `@spfn/notification` — email/SMS/push (verification codes, invitation emails).
837
1152
  - Full guide: `docs/guides/authentication.md`.