@spfn/auth 0.3.0-beta.5 → 0.3.0-beta.7
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 +146 -0
- package/dist/{authenticate-Ctul07Sc.d.ts → authenticate-Mg9D7Nys.d.ts} +174 -7
- package/dist/config.d.ts +60 -0
- package/dist/config.js +27 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +39 -2
- package/dist/errors.js +24 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +33 -6
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -1
- package/dist/nextjs/api.js +58 -1
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.js +4 -0
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +359 -85
- package/dist/server.js +800 -368
- package/dist/server.js.map +1 -1
- package/migrations/20260810112144_colorful_tomorrow_man/migration.sql +18 -0
- package/migrations/20260810112144_colorful_tomorrow_man/snapshot.json +3576 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -154,6 +154,8 @@ real secret values out of band, never commit them.
|
|
|
154
154
|
| `SPFN_AUTH_OAUTH_SUCCESS_URL` | `.env.server` | — | default `/auth/callback` |
|
|
155
155
|
| `SPFN_AUTH_OAUTH_ERROR_URL` | `.env.server` | — | default `/auth/error?error={error}` |
|
|
156
156
|
| `SPFN_AUTH_RESERVED_USERNAMES` / `_USERNAME_MIN_LENGTH` / `_USERNAME_MAX_LENGTH` | `.env.server` | — | username rules |
|
|
157
|
+
| `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` / `_SETUP_TTL_MINUTES` | `.env.server` | — | defaults `30` / `15` — see [Verified-email signup](#verified-email-signup) |
|
|
158
|
+
| `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `.env.server` | — | default `/signup/confirm`; the page in your app the emailed link opens |
|
|
157
159
|
| `NEXT_PUBLIC_SPFN_API_URL` / `NEXT_PUBLIC_SPFN_APP_URL` | `.env.local` | — | browser-facing URLs for OAuth redirects |
|
|
158
160
|
|
|
159
161
|
Read validated values via `import { env } from '@spfn/auth/config'` (a proxy validated at
|
|
@@ -178,6 +180,9 @@ routes use `.skip(['auth'])`; the rest require `Authorization: Bearer <client-si
|
|
|
178
180
|
| `sendVerificationCode` | POST `/_auth/codes` | public | send 6-digit OTP |
|
|
179
181
|
| `verifyCode` | POST `/_auth/codes/verify` | public | verify OTP → verification token |
|
|
180
182
|
| `register` | POST `/_auth/register` | public | create user + register public key |
|
|
183
|
+
| `requestSignupLink` | POST `/_auth/signup/email` | public | email a one-time signup confirmation link — see [Verified-email signup](#verified-email-signup) |
|
|
184
|
+
| `confirmSignupLink` | POST `/_auth/signup/email/confirm` | public | exchange the link for a password-setup session |
|
|
185
|
+
| `completeSignup` | POST `/_auth/signup/password` | setup session | set the password, which creates the account and signs in |
|
|
181
186
|
| `login` | POST `/_auth/login` | public | password login + new session key |
|
|
182
187
|
| `logout` | POST `/_auth/logout` | yes | revoke current key |
|
|
183
188
|
| `rotateKey` | POST `/_auth/keys/rotate` | yes | rotate public key before 90-day expiry |
|
|
@@ -205,6 +210,83 @@ sends the public key on register/login, signs request JWTs locally, and the serv
|
|
|
205
210
|
with the stored public key (`keyId` carried in the JWT). The server never holds a private key.
|
|
206
211
|
Keys expire after 90 days — rotate with `rotateKey`.
|
|
207
212
|
|
|
213
|
+
### Verified-email signup
|
|
214
|
+
|
|
215
|
+
A second way in, alongside the six-digit code. The address is proven before a password
|
|
216
|
+
exists, so nothing is stored for someone who never confirms.
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
request → a one-time link is emailed
|
|
220
|
+
confirm → the link becomes a short-lived, HttpOnly password-setup session
|
|
221
|
+
password → the account is created, the device registered, the user signed in
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The six-digit-code path (`sendVerificationCode` → `verifyCode` → `register`) is unchanged.
|
|
225
|
+
Offer whichever suits your product, or both.
|
|
226
|
+
|
|
227
|
+
**1 — request the link.** The response is identical whether or not the address already has
|
|
228
|
+
an account, so it cannot be used to probe for accounts. When one exists, the owner gets a
|
|
229
|
+
"you already have an account" notice instead of a usable link.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
await authApi.requestSignupLink.call({
|
|
233
|
+
body: { email: 'user@example.com', returnPath: '/welcome' }, // returnPath optional
|
|
234
|
+
});
|
|
235
|
+
// → { success: true, expiresAt }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Calling it again is how a resend works: it invalidates the previous link and any setup
|
|
239
|
+
session opened from it. `returnPath` must be a path inside your app — absolute URLs,
|
|
240
|
+
`//host`, and `..` are refused, so the link cannot become an open redirect.
|
|
241
|
+
|
|
242
|
+
**2 — the page the link opens.** The email points at a page in *your* app
|
|
243
|
+
(`SPFN_AUTH_SIGNUP_CONFIRM_PATH`, default `/signup/confirm`), not at an API route. That page
|
|
244
|
+
reads the token from the query string and posts it:
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
'use client';
|
|
248
|
+
|
|
249
|
+
const token = useSearchParams().get('token');
|
|
250
|
+
|
|
251
|
+
const { email, returnPath } = await authApi.confirmSignupLink.call({ body: { token } });
|
|
252
|
+
|
|
253
|
+
// Drop the token from the URL so it does not linger in history or a Referer header.
|
|
254
|
+
window.history.replaceState({}, '', window.location.pathname);
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The setup session comes back as an HttpOnly cookie — the proxy interceptor moves it there
|
|
258
|
+
and strips it from the response body, so page script never holds it. Serve this page with
|
|
259
|
+
`Referrer-Policy: no-referrer`.
|
|
260
|
+
|
|
261
|
+
**3 — set the password.** This is the step that creates the account. The setup cookie
|
|
262
|
+
authorizes it; the device keypair is injected by the interceptor exactly as it is for
|
|
263
|
+
`register`.
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
await authApi.completeSignup.call({ body: { password } });
|
|
267
|
+
// → { userId, publicId, email } + session cookie, same as register
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Creating the user, registering the device key, and marking the setup session used all commit
|
|
271
|
+
together. A password that fails the strength policy leaves the session usable, so the user
|
|
272
|
+
retypes rather than requesting a fresh email.
|
|
273
|
+
|
|
274
|
+
**Settings.**
|
|
275
|
+
|
|
276
|
+
| Variable | Default | Meaning |
|
|
277
|
+
|----------|---------|---------|
|
|
278
|
+
| `SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES` | `30` | how long the emailed link works |
|
|
279
|
+
| `SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES` | `15` | how long the password-setup session works |
|
|
280
|
+
| `SPFN_AUTH_SIGNUP_CONFIRM_PATH` | `/signup/confirm` | the page in your app the link opens |
|
|
281
|
+
|
|
282
|
+
The link URL is built on `NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL`, the same resolution the
|
|
283
|
+
OAuth callbacks use. Delivery uses the `signup-link` template in `@spfn/notification` —
|
|
284
|
+
override it there to change the copy.
|
|
285
|
+
|
|
286
|
+
**What is stored.** Only SHA-256 hashes of the link token and the setup secret, in
|
|
287
|
+
`spfn_auth.signup_link_tokens`. Neither credential is recoverable from the database, and
|
|
288
|
+
both are one-time: a link opens one setup session, and a setup session sets one password.
|
|
289
|
+
|
|
208
290
|
### Registered devices (key management)
|
|
209
291
|
|
|
210
292
|
Keys are per-device, so a login never revokes the previous key and they accumulate on purpose.
|
|
@@ -1096,6 +1178,70 @@ app.post('/v1/echo', createClientProofGuard(state), (c) => { /* handler */ });
|
|
|
1096
1178
|
Responses and errors MUST be canonical bytes with the contract envelope — build them with
|
|
1097
1179
|
`encodeCanonicalJson`/`ClientProofRefusal`, never `c.json()` (key order and int64 differ).
|
|
1098
1180
|
|
|
1181
|
+
## Custom auth profiles (`registerAuthProfile`)
|
|
1182
|
+
|
|
1183
|
+
`clientProofV1` is not a special case in the middleware — it is one entry in a registry
|
|
1184
|
+
`authenticate` and `optionalAuth` dispatch on. An app registers its own scheme the same way,
|
|
1185
|
+
without forking the middleware or wrapping it:
|
|
1186
|
+
|
|
1187
|
+
```typescript
|
|
1188
|
+
import { registerAuthProfile, type AuthContext } from '@spfn/auth/server';
|
|
1189
|
+
import { UnauthorizedError } from '@spfn/core/errors';
|
|
1190
|
+
|
|
1191
|
+
// At boot — server.config.ts, before the server starts taking requests.
|
|
1192
|
+
registerAuthProfile('serviceTokenV1', {
|
|
1193
|
+
verify: async (c): Promise<AuthContext> =>
|
|
1194
|
+
{
|
|
1195
|
+
const user = await findServiceAccount(c.req.header('x-acme-service-token'));
|
|
1196
|
+
if (user === null)
|
|
1197
|
+
{
|
|
1198
|
+
// A refusal leaves the verifier as a throw. It reaches the app's
|
|
1199
|
+
// error handler exactly as the Bearer path's does.
|
|
1200
|
+
throw new UnauthorizedError({ message: 'Invalid service token' });
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
return {
|
|
1204
|
+
user,
|
|
1205
|
+
userId: String(user.id),
|
|
1206
|
+
keyId: 'service-token',
|
|
1207
|
+
role: null,
|
|
1208
|
+
locale: 'en',
|
|
1209
|
+
scheme: 'serviceTokenV1',
|
|
1210
|
+
};
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
```
|
|
1214
|
+
|
|
1215
|
+
A request naming the profile is then answered by that verifier:
|
|
1216
|
+
|
|
1217
|
+
```http
|
|
1218
|
+
POST /v1/reports
|
|
1219
|
+
x-spfn-auth-profile: serviceTokenV1
|
|
1220
|
+
x-acme-service-token: <the app's own credential>
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
- **Register at boot, before the first request.** The registry is read on every dispatch, so a
|
|
1224
|
+
profile registered later is simply a profile the requests before it did not have. Registration
|
|
1225
|
+
is not frozen after startup — it is a contract, not a runtime check.
|
|
1226
|
+
- **A duplicate name throws**, `clientProofV1` included. Replacing a registered verifier silently
|
|
1227
|
+
is how an import order or a copied profile name swaps the code that decides who is admitted, so
|
|
1228
|
+
there is no override — and no unregistration API for the same reason.
|
|
1229
|
+
- **The verifier must expose a callable `verify`**, and what it resolves must carry a `userId` — a
|
|
1230
|
+
verifier that cannot admit anyone is refused at boot, and a resolve without a principal (`null`,
|
|
1231
|
+
the JS idiom for "no user") is refused as a throw rather than routed as authenticated.
|
|
1232
|
+
- **An unknown profile is still refused** (`PROFILE_REJECTED`, 400): registering one name does not
|
|
1233
|
+
open the header to others.
|
|
1234
|
+
- **Mixing is still refused.** A request carrying both `x-spfn-auth-profile` and `Authorization` is
|
|
1235
|
+
rejected before either path runs; a custom verifier never sees it.
|
|
1236
|
+
- **A verifier's throw propagates**, and only the internal clientProofV1 contract refusal is
|
|
1237
|
+
answered with the canonical envelope. Under `optionalAuth` too: credentials that were presented
|
|
1238
|
+
and refused are never downgraded to anonymous passage — only "presented nothing" continues
|
|
1239
|
+
without an auth context.
|
|
1240
|
+
- **`AuthContext.scheme` is an open union** — `'bearer' | 'clientProofV1' | 'oneTimeToken' | (string
|
|
1241
|
+
& {})`. The built-in names keep their autocomplete and a registered profile names its own scheme.
|
|
1242
|
+
The field stays informational: downstream permission and tenant code takes one principal shape and
|
|
1243
|
+
never branches on how it was produced.
|
|
1244
|
+
|
|
1099
1245
|
## Account Deletion & Recovery
|
|
1100
1246
|
|
|
1101
1247
|
Grace-period deletion with in-window recovery, an admin/GDPR-response entry point for immediate
|
|
@@ -214,6 +214,93 @@ declare function verifyCodeService(params: VerifyCodeParams): Promise<{
|
|
|
214
214
|
verificationToken: string;
|
|
215
215
|
}>;
|
|
216
216
|
|
|
217
|
+
/**
|
|
218
|
+
* @spfn/auth - Verified-Email Signup Service
|
|
219
|
+
*
|
|
220
|
+
* A signup where the address is proven before a password exists:
|
|
221
|
+
*
|
|
222
|
+
* request -> a one-time link is emailed
|
|
223
|
+
* confirm -> the link is exchanged for a short-lived password-setup session
|
|
224
|
+
* password -> the account is created, the device registered, the user signed in
|
|
225
|
+
*
|
|
226
|
+
* The link token and the setup secret are bearer credentials, so neither is ever
|
|
227
|
+
* stored. Only their SHA-256 hashes are, and lookup is by hash. A database dump
|
|
228
|
+
* therefore yields nothing that can be presented to either step.
|
|
229
|
+
*
|
|
230
|
+
* The six-digit-code registration path is untouched and remains the default; this
|
|
231
|
+
* is a second entry point to the same account creation, not a replacement.
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether a return path can be handed back to the browser.
|
|
236
|
+
*
|
|
237
|
+
* Only a path within the app is allowed. The rejected shapes are the ones that
|
|
238
|
+
* turn a return path into an open redirect: an absolute URL, a protocol-relative
|
|
239
|
+
* `//host` that a browser reads as another origin, a backslash that some
|
|
240
|
+
* browsers normalize into a slash, and any `..` traversal.
|
|
241
|
+
*/
|
|
242
|
+
declare function isSafeReturnPath(returnPath: string): boolean;
|
|
243
|
+
interface RequestSignupLinkParams {
|
|
244
|
+
email: string;
|
|
245
|
+
returnPath?: string;
|
|
246
|
+
}
|
|
247
|
+
interface RequestSignupLinkResult {
|
|
248
|
+
success: boolean;
|
|
249
|
+
expiresAt: string;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Step 1 — issue a confirmation link for an address.
|
|
253
|
+
*
|
|
254
|
+
* Answers identically whether or not the address already has an account. When it
|
|
255
|
+
* does, the owner gets a notice instead of a usable link, through the same
|
|
256
|
+
* dedupe window the six-digit-code path uses.
|
|
257
|
+
*
|
|
258
|
+
* Requesting again is how a resend works: every live link for the address is
|
|
259
|
+
* superseded first, so the newest link is the only one that opens, and any setup
|
|
260
|
+
* session already opened from an older link dies with it.
|
|
261
|
+
*/
|
|
262
|
+
declare function requestSignupLinkService(params: RequestSignupLinkParams): Promise<RequestSignupLinkResult>;
|
|
263
|
+
interface ConfirmSignupLinkParams {
|
|
264
|
+
token: string;
|
|
265
|
+
}
|
|
266
|
+
interface ConfirmSignupLinkResult {
|
|
267
|
+
email: string;
|
|
268
|
+
returnPath: string | null;
|
|
269
|
+
/** Handed to the proxy interceptor, which moves it into an HttpOnly cookie. */
|
|
270
|
+
setupSecret: string;
|
|
271
|
+
setupExpiresAt: string;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Step 2 — exchange a link for a password-setup session.
|
|
275
|
+
*
|
|
276
|
+
* Nothing binds the row to a device or a browser, which is what lets someone
|
|
277
|
+
* request the link on a laptop and open it on a phone.
|
|
278
|
+
*/
|
|
279
|
+
declare function confirmSignupLinkService(params: ConfirmSignupLinkParams): Promise<ConfirmSignupLinkResult>;
|
|
280
|
+
interface CompleteSignupParams {
|
|
281
|
+
setupSecret?: string;
|
|
282
|
+
password: string;
|
|
283
|
+
publicKey: string;
|
|
284
|
+
keyId: string;
|
|
285
|
+
fingerprint: string;
|
|
286
|
+
algorithm?: KeyAlgorithmType;
|
|
287
|
+
deviceName?: string;
|
|
288
|
+
platform?: KeyPlatformType;
|
|
289
|
+
metadata?: Record<string, unknown>;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Step 3 — set the password, which is what creates the account.
|
|
293
|
+
*
|
|
294
|
+
* Run under `Transactional()`: the user row, the device key and the completion
|
|
295
|
+
* mark commit together. A device-key failure must not leave an account nobody
|
|
296
|
+
* can sign into, and a completion mark must not survive a rolled-back account.
|
|
297
|
+
*
|
|
298
|
+
* A refusal that is the user's to fix — a weak password, an app policy that
|
|
299
|
+
* rejects the registration — leaves the setup session usable, so the fix is
|
|
300
|
+
* retyping the password rather than requesting a fresh email.
|
|
301
|
+
*/
|
|
302
|
+
declare function completeSignupService(params: CompleteSignupParams): Promise<RegisterResult>;
|
|
303
|
+
|
|
217
304
|
/**
|
|
218
305
|
* @spfn/auth - Key Service
|
|
219
306
|
*
|
|
@@ -814,6 +901,33 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
814
901
|
platform: _sinclair_typebox.TOptional<_sinclair_typebox.TUnion<_sinclair_typebox.TLiteral<"ios" | "android" | "web" | "desktop">[]>>;
|
|
815
902
|
}>;
|
|
816
903
|
}, RegisterResult>;
|
|
904
|
+
requestSignupLink: _spfn_core_route.RouteDef<{
|
|
905
|
+
body: _sinclair_typebox.TObject<{
|
|
906
|
+
email: _sinclair_typebox.TString;
|
|
907
|
+
returnPath: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
908
|
+
}>;
|
|
909
|
+
}, {}, RequestSignupLinkResult>;
|
|
910
|
+
confirmSignupLink: _spfn_core_route.RouteDef<{
|
|
911
|
+
body: _sinclair_typebox.TObject<{
|
|
912
|
+
token: _sinclair_typebox.TString;
|
|
913
|
+
}>;
|
|
914
|
+
}, {}, ConfirmSignupLinkResult>;
|
|
915
|
+
completeSignup: _spfn_core_route.RouteDef<{
|
|
916
|
+
body: _sinclair_typebox.TObject<{
|
|
917
|
+
password: _sinclair_typebox.TString;
|
|
918
|
+
metadata: _sinclair_typebox.TOptional<_sinclair_typebox.TRecord<_sinclair_typebox.TString, _sinclair_typebox.TUnknown>>;
|
|
919
|
+
}>;
|
|
920
|
+
}, {
|
|
921
|
+
body: _sinclair_typebox.TObject<{
|
|
922
|
+
setupSecret: _sinclair_typebox.TString;
|
|
923
|
+
publicKey: _sinclair_typebox.TString;
|
|
924
|
+
keyId: _sinclair_typebox.TString;
|
|
925
|
+
fingerprint: _sinclair_typebox.TString;
|
|
926
|
+
algorithm: _sinclair_typebox.TUnion<_sinclair_typebox.TLiteral<"ES256" | "RS256">[]>;
|
|
927
|
+
deviceName: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
928
|
+
platform: _sinclair_typebox.TOptional<_sinclair_typebox.TUnion<_sinclair_typebox.TLiteral<"ios" | "android" | "web" | "desktop">[]>>;
|
|
929
|
+
}>;
|
|
930
|
+
}, RegisterResult>;
|
|
817
931
|
login: _spfn_core_route.RouteDef<{
|
|
818
932
|
body: _sinclair_typebox.TObject<{
|
|
819
933
|
email: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
@@ -879,7 +993,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
879
993
|
id: number;
|
|
880
994
|
name: string;
|
|
881
995
|
displayName: string;
|
|
882
|
-
category: "
|
|
996
|
+
category: "custom" | "user" | "auth" | "rbac" | "system" | undefined;
|
|
883
997
|
}[];
|
|
884
998
|
userId: number;
|
|
885
999
|
publicId: string;
|
|
@@ -1179,8 +1293,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1179
1293
|
}, {}, {
|
|
1180
1294
|
roles: {
|
|
1181
1295
|
description: string | null;
|
|
1182
|
-
name: string;
|
|
1183
1296
|
id: number;
|
|
1297
|
+
name: string;
|
|
1184
1298
|
displayName: string;
|
|
1185
1299
|
isBuiltin: boolean;
|
|
1186
1300
|
isSystem: boolean;
|
|
@@ -1201,8 +1315,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1201
1315
|
}, {}, {
|
|
1202
1316
|
role: {
|
|
1203
1317
|
description: string | null;
|
|
1204
|
-
name: string;
|
|
1205
1318
|
id: number;
|
|
1319
|
+
name: string;
|
|
1206
1320
|
displayName: string;
|
|
1207
1321
|
isBuiltin: boolean;
|
|
1208
1322
|
isSystem: boolean;
|
|
@@ -1225,8 +1339,8 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1225
1339
|
}, {}, {
|
|
1226
1340
|
role: {
|
|
1227
1341
|
description: string | null;
|
|
1228
|
-
name: string;
|
|
1229
1342
|
id: number;
|
|
1343
|
+
name: string;
|
|
1230
1344
|
displayName: string;
|
|
1231
1345
|
isBuiltin: boolean;
|
|
1232
1346
|
isSystem: boolean;
|
|
@@ -1310,6 +1424,11 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
1310
1424
|
* downstream permission/tenant code consumes one principal shape and never
|
|
1311
1425
|
* branches on how it was authenticated.
|
|
1312
1426
|
*
|
|
1427
|
+
* An app adds its own scheme with `registerAuthProfile` at boot. The dispatch
|
|
1428
|
+
* it joins is the one below, unchanged: a name nobody registered is still
|
|
1429
|
+
* refused, and profile credentials mixed with an Authorization header are
|
|
1430
|
+
* still refused before either path runs.
|
|
1431
|
+
*
|
|
1313
1432
|
* The clientProofV1 verifier reuses the phase-1 admission pieces (header
|
|
1314
1433
|
* shape, canonical body, proof-input assembly, ECDSA verification) with two
|
|
1315
1434
|
* production substitutions: the key directory is `user_public_keys` via
|
|
@@ -1330,8 +1449,13 @@ interface AuthContext {
|
|
|
1330
1449
|
keyId: string;
|
|
1331
1450
|
role: string | null;
|
|
1332
1451
|
locale: string;
|
|
1333
|
-
/**
|
|
1334
|
-
|
|
1452
|
+
/**
|
|
1453
|
+
* How the principal was authenticated. Informational — downstream code
|
|
1454
|
+
* never branches on it. The union stays open for the profiles an app
|
|
1455
|
+
* registers itself: the built-in names keep their autocomplete, and a
|
|
1456
|
+
* registered profile names its own scheme without editing this file.
|
|
1457
|
+
*/
|
|
1458
|
+
scheme: 'bearer' | 'clientProofV1' | 'oneTimeToken' | (string & {});
|
|
1335
1459
|
}
|
|
1336
1460
|
/** A profile's verifier: admits the request and returns the principal, or throws. */
|
|
1337
1461
|
interface AuthProfileVerifier {
|
|
@@ -1378,6 +1502,49 @@ type AuthProfileOutcome = {
|
|
|
1378
1502
|
* handed to the generic error handler is classified by its class name instead.
|
|
1379
1503
|
*/
|
|
1380
1504
|
declare function runAuthProfile(c: Context): Promise<AuthProfileOutcome>;
|
|
1505
|
+
/**
|
|
1506
|
+
* Registers an app's own verifier under a profile name.
|
|
1507
|
+
*
|
|
1508
|
+
* Call it at boot, before the first request: the registry is a module-global
|
|
1509
|
+
* read on every dispatch, so a profile registered later is simply a profile
|
|
1510
|
+
* the requests before it did not have. There is no freeze and no
|
|
1511
|
+
* unregistration — an auth surface that can be rearranged at runtime is a
|
|
1512
|
+
* surface an app bug can rearrange.
|
|
1513
|
+
*
|
|
1514
|
+
* A duplicate name throws rather than replacing the verifier that holds it,
|
|
1515
|
+
* `clientProofV1` included. A silent override is how a second import order, or
|
|
1516
|
+
* a copied profile name, quietly swaps the code that decides who is admitted.
|
|
1517
|
+
*
|
|
1518
|
+
* The verifier must expose a callable `verify` — a value that cannot admit
|
|
1519
|
+
* anyone is refused at boot rather than becoming a registry entry the dispatch
|
|
1520
|
+
* reads as "no profile header", which is anonymous passage under
|
|
1521
|
+
* `optionalAuth` for a request that presented profile credentials.
|
|
1522
|
+
*
|
|
1523
|
+
* The verifier returns the same `AuthContext` the Bearer path sets and refuses
|
|
1524
|
+
* by throwing. A resolve that carries no `userId` is refused as a throw too —
|
|
1525
|
+
* "no user" is a refusal, never a principal. A throw is not caught here:
|
|
1526
|
+
* `runAuthProfile` answers the internal clientProofV1 contract refusal and
|
|
1527
|
+
* nothing else, so a verifier's own error reaches the app's generic error
|
|
1528
|
+
* handler exactly as the Bearer path's `UnauthorizedError` does — and never
|
|
1529
|
+
* becomes anonymous passage, not even under `optionalAuth`.
|
|
1530
|
+
*
|
|
1531
|
+
* @example
|
|
1532
|
+
* ```typescript
|
|
1533
|
+
* registerAuthProfile('serviceTokenV1', {
|
|
1534
|
+
* verify: async (c) =>
|
|
1535
|
+
* {
|
|
1536
|
+
* const user = await authenticateServiceToken(c.req.header('x-acme-service-token'));
|
|
1537
|
+
* if (user === null)
|
|
1538
|
+
* {
|
|
1539
|
+
* throw new UnauthorizedError({ message: 'Invalid service token' });
|
|
1540
|
+
* }
|
|
1541
|
+
*
|
|
1542
|
+
* return { user, userId: String(user.id), keyId: 'service', role: null, locale: 'en', scheme: 'serviceTokenV1' };
|
|
1543
|
+
* },
|
|
1544
|
+
* });
|
|
1545
|
+
* ```
|
|
1546
|
+
*/
|
|
1547
|
+
declare function registerAuthProfile(profileId: string, verifier: AuthProfileVerifier): void;
|
|
1381
1548
|
|
|
1382
1549
|
declare module 'hono' {
|
|
1383
1550
|
interface ContextVariableMap {
|
|
@@ -1444,4 +1611,4 @@ declare const authenticate: _spfn_core_route.NamedMiddleware<"auth">;
|
|
|
1444
1611
|
*/
|
|
1445
1612
|
declare const optionalAuth: _spfn_core_route.NamedMiddleware<"optionalAuth">;
|
|
1446
1613
|
|
|
1447
|
-
export { type
|
|
1614
|
+
export { type SendVerificationCodeParams as $, type AuthInitOptions as A, type OAuthCodeExchangeOptions as B, type ConfirmSignupLinkResult as C, DeviceNameSchema as D, EmailSchema as E, type OAuthNativeParams as F, type OAuthStartParams as G, type OAuthTokens as H, type IssueOneTimeTokenResult as I, PasswordSchema as J, type KeySummary as K, type LoginResult as L, PhoneSchema as M, type NativeVerifyOptions as N, type OAuthStartResult as O, type PermissionConfig as P, PlatformSchema as Q, type RoleConfig as R, type SendVerificationCodeResult as S, type RegisterParams as T, type UserProfile as U, VERIFICATION_PURPOSES as V, type RegisterPublicKeyParams as W, type RequestSignupLinkParams as X, type RevokeAllKeysParams as Y, type RevokeKeyParams as Z, type RotateKeyParams as _, type RegisterResult as a, TargetTypeSchema as a0, type UnlinkNotification as a1, UnlinkNotifyRejection as a2, type UnlinkNotifyRequest as a3, type UnlinkNotifyResult as a4, VerificationPurposeSchema as a5, type VerifyCodeParams as a6, type VerifyCodeResult as a7, authenticate as a8, buildOAuthErrorUrl as a9, revokeKeyService as aA, rotateKeyService as aB, runAuthProfile as aC, selectAuthProfile as aD, sendVerificationCodeService as aE, verifyCodeService as aF, verifyOneTimeTokenService as aG, changePasswordService as aa, completeSignupService as ab, confirmSignupLinkService as ac, getEnabledOAuthProviders as ad, getGoogleAccessToken as ae, getOAuthProvider as af, getRegisteredProviders as ag, isOAuthProviderEnabled as ah, isSafeReturnPath as ai, issueOneTimeTokenService as aj, listKeysService as ak, loginService as al, logoutService as am, oauthCallbackService as an, oauthNativeService as ao, oauthStartService as ap, oauthUnlinkNotifyService as aq, optionalAuth as ar, registerAuthProfile as as, registerOAuthProvider as at, registerPublicKeyService as au, registerService as av, requestSignupLinkService as aw, requireEnabledProvider as ax, resolveAuthenticatedUser as ay, revokeAllKeysService as az, type RequestSignupLinkResult as b, type RotateKeyResult as c, type RevokeAllKeysResult as d, type OAuthNativeResult as e, type ProfileInfo as f, type AuthSession as g, PERMISSION_CATEGORIES as h, type PermissionCategory as i, VERIFICATION_TARGET_TYPES as j, type VerificationPurpose as k, type VerificationTargetType as l, mainAuthRouter as m, type OAuthProvider as n, type AuthContext as o, type AuthProfileOutcome as p, type AuthProfileVerifier as q, type ChangePasswordParams as r, type CompleteSignupParams as s, type ConfirmSignupLinkParams as t, KEY_FINGERPRINT_PREFIX_LENGTH as u, type LoginParams as v, type LogoutParams as w, type NormalizedIdentity as x, type OAuthCallbackParams as y, type OAuthCallbackResult as z };
|
package/dist/config.d.ts
CHANGED
|
@@ -195,6 +195,36 @@ declare const authEnvSchema: {
|
|
|
195
195
|
} & {
|
|
196
196
|
key: "SPFN_AUTH_USERNAME_MAX_LENGTH";
|
|
197
197
|
};
|
|
198
|
+
SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES: {
|
|
199
|
+
description: string;
|
|
200
|
+
default: number;
|
|
201
|
+
required: boolean;
|
|
202
|
+
examples: number[];
|
|
203
|
+
type: "number";
|
|
204
|
+
validator: (value: string) => number;
|
|
205
|
+
} & {
|
|
206
|
+
key: "SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES";
|
|
207
|
+
};
|
|
208
|
+
SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES: {
|
|
209
|
+
description: string;
|
|
210
|
+
default: number;
|
|
211
|
+
required: boolean;
|
|
212
|
+
examples: number[];
|
|
213
|
+
type: "number";
|
|
214
|
+
validator: (value: string) => number;
|
|
215
|
+
} & {
|
|
216
|
+
key: "SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES";
|
|
217
|
+
};
|
|
218
|
+
SPFN_AUTH_SIGNUP_CONFIRM_PATH: {
|
|
219
|
+
description: string;
|
|
220
|
+
default: string;
|
|
221
|
+
required: boolean;
|
|
222
|
+
examples: string[];
|
|
223
|
+
type: "string";
|
|
224
|
+
validator: (value: string) => string;
|
|
225
|
+
} & {
|
|
226
|
+
key: "SPFN_AUTH_SIGNUP_CONFIRM_PATH";
|
|
227
|
+
};
|
|
198
228
|
SPFN_API_URL: {
|
|
199
229
|
description: string;
|
|
200
230
|
default: string;
|
|
@@ -609,6 +639,36 @@ declare const env: _spfn_core_env.InferEnvType<{
|
|
|
609
639
|
} & {
|
|
610
640
|
key: "SPFN_AUTH_USERNAME_MAX_LENGTH";
|
|
611
641
|
};
|
|
642
|
+
SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES: {
|
|
643
|
+
description: string;
|
|
644
|
+
default: number;
|
|
645
|
+
required: boolean;
|
|
646
|
+
examples: number[];
|
|
647
|
+
type: "number";
|
|
648
|
+
validator: (value: string) => number;
|
|
649
|
+
} & {
|
|
650
|
+
key: "SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES";
|
|
651
|
+
};
|
|
652
|
+
SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES: {
|
|
653
|
+
description: string;
|
|
654
|
+
default: number;
|
|
655
|
+
required: boolean;
|
|
656
|
+
examples: number[];
|
|
657
|
+
type: "number";
|
|
658
|
+
validator: (value: string) => number;
|
|
659
|
+
} & {
|
|
660
|
+
key: "SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES";
|
|
661
|
+
};
|
|
662
|
+
SPFN_AUTH_SIGNUP_CONFIRM_PATH: {
|
|
663
|
+
description: string;
|
|
664
|
+
default: string;
|
|
665
|
+
required: boolean;
|
|
666
|
+
examples: string[];
|
|
667
|
+
type: "string";
|
|
668
|
+
validator: (value: string) => string;
|
|
669
|
+
} & {
|
|
670
|
+
key: "SPFN_AUTH_SIGNUP_CONFIRM_PATH";
|
|
671
|
+
};
|
|
612
672
|
SPFN_API_URL: {
|
|
613
673
|
description: string;
|
|
614
674
|
default: string;
|
package/dist/config.js
CHANGED
|
@@ -201,6 +201,33 @@ var authEnvSchema = defineEnvSchema({
|
|
|
201
201
|
})
|
|
202
202
|
},
|
|
203
203
|
// ============================================================================
|
|
204
|
+
// Verified-email signup
|
|
205
|
+
// ============================================================================
|
|
206
|
+
SPFN_AUTH_SIGNUP_LINK_TTL_MINUTES: {
|
|
207
|
+
...envNumber({
|
|
208
|
+
description: "How long an emailed signup confirmation link stays valid. Long enough to survive a mail delay, short enough that a link left in an inbox stops working.",
|
|
209
|
+
default: 30,
|
|
210
|
+
required: false,
|
|
211
|
+
examples: [15, 30, 60]
|
|
212
|
+
})
|
|
213
|
+
},
|
|
214
|
+
SPFN_AUTH_SIGNUP_SETUP_TTL_MINUTES: {
|
|
215
|
+
...envNumber({
|
|
216
|
+
description: "How long the password-setup session opened by a confirmation link stays valid. Covers one sitting at the password form, not an abandoned tab.",
|
|
217
|
+
default: 15,
|
|
218
|
+
required: false,
|
|
219
|
+
examples: [10, 15, 30]
|
|
220
|
+
})
|
|
221
|
+
},
|
|
222
|
+
SPFN_AUTH_SIGNUP_CONFIRM_PATH: {
|
|
223
|
+
...envString({
|
|
224
|
+
description: "App page the emailed confirmation link opens, as a path on {NEXT_PUBLIC_SPFN_APP_URL || SPFN_APP_URL}. The page reads the token from the query string and posts it to /_auth/signup/email/confirm; it is a page in your app, not an API route.",
|
|
225
|
+
default: "/signup/confirm",
|
|
226
|
+
required: false,
|
|
227
|
+
examples: ["/signup/confirm", "/auth/confirm", "/join/verify"]
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
// ============================================================================
|
|
204
231
|
// API Configuration
|
|
205
232
|
// ============================================================================
|
|
206
233
|
SPFN_API_URL: {
|