@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.
- package/README.md +333 -18
- package/dist/{authenticate-C7w-pLOR.d.ts → authenticate-ZssfqJ7d.d.ts} +150 -9
- package/dist/client-proof.d.ts +1 -1
- package/dist/client-proof.js +30 -0
- package/dist/client-proof.js.map +1 -1
- package/dist/config.d.ts +36 -0
- package/dist/config.js +18 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +93 -2
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +36 -7
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +2 -2
- package/dist/server.d.ts +195 -80
- package/dist/server.js +515 -106
- package/dist/server.js.map +1 -1
- package/dist/{session-CGxgH3C9.d.ts → session-CFK4BT25.d.ts} +1 -1
- package/dist/{types-1BMx0OX1.d.ts → types-CD95yudz.d.ts} +15 -1
- package/migrations/20251125021229_premium_famine/snapshot.json +2641 -0
- package/migrations/20260225130050_smooth_the_fury/snapshot.json +2686 -0
- package/migrations/20260308141417_deep_iceman/snapshot.json +2686 -0
- package/migrations/20260308151309_perfect_deathbird/snapshot.json +2731 -0
- package/migrations/20260308201135_concerned_rawhide_kid/snapshot.json +2786 -0
- package/migrations/20260629103209_lethal_lifeguard/snapshot.json +2786 -0
- package/migrations/20260709073531_easy_hardball/snapshot.json +3119 -0
- package/migrations/20260714081434_glossy_major_mapleleaf/snapshot.json +3112 -0
- package/migrations/20260804105939_amazing_bushwacker/migration.sql +3 -0
- package/migrations/20260804105939_amazing_bushwacker/snapshot.json +3112 -0
- package/migrations/20260804110033_fat_piledriver/migration.sql +2 -0
- package/migrations/20260804110033_fat_piledriver/snapshot.json +3138 -0
- package/package.json +3 -6
- package/migrations/meta/0000_snapshot.json +0 -1632
- package/migrations/meta/0001_snapshot.json +0 -1660
- package/migrations/meta/0002_snapshot.json +0 -1660
- package/migrations/meta/0003_snapshot.json +0 -1689
- package/migrations/meta/0004_snapshot.json +0 -1721
- package/migrations/meta/0005_snapshot.json +0 -1721
- package/migrations/meta/0006_snapshot.json +0 -1921
- package/migrations/meta/0007_snapshot.json +0 -1916
- package/migrations/meta/_journal.json +0 -62
- /package/migrations/{0000_premium_famine.sql → 20251125021229_premium_famine/migration.sql} +0 -0
- /package/migrations/{0001_smooth_the_fury.sql → 20260225130050_smooth_the_fury/migration.sql} +0 -0
- /package/migrations/{0002_deep_iceman.sql → 20260308141417_deep_iceman/migration.sql} +0 -0
- /package/migrations/{0003_perfect_deathbird.sql → 20260308151309_perfect_deathbird/migration.sql} +0 -0
- /package/migrations/{0004_concerned_rawhide_kid.sql → 20260308201135_concerned_rawhide_kid/migration.sql} +0 -0
- /package/migrations/{0005_lethal_lifeguard.sql → 20260629103209_lethal_lifeguard/migration.sql} +0 -0
- /package/migrations/{0006_easy_hardball.sql → 20260709073531_easy_hardball/migration.sql} +0 -0
- /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
|
|
1
|
+
# @spfn/auth
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
##
|
|
52
|
+
## How do I add auth to an SPFN app?
|
|
35
53
|
|
|
36
|
-
|
|
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
|
-
##
|
|
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)
|
|
365
|
-
Apple
|
|
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
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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`.
|