@spfn/auth 0.3.0-beta.24 → 0.3.0-beta.25
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 +163 -5
- package/dist/client-proof.d.ts +10 -1
- package/dist/client-proof.js +126 -9
- package/dist/client-proof.js.map +1 -1
- package/dist/client.d.ts +50 -1
- package/dist/client.js +25 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +40 -0
- package/dist/config.js +16 -0
- package/dist/config.js.map +1 -1
- package/dist/errors.d.ts +48 -2
- package/dist/errors.js +27 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +23 -16
- package/dist/index.js +31 -1
- package/dist/index.js.map +1 -1
- package/dist/{machine-principals-ZJd9anVT.d.ts → machine-principals-CdEgxOB1.d.ts} +1397 -886
- package/dist/nextjs/api.js +182 -40
- package/dist/nextjs/api.js.map +1 -1
- package/dist/nextjs/server.d.ts +17 -0
- package/dist/nextjs/server.js +33 -6
- package/dist/nextjs/server.js.map +1 -1
- package/dist/server.d.ts +256 -96
- package/dist/server.js +1328 -676
- package/dist/server.js.map +1 -1
- package/migrations/20260919023107_even_mikhail_rasputin/migration.sql +20 -0
- package/migrations/20260919023107_even_mikhail_rasputin/snapshot.json +6300 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import * as _simplewebauthn_server from '@simplewebauthn/server';
|
|
2
|
-
import { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, PublicKeyCredentialCreationOptionsJSON } from '@simplewebauthn/server';
|
|
3
1
|
import { S as SessionBindingType, K as KeyAlgorithmType, h as KeyPlatformType, l as SocialProvider } from './types-CTdoTOxM.js';
|
|
2
|
+
import * as _simplewebauthn_server from '@simplewebauthn/server';
|
|
3
|
+
import { RegistrationResponseJSON, AuthenticationResponseJSON, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server';
|
|
4
4
|
import * as _spfn_core_route from '@spfn/core/route';
|
|
5
5
|
import * as _sinclair_typebox from '@sinclair/typebox';
|
|
6
6
|
import { Static } from '@sinclair/typebox';
|
|
@@ -91,288 +91,69 @@ interface UserProfile {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* @spfn/auth -
|
|
94
|
+
* @spfn/auth - What a sign-in answers with
|
|
95
95
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
96
|
+
* One module for the shape every path that starts a session returns, and for the
|
|
97
|
+
* helper that fills in its binding half. It is here rather than in
|
|
98
|
+
* `auth.service.ts` because three of its readers are upstream of that file —
|
|
99
|
+
* `key.service` decides the second-factor step-up, `mfa.service` resolves it —
|
|
100
|
+
* and a type living with one of its producers would put those services in a
|
|
101
|
+
* cycle with each other.
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The second-factor challenge a stepped-up sign-in hands back (#95).
|
|
100
106
|
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
107
|
+
* `secret` is the 32 random bytes the challenge was minted from, and it is the
|
|
108
|
+
* only form of it that ever leaves the server — the row is addressed by its
|
|
109
|
+
* hash. It authorizes exactly one thing, `POST /_auth/mfa/verify` for this one
|
|
110
|
+
* registration, and it is not a bearer credential for anything else.
|
|
111
|
+
*/
|
|
112
|
+
interface MfaChallengeHandle {
|
|
113
|
+
secret: string;
|
|
114
|
+
/** Epoch milliseconds the challenge stops verifying at. */
|
|
115
|
+
expiresAtMillis: number;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What a sign-in answers with, on every path that starts a session.
|
|
105
119
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
120
|
+
* **One type with a required discriminant, not a union.** A sign-in on an
|
|
121
|
+
* account with a second factor and a device it has never seen answers 202 with
|
|
122
|
+
* `mfaRequired: true` and a challenge instead of a session (#95), and the two
|
|
123
|
+
* answers have to be one declared type: `authApi.login` infers its result from
|
|
124
|
+
* this declaration, so a union would make every existing `result.userId` in
|
|
125
|
+
* every consuming app stop compiling, and the mobile contract's grammar has no
|
|
126
|
+
* union type either — `DeviceAuthPollResponse` was flattened the same way and
|
|
127
|
+
* for the same reason. Narrow on `mfaRequired` before reading `userId`.
|
|
128
|
+
*
|
|
129
|
+
* `sessionBinding` and `keyExpiresAtMillis` are the carrier #97 needed. The
|
|
130
|
+
* Next.js proxy generated the device key and sealed the cookie, but only the
|
|
131
|
+
* backend knows whether the account asked for a bound session and when the key
|
|
132
|
+
* it just registered runs out — so the sign-in says it here and the interceptor
|
|
133
|
+
* copies both into `SessionData`. A response without them seals an unbound
|
|
134
|
+
* session, which is what every account that did not opt in gets and what every
|
|
135
|
+
* path predating that change keeps getting.
|
|
109
136
|
*/
|
|
137
|
+
interface LoginResult {
|
|
138
|
+
/** true means no session was started: verify the challenge below first. */
|
|
139
|
+
mfaRequired: boolean;
|
|
140
|
+
/** Present exactly when `mfaRequired` is true. */
|
|
141
|
+
challenge?: MfaChallengeHandle;
|
|
142
|
+
userId?: string;
|
|
143
|
+
publicId?: string;
|
|
144
|
+
email?: string;
|
|
145
|
+
phone?: string;
|
|
146
|
+
passwordChangeRequired?: boolean;
|
|
147
|
+
/** `'passkey'` when the key registered by this sign-in is bound. Absent otherwise. */
|
|
148
|
+
sessionBinding?: SessionBindingType;
|
|
149
|
+
/** Epoch milliseconds that key expires at. Only sent alongside `sessionBinding`. */
|
|
150
|
+
keyExpiresAtMillis?: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
110
153
|
/**
|
|
111
|
-
*
|
|
154
|
+
* @spfn/auth - Auth Service
|
|
112
155
|
*
|
|
113
|
-
*
|
|
114
|
-
* `singleDevice` is bound to one authenticator. Reported by the authenticator at
|
|
115
|
-
* enrollment and shown in the management list, because "this one is only on that
|
|
116
|
-
* phone" is what the owner needs to know before revoking the other entry.
|
|
117
|
-
*/
|
|
118
|
-
declare const PASSKEY_DEVICE_TYPES: readonly ["singleDevice", "multiDevice"];
|
|
119
|
-
type PasskeyDeviceType = typeof PASSKEY_DEVICE_TYPES[number];
|
|
120
|
-
/** How long a label may be — the key list's `deviceName` bound, for the same reason. */
|
|
121
|
-
declare const PASSKEY_LABEL_MAX_LENGTH = 64;
|
|
122
|
-
declare const passkeys: drizzle_orm_pg_core.PgTableWithColumns<{
|
|
123
|
-
name: "passkeys";
|
|
124
|
-
schema: string;
|
|
125
|
-
columns: {
|
|
126
|
-
createdAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.PgTimestampBuilder>>, {
|
|
127
|
-
name: string;
|
|
128
|
-
tableName: "passkeys";
|
|
129
|
-
dataType: "object date";
|
|
130
|
-
data: Date;
|
|
131
|
-
driverParam: string;
|
|
132
|
-
notNull: true;
|
|
133
|
-
hasDefault: true;
|
|
134
|
-
isPrimaryKey: false;
|
|
135
|
-
isAutoincrement: false;
|
|
136
|
-
hasRuntimeDefault: false;
|
|
137
|
-
enumValues: undefined;
|
|
138
|
-
identity: undefined;
|
|
139
|
-
generated: undefined;
|
|
140
|
-
}>;
|
|
141
|
-
updatedAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.PgTimestampBuilder>>>, {
|
|
142
|
-
name: string;
|
|
143
|
-
tableName: "passkeys";
|
|
144
|
-
dataType: "object date";
|
|
145
|
-
data: Date;
|
|
146
|
-
driverParam: string;
|
|
147
|
-
notNull: true;
|
|
148
|
-
hasDefault: true;
|
|
149
|
-
isPrimaryKey: false;
|
|
150
|
-
isAutoincrement: false;
|
|
151
|
-
hasRuntimeDefault: false;
|
|
152
|
-
enumValues: undefined;
|
|
153
|
-
identity: undefined;
|
|
154
|
-
generated: undefined;
|
|
155
|
-
}>;
|
|
156
|
-
id: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetIsPrimaryKey<drizzle_orm_pg_core.PgBigSerial53Builder>, {
|
|
157
|
-
name: string;
|
|
158
|
-
tableName: "passkeys";
|
|
159
|
-
dataType: "number int53";
|
|
160
|
-
data: number;
|
|
161
|
-
driverParam: number;
|
|
162
|
-
notNull: true;
|
|
163
|
-
hasDefault: true;
|
|
164
|
-
isPrimaryKey: false;
|
|
165
|
-
isAutoincrement: false;
|
|
166
|
-
hasRuntimeDefault: false;
|
|
167
|
-
enumValues: undefined;
|
|
168
|
-
identity: undefined;
|
|
169
|
-
generated: undefined;
|
|
170
|
-
}>;
|
|
171
|
-
userId: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBigInt53Builder>, {
|
|
172
|
-
name: string;
|
|
173
|
-
tableName: "passkeys";
|
|
174
|
-
dataType: "number int53";
|
|
175
|
-
data: number;
|
|
176
|
-
driverParam: string | number;
|
|
177
|
-
notNull: true;
|
|
178
|
-
hasDefault: false;
|
|
179
|
-
isPrimaryKey: false;
|
|
180
|
-
isAutoincrement: false;
|
|
181
|
-
hasRuntimeDefault: false;
|
|
182
|
-
enumValues: undefined;
|
|
183
|
-
identity: undefined;
|
|
184
|
-
generated: undefined;
|
|
185
|
-
}>;
|
|
186
|
-
credentialId: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>>, {
|
|
187
|
-
name: string;
|
|
188
|
-
tableName: "passkeys";
|
|
189
|
-
dataType: "string";
|
|
190
|
-
data: string;
|
|
191
|
-
driverParam: string;
|
|
192
|
-
notNull: true;
|
|
193
|
-
hasDefault: false;
|
|
194
|
-
isPrimaryKey: false;
|
|
195
|
-
isAutoincrement: false;
|
|
196
|
-
hasRuntimeDefault: false;
|
|
197
|
-
enumValues: undefined;
|
|
198
|
-
identity: undefined;
|
|
199
|
-
generated: undefined;
|
|
200
|
-
}>;
|
|
201
|
-
publicKey: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>>, {
|
|
202
|
-
name: string;
|
|
203
|
-
tableName: "passkeys";
|
|
204
|
-
dataType: "string";
|
|
205
|
-
data: string;
|
|
206
|
-
driverParam: string;
|
|
207
|
-
notNull: true;
|
|
208
|
-
hasDefault: false;
|
|
209
|
-
isPrimaryKey: false;
|
|
210
|
-
isAutoincrement: false;
|
|
211
|
-
hasRuntimeDefault: false;
|
|
212
|
-
enumValues: undefined;
|
|
213
|
-
identity: undefined;
|
|
214
|
-
generated: undefined;
|
|
215
|
-
}>;
|
|
216
|
-
counter: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgIntegerBuilder>>, {
|
|
217
|
-
name: string;
|
|
218
|
-
tableName: "passkeys";
|
|
219
|
-
dataType: "number int32";
|
|
220
|
-
data: number;
|
|
221
|
-
driverParam: string | number;
|
|
222
|
-
notNull: true;
|
|
223
|
-
hasDefault: true;
|
|
224
|
-
isPrimaryKey: false;
|
|
225
|
-
isAutoincrement: false;
|
|
226
|
-
hasRuntimeDefault: false;
|
|
227
|
-
enumValues: undefined;
|
|
228
|
-
identity: undefined;
|
|
229
|
-
generated: undefined;
|
|
230
|
-
}>;
|
|
231
|
-
transports: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetDimensions<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, 1>, {
|
|
232
|
-
name: string;
|
|
233
|
-
tableName: "passkeys";
|
|
234
|
-
dataType: "string";
|
|
235
|
-
data: string[];
|
|
236
|
-
driverParam: string | string[];
|
|
237
|
-
notNull: false;
|
|
238
|
-
hasDefault: false;
|
|
239
|
-
isPrimaryKey: false;
|
|
240
|
-
isAutoincrement: false;
|
|
241
|
-
hasRuntimeDefault: false;
|
|
242
|
-
enumValues: undefined;
|
|
243
|
-
identity: undefined;
|
|
244
|
-
generated: undefined;
|
|
245
|
-
}>;
|
|
246
|
-
deviceType: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<["singleDevice", "multiDevice"] & [string, ...string[]]>>, {
|
|
247
|
-
name: string;
|
|
248
|
-
tableName: "passkeys";
|
|
249
|
-
dataType: "string enum";
|
|
250
|
-
data: "singleDevice" | "multiDevice";
|
|
251
|
-
driverParam: string;
|
|
252
|
-
notNull: true;
|
|
253
|
-
hasDefault: false;
|
|
254
|
-
isPrimaryKey: false;
|
|
255
|
-
isAutoincrement: false;
|
|
256
|
-
hasRuntimeDefault: false;
|
|
257
|
-
enumValues: ["singleDevice", "multiDevice"] & [string, ...string[]];
|
|
258
|
-
identity: undefined;
|
|
259
|
-
generated: undefined;
|
|
260
|
-
}>;
|
|
261
|
-
backedUp: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBooleanBuilder>>, {
|
|
262
|
-
name: string;
|
|
263
|
-
tableName: "passkeys";
|
|
264
|
-
dataType: "boolean";
|
|
265
|
-
data: boolean;
|
|
266
|
-
driverParam: boolean;
|
|
267
|
-
notNull: true;
|
|
268
|
-
hasDefault: true;
|
|
269
|
-
isPrimaryKey: false;
|
|
270
|
-
isAutoincrement: false;
|
|
271
|
-
hasRuntimeDefault: false;
|
|
272
|
-
enumValues: undefined;
|
|
273
|
-
identity: undefined;
|
|
274
|
-
generated: undefined;
|
|
275
|
-
}>;
|
|
276
|
-
aaguid: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, {
|
|
277
|
-
name: string;
|
|
278
|
-
tableName: "passkeys";
|
|
279
|
-
dataType: "string";
|
|
280
|
-
data: string;
|
|
281
|
-
driverParam: string;
|
|
282
|
-
notNull: false;
|
|
283
|
-
hasDefault: false;
|
|
284
|
-
isPrimaryKey: false;
|
|
285
|
-
isAutoincrement: false;
|
|
286
|
-
hasRuntimeDefault: false;
|
|
287
|
-
enumValues: undefined;
|
|
288
|
-
identity: undefined;
|
|
289
|
-
generated: undefined;
|
|
290
|
-
}>;
|
|
291
|
-
label: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, {
|
|
292
|
-
name: string;
|
|
293
|
-
tableName: "passkeys";
|
|
294
|
-
dataType: "string";
|
|
295
|
-
data: string;
|
|
296
|
-
driverParam: string;
|
|
297
|
-
notNull: false;
|
|
298
|
-
hasDefault: false;
|
|
299
|
-
isPrimaryKey: false;
|
|
300
|
-
isAutoincrement: false;
|
|
301
|
-
hasRuntimeDefault: false;
|
|
302
|
-
enumValues: undefined;
|
|
303
|
-
identity: undefined;
|
|
304
|
-
generated: undefined;
|
|
305
|
-
}>;
|
|
306
|
-
secondFactor: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBooleanBuilder>>, {
|
|
307
|
-
name: string;
|
|
308
|
-
tableName: "passkeys";
|
|
309
|
-
dataType: "boolean";
|
|
310
|
-
data: boolean;
|
|
311
|
-
driverParam: boolean;
|
|
312
|
-
notNull: true;
|
|
313
|
-
hasDefault: true;
|
|
314
|
-
isPrimaryKey: false;
|
|
315
|
-
isAutoincrement: false;
|
|
316
|
-
hasRuntimeDefault: false;
|
|
317
|
-
enumValues: undefined;
|
|
318
|
-
identity: undefined;
|
|
319
|
-
generated: undefined;
|
|
320
|
-
}>;
|
|
321
|
-
lastUsedAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTimestampBuilder, {
|
|
322
|
-
name: string;
|
|
323
|
-
tableName: "passkeys";
|
|
324
|
-
dataType: "object date";
|
|
325
|
-
data: Date;
|
|
326
|
-
driverParam: string;
|
|
327
|
-
notNull: false;
|
|
328
|
-
hasDefault: false;
|
|
329
|
-
isPrimaryKey: false;
|
|
330
|
-
isAutoincrement: false;
|
|
331
|
-
hasRuntimeDefault: false;
|
|
332
|
-
enumValues: undefined;
|
|
333
|
-
identity: undefined;
|
|
334
|
-
generated: undefined;
|
|
335
|
-
}>;
|
|
336
|
-
revokedAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTimestampBuilder, {
|
|
337
|
-
name: string;
|
|
338
|
-
tableName: "passkeys";
|
|
339
|
-
dataType: "object date";
|
|
340
|
-
data: Date;
|
|
341
|
-
driverParam: string;
|
|
342
|
-
notNull: false;
|
|
343
|
-
hasDefault: false;
|
|
344
|
-
isPrimaryKey: false;
|
|
345
|
-
isAutoincrement: false;
|
|
346
|
-
hasRuntimeDefault: false;
|
|
347
|
-
enumValues: undefined;
|
|
348
|
-
identity: undefined;
|
|
349
|
-
generated: undefined;
|
|
350
|
-
}>;
|
|
351
|
-
revokedReason: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, {
|
|
352
|
-
name: string;
|
|
353
|
-
tableName: "passkeys";
|
|
354
|
-
dataType: "string";
|
|
355
|
-
data: string;
|
|
356
|
-
driverParam: string;
|
|
357
|
-
notNull: false;
|
|
358
|
-
hasDefault: false;
|
|
359
|
-
isPrimaryKey: false;
|
|
360
|
-
isAutoincrement: false;
|
|
361
|
-
hasRuntimeDefault: false;
|
|
362
|
-
enumValues: undefined;
|
|
363
|
-
identity: undefined;
|
|
364
|
-
generated: undefined;
|
|
365
|
-
}>;
|
|
366
|
-
};
|
|
367
|
-
dialect: "pg";
|
|
368
|
-
}>;
|
|
369
|
-
type Passkey = typeof passkeys.$inferSelect;
|
|
370
|
-
type NewPasskey = typeof passkeys.$inferInsert;
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* @spfn/auth - Auth Service
|
|
374
|
-
*
|
|
375
|
-
* Core authentication logic: registration, login, logout, password management
|
|
156
|
+
* Core authentication logic: registration, login, logout, password management
|
|
376
157
|
*/
|
|
377
158
|
|
|
378
159
|
interface RegisterParams {
|
|
@@ -422,36 +203,7 @@ interface LoginParams {
|
|
|
422
203
|
/** Whether proxy-guard recognised the trusted Next.js proxy, from the same helper. */
|
|
423
204
|
webProxy?: boolean;
|
|
424
205
|
}
|
|
425
|
-
|
|
426
|
-
* What a sign-in answers with, on every path that starts a session.
|
|
427
|
-
*
|
|
428
|
-
* The last two fields are the carrier #97 needed. The Next.js proxy generated
|
|
429
|
-
* the device key and sealed the cookie, but only the backend knows whether the
|
|
430
|
-
* account asked for a bound session and when the key it just registered runs
|
|
431
|
-
* out — so the sign-in says it here and the interceptor copies both into
|
|
432
|
-
* `SessionData`. A response without them seals an unbound session, which is what
|
|
433
|
-
* every account that did not opt in gets and what every path predating this
|
|
434
|
-
* change keeps getting.
|
|
435
|
-
*/
|
|
436
|
-
interface LoginResult {
|
|
437
|
-
userId: string;
|
|
438
|
-
publicId: string;
|
|
439
|
-
email?: string;
|
|
440
|
-
phone?: string;
|
|
441
|
-
passwordChangeRequired: boolean;
|
|
442
|
-
/** `'passkey'` when the key registered by this sign-in is bound. Absent otherwise. */
|
|
443
|
-
sessionBinding?: SessionBindingType;
|
|
444
|
-
/** Epoch milliseconds that key expires at. Only sent alongside `sessionBinding`. */
|
|
445
|
-
keyExpiresAtMillis?: number;
|
|
446
|
-
}
|
|
447
|
-
/**
|
|
448
|
-
* The binding half of a sign-in answer, as a type.
|
|
449
|
-
*
|
|
450
|
-
* Named because more than one result carries it: a password reset registers a
|
|
451
|
-
* device key exactly as a sign-in does, so its answer has to say so too or the
|
|
452
|
-
* proxy seals a cookie that does not know the key it holds is short-lived.
|
|
453
|
-
*/
|
|
454
|
-
type LoginBindingFields = Pick<LoginResult, 'sessionBinding' | 'keyExpiresAtMillis'>;
|
|
206
|
+
|
|
455
207
|
interface LogoutParams {
|
|
456
208
|
userId: number;
|
|
457
209
|
keyId: string;
|
|
@@ -492,94 +244,6 @@ declare function logoutService(params: LogoutParams): Promise<void>;
|
|
|
492
244
|
*/
|
|
493
245
|
declare function changePasswordService(params: ChangePasswordParams): Promise<void>;
|
|
494
246
|
|
|
495
|
-
/**
|
|
496
|
-
* @spfn/auth - Session Renewal Service
|
|
497
|
-
*
|
|
498
|
-
* What a bound session does when its key runs out: prove, with a fresh WebAuthn
|
|
499
|
-
* assertion, that the person who enrolled the passkey is still at the machine,
|
|
500
|
-
* and get a new short-lived key sealed into the cookie.
|
|
501
|
-
*
|
|
502
|
-
* Neither step is public. The expiring key is named by `expiredKeyId`, and that
|
|
503
|
-
* value reaches the service from `authenticateForRenewal` — the `keyId` of a
|
|
504
|
-
* bearer JWT this very key signed — rather than from the request body, so a
|
|
505
|
-
* caller who does not hold the private half cannot name a key at all. The
|
|
506
|
-
* assertion still has to be signed by a passkey that key's owner enrolled: the
|
|
507
|
-
* signature proves the cookie, and the cookie is the thing that may have been
|
|
508
|
-
* copied.
|
|
509
|
-
*
|
|
510
|
-
* The admission below is run again here all the same. The middleware and the
|
|
511
|
-
* service ask the same four questions of the row, and a service that trusted its
|
|
512
|
-
* caller to have asked them would be one refactor away from not being asked at
|
|
513
|
-
* all.
|
|
514
|
-
*
|
|
515
|
-
* Every refusal is the same refusal. A key that never existed, a stranger's key,
|
|
516
|
-
* an unbound key, a revoked one, one past its grace, an inactive account, a spent
|
|
517
|
-
* challenge, an assertion that did not verify — all `SessionRenewalRefusedError`,
|
|
518
|
-
* with the same body, because anything finer would answer "is this key id live"
|
|
519
|
-
* to whoever asked.
|
|
520
|
-
*
|
|
521
|
-
* Renewal announces nothing. No `auth.login`, no `auth.device.registered`, and
|
|
522
|
-
* `lastLoginAt` does not move: this is the same person on the same device
|
|
523
|
-
* continuing the session they already had, and a subscriber mailing "new sign-in"
|
|
524
|
-
* once a day per device would train its reader to ignore the notice that matters.
|
|
525
|
-
* A `lastLoginAt` that moved every day would make dormant-account detection
|
|
526
|
-
* meaningless for exactly the accounts that turned this protection on.
|
|
527
|
-
*/
|
|
528
|
-
|
|
529
|
-
interface StartSessionRenewParams {
|
|
530
|
-
/** The key that ran out, read off the JWT the request was signed with. */
|
|
531
|
-
expiredKeyId: string;
|
|
532
|
-
}
|
|
533
|
-
interface FinishSessionRenewParams extends StartSessionRenewParams {
|
|
534
|
-
/** The assertion, from `navigator.credentials.get()`. */
|
|
535
|
-
response: AuthenticationResponseJSON;
|
|
536
|
-
/**
|
|
537
|
-
* The new key pair, in the vocabulary the Next.js login interceptor already
|
|
538
|
-
* writes: `renew/verify` is on that interceptor's path list, so these arrive
|
|
539
|
-
* exactly as they do on a login.
|
|
540
|
-
*/
|
|
541
|
-
keyId: string;
|
|
542
|
-
publicKey: string;
|
|
543
|
-
fingerprint: string;
|
|
544
|
-
algorithm?: KeyAlgorithmType;
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* What a completed renewal answers: a sign-in result, plus the new key's id.
|
|
548
|
-
*
|
|
549
|
-
* The id is the one thing a renewal has that a sign-in does not need to say —
|
|
550
|
-
* `renewSession()` promises it to the app, which has no other way to learn it
|
|
551
|
-
* (the key pair is minted in the proxy and the private half never leaves the
|
|
552
|
-
* cookie). It is not a contract operation, so nothing generated reads it.
|
|
553
|
-
*/
|
|
554
|
-
interface SessionRenewResult extends LoginResult {
|
|
555
|
-
/** The key this renewal registered, the one the session now signs with. */
|
|
556
|
-
keyId: string;
|
|
557
|
-
}
|
|
558
|
-
/**
|
|
559
|
-
* Step 1 — the challenge the authenticator signs.
|
|
560
|
-
*
|
|
561
|
-
* `allowCredentials` is empty and the account lives only on the challenge row.
|
|
562
|
-
* See `startRenewalCeremonyService`.
|
|
563
|
-
*
|
|
564
|
-
* @throws SessionRenewalRefusedError 갱신할 수 없는 키·계정일 때 (모든 사유 동일)
|
|
565
|
-
*/
|
|
566
|
-
declare function startSessionRenewService(params: StartSessionRenewParams): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
567
|
-
/**
|
|
568
|
-
* Step 2 — verify the assertion, put a new bound key in place of the old one.
|
|
569
|
-
*
|
|
570
|
-
* The revocation runs first and its answer is the race winner: two verifies that
|
|
571
|
-
* both got past their own challenges meet at the same conditional UPDATE, and
|
|
572
|
-
* only the one that actually revoked the key goes on to register a replacement.
|
|
573
|
-
*
|
|
574
|
-
* The new key inherits the old row's provenance, so the device list keeps saying
|
|
575
|
-
* where this device first appeared rather than re-stamping itself every day. Its
|
|
576
|
-
* expiry is a fresh window from now — renewal is a renewal, not an extension of
|
|
577
|
-
* what the old key had.
|
|
578
|
-
*
|
|
579
|
-
* @throws SessionRenewalRefusedError 갱신할 수 없을 때 (증명 실패 포함, 모든 사유 동일)
|
|
580
|
-
*/
|
|
581
|
-
declare function finishSessionRenewService(params: FinishSessionRenewParams): Promise<SessionRenewResult>;
|
|
582
|
-
|
|
583
247
|
declare const EmailSchema: _sinclair_typebox.TString;
|
|
584
248
|
declare const PhoneSchema: _sinclair_typebox.TString;
|
|
585
249
|
/**
|
|
@@ -652,6 +316,7 @@ declare const DeviceAuthPollResponseSchema: _sinclair_typebox.TUnion<[_sinclair_
|
|
|
652
316
|
intervalMillis: _sinclair_typebox.TInteger;
|
|
653
317
|
}>, _sinclair_typebox.TObject<{
|
|
654
318
|
status: _sinclair_typebox.TLiteral<"approved">;
|
|
319
|
+
mfaRequired: _sinclair_typebox.TBoolean;
|
|
655
320
|
userId: _sinclair_typebox.TString;
|
|
656
321
|
publicId: _sinclair_typebox.TString;
|
|
657
322
|
email: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
@@ -898,7 +563,7 @@ interface CompletePasswordResetParams {
|
|
|
898
563
|
* believing the session unbound: no user-agent check for the life of that key,
|
|
899
564
|
* and a sign-out a day later instead of the renewal prompt.
|
|
900
565
|
*/
|
|
901
|
-
declare function completePasswordResetService(params: CompletePasswordResetParams): Promise<
|
|
566
|
+
declare function completePasswordResetService(params: CompletePasswordResetParams): Promise<LoginResult>;
|
|
902
567
|
|
|
903
568
|
/**
|
|
904
569
|
* @spfn/auth - Device Auth Service
|
|
@@ -986,10 +651,22 @@ interface DeviceAuthPendingResult {
|
|
|
986
651
|
status: 'pending';
|
|
987
652
|
intervalMillis: number;
|
|
988
653
|
}
|
|
989
|
-
/**
|
|
654
|
+
/**
|
|
655
|
+
* Approved and spent: the key is registered and this is the login it produced.
|
|
656
|
+
*
|
|
657
|
+
* `LoginResult`'s login fields are optional because a sign-in may answer a
|
|
658
|
+
* second-factor challenge instead of a session (#95). This branch never can —
|
|
659
|
+
* the owner already said yes on a device that is signed in, which is itself the
|
|
660
|
+
* second factor — so the three it always carries are narrowed back to required
|
|
661
|
+
* and a consumer of the approved branch reads exactly what it always read.
|
|
662
|
+
*/
|
|
990
663
|
type DeviceAuthApprovedResult = {
|
|
991
664
|
status: 'approved';
|
|
992
|
-
} & LoginResult
|
|
665
|
+
} & LoginResult & {
|
|
666
|
+
userId: string;
|
|
667
|
+
publicId: string;
|
|
668
|
+
passwordChangeRequired: boolean;
|
|
669
|
+
};
|
|
993
670
|
type PollDeviceAuthResult = DeviceAuthPendingResult | DeviceAuthApprovedResult;
|
|
994
671
|
/**
|
|
995
672
|
* Park a new device's key and hand back the codes it needs.
|
|
@@ -1039,95 +716,374 @@ declare function denyDeviceAuthService(params: DenyDeviceAuthParams): Promise<vo
|
|
|
1039
716
|
declare function pollDeviceAuthService(params: PollDeviceAuthParams): Promise<PollDeviceAuthResult>;
|
|
1040
717
|
|
|
1041
718
|
/**
|
|
1042
|
-
* @spfn/auth -
|
|
1043
|
-
*
|
|
1044
|
-
* WebAuthn passkeys as an optional account credential, alongside a password and
|
|
1045
|
-
* a linked social account rather than in place of either.
|
|
1046
|
-
*
|
|
1047
|
-
* enroll -> options on an identified session, then verify the attestation
|
|
1048
|
-
* sign in -> options with no identifier at all, then verify the assertion
|
|
1049
|
-
* manage -> list, rename, revoke
|
|
1050
|
-
*
|
|
1051
|
-
* A passkey is not a device key. The assertion proves *who* is asking; the
|
|
1052
|
-
* device key registered right after it is what every later request is signed
|
|
1053
|
-
* with, exactly as after a password login (D2). Nothing in clientProofV1 or in
|
|
1054
|
-
* the JWT path changes because a session started this way.
|
|
1055
|
-
*
|
|
1056
|
-
* Challenges are rows, spent by one conditional UPDATE (D7). Two verifies
|
|
1057
|
-
* arriving with the same challenge therefore produce one winner and one refusal,
|
|
1058
|
-
* across instances, rather than both reading it as live.
|
|
1059
|
-
*
|
|
1060
|
-
* Revoking the last thing an account can sign in with is refused (D6) rather
|
|
1061
|
-
* than warned about, because that state has no undo. A verified email address
|
|
1062
|
-
* counts as one of those things: the password reset flow can always give such an
|
|
1063
|
-
* account a password back.
|
|
1064
|
-
*/
|
|
1065
|
-
|
|
1066
|
-
/** One enrolled credential as the management surface shows it. */
|
|
1067
|
-
interface PasskeySummary {
|
|
1068
|
-
passkeyId: string;
|
|
1069
|
-
label: string | null;
|
|
1070
|
-
deviceType: PasskeyDeviceType;
|
|
1071
|
-
backedUp: boolean;
|
|
1072
|
-
transports: string[];
|
|
1073
|
-
createdAt: string;
|
|
1074
|
-
lastUsedAt: string | null;
|
|
1075
|
-
}
|
|
1076
|
-
interface RecentAuthenticationParams {
|
|
1077
|
-
userId: number;
|
|
1078
|
-
/** The device key this request is signed with — its age is the signal. */
|
|
1079
|
-
keyId: string;
|
|
1080
|
-
currentPassword?: string;
|
|
1081
|
-
}
|
|
1082
|
-
/**
|
|
1083
|
-
* Refuse a passkey change unless the caller has recently proved themselves (D4).
|
|
1084
|
-
*
|
|
1085
|
-
* Two ways to satisfy it. The device key this request is signed with was
|
|
1086
|
-
* registered within the window — that is when this device last presented a
|
|
1087
|
-
* credential, and it needs no new state. Or the body carries the account
|
|
1088
|
-
* password.
|
|
1089
|
-
*
|
|
1090
|
-
* An account with no password stored cannot satisfy it with a password, however
|
|
1091
|
-
* plausible the value (E5): the comparison still runs, against a dummy hash, so
|
|
1092
|
-
* "no password on file" costs exactly what "wrong password" costs. Skipping it
|
|
1093
|
-
* would turn response time into an oracle for which accounts are OAuth-only.
|
|
1094
|
-
*
|
|
1095
|
-
* @throws RecentAuthenticationRequiredError
|
|
1096
|
-
*/
|
|
1097
|
-
declare function assertRecentAuthentication(params: RecentAuthenticationParams): Promise<void>;
|
|
1098
|
-
/**
|
|
1099
|
-
* Refuse to remove the only thing an account can sign in with (D6).
|
|
719
|
+
* @spfn/auth - Passkeys Entity
|
|
1100
720
|
*
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
*
|
|
721
|
+
* A WebAuthn credential the account owner enrolled on one of their devices.
|
|
722
|
+
* It is a *credential*, not a session: an assertion proves who is asking, and
|
|
723
|
+
* the ordinary device key in `user_public_keys` is what the request afterwards
|
|
724
|
+
* is signed with. The two tables therefore never stand in for each other.
|
|
1105
725
|
*
|
|
1106
|
-
*
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
726
|
+
* Nothing here is a bearer value, so nothing is hashed. `publicKey` is public by
|
|
727
|
+
* construction and `credentialId` is a handle the authenticator hands to any
|
|
728
|
+
* origin that asks — storing either in the clear costs nothing, and the lookup
|
|
729
|
+
* on `credentialId` has to be a plain equality match on an indexed column.
|
|
1109
730
|
*
|
|
1110
|
-
*
|
|
731
|
+
* Revocation is soft, and `credentialId` stays unique across live and revoked
|
|
732
|
+
* rows alike: a credential someone cut off must never become enrollable again,
|
|
733
|
+
* on this account or on another one.
|
|
1111
734
|
*/
|
|
1112
|
-
declare function assertNotLastRecoveryCredential(userId: number): Promise<void>;
|
|
1113
|
-
interface StartPasskeyEnrollmentParams {
|
|
1114
|
-
userId: number;
|
|
1115
|
-
keyId: string;
|
|
1116
|
-
currentPassword?: string;
|
|
1117
|
-
}
|
|
1118
735
|
/**
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
* An enrolled account steps up first, and every account then meets the
|
|
1122
|
-
* recent-authentication rule this route has always had (#95). The order is what
|
|
1123
|
-
* makes the two independent: `assertStepUp` is a no-op for an unenrolled
|
|
1124
|
-
* account, so the answer such a caller gets is byte-for-byte today's.
|
|
736
|
+
* Whether the credential can leave the authenticator that minted it.
|
|
1125
737
|
*
|
|
1126
|
-
* `
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
|
|
738
|
+
* `multiDevice` is a synced passkey (iCloud Keychain, Google Password Manager);
|
|
739
|
+
* `singleDevice` is bound to one authenticator. Reported by the authenticator at
|
|
740
|
+
* enrollment and shown in the management list, because "this one is only on that
|
|
741
|
+
* phone" is what the owner needs to know before revoking the other entry.
|
|
742
|
+
*/
|
|
743
|
+
declare const PASSKEY_DEVICE_TYPES: readonly ["singleDevice", "multiDevice"];
|
|
744
|
+
type PasskeyDeviceType = typeof PASSKEY_DEVICE_TYPES[number];
|
|
745
|
+
/** How long a label may be — the key list's `deviceName` bound, for the same reason. */
|
|
746
|
+
declare const PASSKEY_LABEL_MAX_LENGTH = 64;
|
|
747
|
+
declare const passkeys: drizzle_orm_pg_core.PgTableWithColumns<{
|
|
748
|
+
name: "passkeys";
|
|
749
|
+
schema: string;
|
|
750
|
+
columns: {
|
|
751
|
+
createdAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.PgTimestampBuilder>>, {
|
|
752
|
+
name: string;
|
|
753
|
+
tableName: "passkeys";
|
|
754
|
+
dataType: "object date";
|
|
755
|
+
data: Date;
|
|
756
|
+
driverParam: string;
|
|
757
|
+
notNull: true;
|
|
758
|
+
hasDefault: true;
|
|
759
|
+
isPrimaryKey: false;
|
|
760
|
+
isAutoincrement: false;
|
|
761
|
+
hasRuntimeDefault: false;
|
|
762
|
+
enumValues: undefined;
|
|
763
|
+
identity: undefined;
|
|
764
|
+
generated: undefined;
|
|
765
|
+
}>;
|
|
766
|
+
updatedAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.PgTimestampBuilder>>>, {
|
|
767
|
+
name: string;
|
|
768
|
+
tableName: "passkeys";
|
|
769
|
+
dataType: "object date";
|
|
770
|
+
data: Date;
|
|
771
|
+
driverParam: string;
|
|
772
|
+
notNull: true;
|
|
773
|
+
hasDefault: true;
|
|
774
|
+
isPrimaryKey: false;
|
|
775
|
+
isAutoincrement: false;
|
|
776
|
+
hasRuntimeDefault: false;
|
|
777
|
+
enumValues: undefined;
|
|
778
|
+
identity: undefined;
|
|
779
|
+
generated: undefined;
|
|
780
|
+
}>;
|
|
781
|
+
id: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetIsPrimaryKey<drizzle_orm_pg_core.PgBigSerial53Builder>, {
|
|
782
|
+
name: string;
|
|
783
|
+
tableName: "passkeys";
|
|
784
|
+
dataType: "number int53";
|
|
785
|
+
data: number;
|
|
786
|
+
driverParam: number;
|
|
787
|
+
notNull: true;
|
|
788
|
+
hasDefault: true;
|
|
789
|
+
isPrimaryKey: false;
|
|
790
|
+
isAutoincrement: false;
|
|
791
|
+
hasRuntimeDefault: false;
|
|
792
|
+
enumValues: undefined;
|
|
793
|
+
identity: undefined;
|
|
794
|
+
generated: undefined;
|
|
795
|
+
}>;
|
|
796
|
+
userId: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBigInt53Builder>, {
|
|
797
|
+
name: string;
|
|
798
|
+
tableName: "passkeys";
|
|
799
|
+
dataType: "number int53";
|
|
800
|
+
data: number;
|
|
801
|
+
driverParam: string | number;
|
|
802
|
+
notNull: true;
|
|
803
|
+
hasDefault: false;
|
|
804
|
+
isPrimaryKey: false;
|
|
805
|
+
isAutoincrement: false;
|
|
806
|
+
hasRuntimeDefault: false;
|
|
807
|
+
enumValues: undefined;
|
|
808
|
+
identity: undefined;
|
|
809
|
+
generated: undefined;
|
|
810
|
+
}>;
|
|
811
|
+
credentialId: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>>, {
|
|
812
|
+
name: string;
|
|
813
|
+
tableName: "passkeys";
|
|
814
|
+
dataType: "string";
|
|
815
|
+
data: string;
|
|
816
|
+
driverParam: string;
|
|
817
|
+
notNull: true;
|
|
818
|
+
hasDefault: false;
|
|
819
|
+
isPrimaryKey: false;
|
|
820
|
+
isAutoincrement: false;
|
|
821
|
+
hasRuntimeDefault: false;
|
|
822
|
+
enumValues: undefined;
|
|
823
|
+
identity: undefined;
|
|
824
|
+
generated: undefined;
|
|
825
|
+
}>;
|
|
826
|
+
publicKey: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>>, {
|
|
827
|
+
name: string;
|
|
828
|
+
tableName: "passkeys";
|
|
829
|
+
dataType: "string";
|
|
830
|
+
data: string;
|
|
831
|
+
driverParam: string;
|
|
832
|
+
notNull: true;
|
|
833
|
+
hasDefault: false;
|
|
834
|
+
isPrimaryKey: false;
|
|
835
|
+
isAutoincrement: false;
|
|
836
|
+
hasRuntimeDefault: false;
|
|
837
|
+
enumValues: undefined;
|
|
838
|
+
identity: undefined;
|
|
839
|
+
generated: undefined;
|
|
840
|
+
}>;
|
|
841
|
+
counter: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgIntegerBuilder>>, {
|
|
842
|
+
name: string;
|
|
843
|
+
tableName: "passkeys";
|
|
844
|
+
dataType: "number int32";
|
|
845
|
+
data: number;
|
|
846
|
+
driverParam: string | number;
|
|
847
|
+
notNull: true;
|
|
848
|
+
hasDefault: true;
|
|
849
|
+
isPrimaryKey: false;
|
|
850
|
+
isAutoincrement: false;
|
|
851
|
+
hasRuntimeDefault: false;
|
|
852
|
+
enumValues: undefined;
|
|
853
|
+
identity: undefined;
|
|
854
|
+
generated: undefined;
|
|
855
|
+
}>;
|
|
856
|
+
transports: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetDimensions<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, 1>, {
|
|
857
|
+
name: string;
|
|
858
|
+
tableName: "passkeys";
|
|
859
|
+
dataType: "string";
|
|
860
|
+
data: string[];
|
|
861
|
+
driverParam: string | string[];
|
|
862
|
+
notNull: false;
|
|
863
|
+
hasDefault: false;
|
|
864
|
+
isPrimaryKey: false;
|
|
865
|
+
isAutoincrement: false;
|
|
866
|
+
hasRuntimeDefault: false;
|
|
867
|
+
enumValues: undefined;
|
|
868
|
+
identity: undefined;
|
|
869
|
+
generated: undefined;
|
|
870
|
+
}>;
|
|
871
|
+
deviceType: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<["singleDevice", "multiDevice"] & [string, ...string[]]>>, {
|
|
872
|
+
name: string;
|
|
873
|
+
tableName: "passkeys";
|
|
874
|
+
dataType: "string enum";
|
|
875
|
+
data: "singleDevice" | "multiDevice";
|
|
876
|
+
driverParam: string;
|
|
877
|
+
notNull: true;
|
|
878
|
+
hasDefault: false;
|
|
879
|
+
isPrimaryKey: false;
|
|
880
|
+
isAutoincrement: false;
|
|
881
|
+
hasRuntimeDefault: false;
|
|
882
|
+
enumValues: ["singleDevice", "multiDevice"] & [string, ...string[]];
|
|
883
|
+
identity: undefined;
|
|
884
|
+
generated: undefined;
|
|
885
|
+
}>;
|
|
886
|
+
backedUp: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBooleanBuilder>>, {
|
|
887
|
+
name: string;
|
|
888
|
+
tableName: "passkeys";
|
|
889
|
+
dataType: "boolean";
|
|
890
|
+
data: boolean;
|
|
891
|
+
driverParam: boolean;
|
|
892
|
+
notNull: true;
|
|
893
|
+
hasDefault: true;
|
|
894
|
+
isPrimaryKey: false;
|
|
895
|
+
isAutoincrement: false;
|
|
896
|
+
hasRuntimeDefault: false;
|
|
897
|
+
enumValues: undefined;
|
|
898
|
+
identity: undefined;
|
|
899
|
+
generated: undefined;
|
|
900
|
+
}>;
|
|
901
|
+
aaguid: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, {
|
|
902
|
+
name: string;
|
|
903
|
+
tableName: "passkeys";
|
|
904
|
+
dataType: "string";
|
|
905
|
+
data: string;
|
|
906
|
+
driverParam: string;
|
|
907
|
+
notNull: false;
|
|
908
|
+
hasDefault: false;
|
|
909
|
+
isPrimaryKey: false;
|
|
910
|
+
isAutoincrement: false;
|
|
911
|
+
hasRuntimeDefault: false;
|
|
912
|
+
enumValues: undefined;
|
|
913
|
+
identity: undefined;
|
|
914
|
+
generated: undefined;
|
|
915
|
+
}>;
|
|
916
|
+
label: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, {
|
|
917
|
+
name: string;
|
|
918
|
+
tableName: "passkeys";
|
|
919
|
+
dataType: "string";
|
|
920
|
+
data: string;
|
|
921
|
+
driverParam: string;
|
|
922
|
+
notNull: false;
|
|
923
|
+
hasDefault: false;
|
|
924
|
+
isPrimaryKey: false;
|
|
925
|
+
isAutoincrement: false;
|
|
926
|
+
hasRuntimeDefault: false;
|
|
927
|
+
enumValues: undefined;
|
|
928
|
+
identity: undefined;
|
|
929
|
+
generated: undefined;
|
|
930
|
+
}>;
|
|
931
|
+
secondFactor: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBooleanBuilder>>, {
|
|
932
|
+
name: string;
|
|
933
|
+
tableName: "passkeys";
|
|
934
|
+
dataType: "boolean";
|
|
935
|
+
data: boolean;
|
|
936
|
+
driverParam: boolean;
|
|
937
|
+
notNull: true;
|
|
938
|
+
hasDefault: true;
|
|
939
|
+
isPrimaryKey: false;
|
|
940
|
+
isAutoincrement: false;
|
|
941
|
+
hasRuntimeDefault: false;
|
|
942
|
+
enumValues: undefined;
|
|
943
|
+
identity: undefined;
|
|
944
|
+
generated: undefined;
|
|
945
|
+
}>;
|
|
946
|
+
lastUsedAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTimestampBuilder, {
|
|
947
|
+
name: string;
|
|
948
|
+
tableName: "passkeys";
|
|
949
|
+
dataType: "object date";
|
|
950
|
+
data: Date;
|
|
951
|
+
driverParam: string;
|
|
952
|
+
notNull: false;
|
|
953
|
+
hasDefault: false;
|
|
954
|
+
isPrimaryKey: false;
|
|
955
|
+
isAutoincrement: false;
|
|
956
|
+
hasRuntimeDefault: false;
|
|
957
|
+
enumValues: undefined;
|
|
958
|
+
identity: undefined;
|
|
959
|
+
generated: undefined;
|
|
960
|
+
}>;
|
|
961
|
+
revokedAt: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTimestampBuilder, {
|
|
962
|
+
name: string;
|
|
963
|
+
tableName: "passkeys";
|
|
964
|
+
dataType: "object date";
|
|
965
|
+
data: Date;
|
|
966
|
+
driverParam: string;
|
|
967
|
+
notNull: false;
|
|
968
|
+
hasDefault: false;
|
|
969
|
+
isPrimaryKey: false;
|
|
970
|
+
isAutoincrement: false;
|
|
971
|
+
hasRuntimeDefault: false;
|
|
972
|
+
enumValues: undefined;
|
|
973
|
+
identity: undefined;
|
|
974
|
+
generated: undefined;
|
|
975
|
+
}>;
|
|
976
|
+
revokedReason: drizzle_orm_pg_core.PgBuildColumn<"passkeys", drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>, {
|
|
977
|
+
name: string;
|
|
978
|
+
tableName: "passkeys";
|
|
979
|
+
dataType: "string";
|
|
980
|
+
data: string;
|
|
981
|
+
driverParam: string;
|
|
982
|
+
notNull: false;
|
|
983
|
+
hasDefault: false;
|
|
984
|
+
isPrimaryKey: false;
|
|
985
|
+
isAutoincrement: false;
|
|
986
|
+
hasRuntimeDefault: false;
|
|
987
|
+
enumValues: undefined;
|
|
988
|
+
identity: undefined;
|
|
989
|
+
generated: undefined;
|
|
990
|
+
}>;
|
|
991
|
+
};
|
|
992
|
+
dialect: "pg";
|
|
993
|
+
}>;
|
|
994
|
+
type Passkey = typeof passkeys.$inferSelect;
|
|
995
|
+
type NewPasskey = typeof passkeys.$inferInsert;
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* @spfn/auth - Passkey Service
|
|
999
|
+
*
|
|
1000
|
+
* WebAuthn passkeys as an optional account credential, alongside a password and
|
|
1001
|
+
* a linked social account rather than in place of either.
|
|
1002
|
+
*
|
|
1003
|
+
* enroll -> options on an identified session, then verify the attestation
|
|
1004
|
+
* sign in -> options with no identifier at all, then verify the assertion
|
|
1005
|
+
* manage -> list, rename, revoke
|
|
1006
|
+
*
|
|
1007
|
+
* A passkey is not a device key. The assertion proves *who* is asking; the
|
|
1008
|
+
* device key registered right after it is what every later request is signed
|
|
1009
|
+
* with, exactly as after a password login (D2). Nothing in clientProofV1 or in
|
|
1010
|
+
* the JWT path changes because a session started this way.
|
|
1011
|
+
*
|
|
1012
|
+
* Challenges are rows, spent by one conditional UPDATE (D7). Two verifies
|
|
1013
|
+
* arriving with the same challenge therefore produce one winner and one refusal,
|
|
1014
|
+
* across instances, rather than both reading it as live.
|
|
1015
|
+
*
|
|
1016
|
+
* Revoking the last thing an account can sign in with is refused (D6) rather
|
|
1017
|
+
* than warned about, because that state has no undo. A verified email address
|
|
1018
|
+
* counts as one of those things: the password reset flow can always give such an
|
|
1019
|
+
* account a password back.
|
|
1020
|
+
*/
|
|
1021
|
+
|
|
1022
|
+
/** One enrolled credential as the management surface shows it. */
|
|
1023
|
+
interface PasskeySummary {
|
|
1024
|
+
passkeyId: string;
|
|
1025
|
+
label: string | null;
|
|
1026
|
+
deviceType: PasskeyDeviceType;
|
|
1027
|
+
backedUp: boolean;
|
|
1028
|
+
transports: string[];
|
|
1029
|
+
createdAt: string;
|
|
1030
|
+
lastUsedAt: string | null;
|
|
1031
|
+
}
|
|
1032
|
+
interface RecentAuthenticationParams {
|
|
1033
|
+
userId: number;
|
|
1034
|
+
/** The device key this request is signed with — its age is the signal. */
|
|
1035
|
+
keyId: string;
|
|
1036
|
+
currentPassword?: string;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Refuse a passkey change unless the caller has recently proved themselves (D4).
|
|
1040
|
+
*
|
|
1041
|
+
* Two ways to satisfy it. The device key this request is signed with was
|
|
1042
|
+
* registered within the window — that is when this device last presented a
|
|
1043
|
+
* credential, and it needs no new state. Or the body carries the account
|
|
1044
|
+
* password.
|
|
1045
|
+
*
|
|
1046
|
+
* An account with no password stored cannot satisfy it with a password, however
|
|
1047
|
+
* plausible the value (E5): the comparison still runs, against a dummy hash, so
|
|
1048
|
+
* "no password on file" costs exactly what "wrong password" costs. Skipping it
|
|
1049
|
+
* would turn response time into an oracle for which accounts are OAuth-only.
|
|
1050
|
+
*
|
|
1051
|
+
* @throws RecentAuthenticationRequiredError
|
|
1052
|
+
*/
|
|
1053
|
+
declare function assertRecentAuthentication(params: RecentAuthenticationParams): Promise<void>;
|
|
1054
|
+
/**
|
|
1055
|
+
* Refuse to remove the only thing an account can sign in with (D6).
|
|
1056
|
+
*
|
|
1057
|
+
* The recovery paths are: another live passkey, a password, a linked social
|
|
1058
|
+
* account, and a verified email address. The last one is new — a password reset
|
|
1059
|
+
* now exists in this package, and an account that can be reset by email can
|
|
1060
|
+
* always get a password back, so the refusal has nothing left to protect.
|
|
1061
|
+
*
|
|
1062
|
+
* What remains refused is the account with none of the four: no other passkey,
|
|
1063
|
+
* no password, no social account, and no verified email — a phone-only account
|
|
1064
|
+
* among them. Nobody, support included, could undo that state.
|
|
1065
|
+
*
|
|
1066
|
+
* @throws LastRecoveryCredentialError
|
|
1067
|
+
*/
|
|
1068
|
+
declare function assertNotLastRecoveryCredential(userId: number): Promise<void>;
|
|
1069
|
+
interface StartPasskeyEnrollmentParams {
|
|
1070
|
+
userId: number;
|
|
1071
|
+
keyId: string;
|
|
1072
|
+
currentPassword?: string;
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* Step 1 of enrollment — options for `navigator.credentials.create()`.
|
|
1076
|
+
*
|
|
1077
|
+
* An enrolled account steps up first, and every account then meets the
|
|
1078
|
+
* recent-authentication rule this route has always had (#95). The order is what
|
|
1079
|
+
* makes the two independent: `assertStepUp` is a no-op for an unenrolled
|
|
1080
|
+
* account, so the answer such a caller gets is byte-for-byte today's.
|
|
1081
|
+
*
|
|
1082
|
+
* `excludeCredentials` lists the caller's **live** passkeys only, so the
|
|
1083
|
+
* authenticator quietly refuses one already enrolled here. Revoked ones are left
|
|
1084
|
+
* out on purpose: they must not be re-enrolled either, and the check that
|
|
1085
|
+
* refuses them is the global uniqueness check at verify (E11/M10) — listing them
|
|
1086
|
+
* here would hand out credential ids the account no longer uses.
|
|
1131
1087
|
*/
|
|
1132
1088
|
declare function startPasskeyEnrollmentService(params: StartPasskeyEnrollmentParams): Promise<PublicKeyCredentialCreationOptionsJSON>;
|
|
1133
1089
|
interface FinishPasskeyEnrollmentParams {
|
|
@@ -1141,94 +1097,607 @@ interface FinishPasskeyEnrollmentResult {
|
|
|
1141
1097
|
createdAt: string;
|
|
1142
1098
|
}
|
|
1143
1099
|
/**
|
|
1144
|
-
* Step 2 of enrollment — verify the attestation and keep the credential.
|
|
1100
|
+
* Step 2 of enrollment — verify the attestation and keep the credential.
|
|
1101
|
+
*
|
|
1102
|
+
* Runs under `Transactional()`: the challenge is spent and the row written
|
|
1103
|
+
* together, so a failure after the spend leaves the challenge live and the
|
|
1104
|
+
* ceremony retryable, while a success can never be replayed.
|
|
1105
|
+
*/
|
|
1106
|
+
declare function finishPasskeyEnrollmentService(params: FinishPasskeyEnrollmentParams): Promise<FinishPasskeyEnrollmentResult>;
|
|
1107
|
+
/**
|
|
1108
|
+
* Step 1 of sign-in — options for `navigator.credentials.get()`.
|
|
1109
|
+
*
|
|
1110
|
+
* Takes nothing and returns the same shape to everyone: `allowCredentials` is
|
|
1111
|
+
* always empty and the challenge row names no account (D3). There is no input
|
|
1112
|
+
* that could make this answer differ by whether an account exists, which is the
|
|
1113
|
+
* point — the discoverable credential on the device is what names the owner.
|
|
1114
|
+
*/
|
|
1115
|
+
declare function startPasskeyLoginService(): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
1116
|
+
interface FinishPasskeyLoginParams {
|
|
1117
|
+
response: AuthenticationResponseJSON;
|
|
1118
|
+
publicKey: string;
|
|
1119
|
+
keyId: string;
|
|
1120
|
+
fingerprint: string;
|
|
1121
|
+
algorithm?: KeyAlgorithmType;
|
|
1122
|
+
oldKeyId?: string;
|
|
1123
|
+
deviceName?: string;
|
|
1124
|
+
platform?: KeyPlatformType;
|
|
1125
|
+
/** Client address of the request, from `deviceProvenance` at the route. */
|
|
1126
|
+
ip?: string;
|
|
1127
|
+
/** `user-agent` of the request, already truncated at the route. */
|
|
1128
|
+
userAgent?: string;
|
|
1129
|
+
/** Whether proxy-guard recognised the trusted Next.js proxy, from the same helper. */
|
|
1130
|
+
webProxy?: boolean;
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Step 2 of sign-in — verify the assertion, then sign in exactly as a password
|
|
1134
|
+
* login does.
|
|
1135
|
+
*
|
|
1136
|
+
* The tail from the active-status check onward is the one every identified
|
|
1137
|
+
* sign-in runs (`loginService`, the OAuth flows): revoke the key being replaced,
|
|
1138
|
+
* register the new device key, stamp the last login, announce it after commit.
|
|
1139
|
+
* Passkeys add a way to prove identity, not a second way to hold a session.
|
|
1140
|
+
*/
|
|
1141
|
+
declare function finishPasskeyLoginService(params: FinishPasskeyLoginParams): Promise<LoginResult>;
|
|
1142
|
+
/**
|
|
1143
|
+
* The caller's live passkeys, newest first.
|
|
1144
|
+
*/
|
|
1145
|
+
declare function listPasskeysService(userId: number): Promise<PasskeySummary[]>;
|
|
1146
|
+
interface RenamePasskeyParams {
|
|
1147
|
+
userId: number;
|
|
1148
|
+
passkeyId: string;
|
|
1149
|
+
label: string;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Rename a passkey. Owner-scoped, so someone else's id is a 404 and nothing
|
|
1153
|
+
* about it is disclosed.
|
|
1154
|
+
*
|
|
1155
|
+
* No recent-authentication gate: a label is display only, and nothing is
|
|
1156
|
+
* authorized by it.
|
|
1157
|
+
*/
|
|
1158
|
+
declare function renamePasskeyService(params: RenamePasskeyParams): Promise<{
|
|
1159
|
+
passkeyId: string;
|
|
1160
|
+
label: string;
|
|
1161
|
+
}>;
|
|
1162
|
+
interface RevokePasskeyParams {
|
|
1163
|
+
userId: number;
|
|
1164
|
+
keyId: string;
|
|
1165
|
+
passkeyId: string;
|
|
1166
|
+
currentPassword?: string;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Retire a passkey.
|
|
1170
|
+
*
|
|
1171
|
+
* Gated on the second factor for an enrolled account, and then — for every
|
|
1172
|
+
* account, enrolled or not — on recent authentication, because someone who
|
|
1173
|
+
* walked up to an unlocked laptop should not be able to strip the account's
|
|
1174
|
+
* credentials; and on the
|
|
1175
|
+
* last-recovery-credential guard, because there is no undo for the state that
|
|
1176
|
+
* would leave.
|
|
1177
|
+
*
|
|
1178
|
+
* The owner row is locked before the guard runs, and the route's
|
|
1179
|
+
* `Transactional()` is what holds that lock to commit. Without it the guard is a
|
|
1180
|
+
* read-modify-write with a gap: an owner with two passkeys and nothing else who
|
|
1181
|
+
* fires two revokes at once has both count two live credentials, both pass, and
|
|
1182
|
+
* both revoke — the exact state the guard exists to refuse. Locking makes the
|
|
1183
|
+
* second revoke count one.
|
|
1184
|
+
*/
|
|
1185
|
+
declare function revokePasskeyService(params: RevokePasskeyParams): Promise<{
|
|
1186
|
+
passkeyId: string;
|
|
1187
|
+
}>;
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Auth provider type
|
|
1191
|
+
*
|
|
1192
|
+
* 직접 인증(email/phone) + 등록 가능한 모든 소셜 provider(SOCIAL_PROVIDERS).
|
|
1193
|
+
*/
|
|
1194
|
+
declare const AuthProviderSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"email">, _sinclair_typebox.TLiteral<"phone">, ..._sinclair_typebox.TLiteral<"google" | "apple" | "github" | "kakao" | "naver" | "superself">[]]>;
|
|
1195
|
+
/** The doors `auth.login` can name, as a type. */
|
|
1196
|
+
type AuthLoginProvider = Static<typeof AuthLoginProviderSchema>;
|
|
1197
|
+
/**
|
|
1198
|
+
* Login provider type
|
|
1199
|
+
*
|
|
1200
|
+
* AuthProviderSchema + `'device'` and `'passkey'`.
|
|
1201
|
+
*
|
|
1202
|
+
* `'device'` is how a device-code login names itself: the account was proven on
|
|
1203
|
+
* another device that was already signed in, so no credential was presented here
|
|
1204
|
+
* and none of the values above describes it. `'passkey'` is a WebAuthn assertion
|
|
1205
|
+
* — a credential of the account, but not one of the sign-up channels.
|
|
1206
|
+
*
|
|
1207
|
+
* A separate union rather than a widened AuthProviderSchema. Neither is a way to
|
|
1208
|
+
* register — a device-code request can only ever be approved by an existing
|
|
1209
|
+
* account, and a passkey has to be enrolled from a session that already exists —
|
|
1210
|
+
* and neither is something a provider can unlink, so the two events that mean
|
|
1211
|
+
* those things must not start accepting them.
|
|
1212
|
+
*/
|
|
1213
|
+
declare const AuthLoginProviderSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"email">, _sinclair_typebox.TLiteral<"phone">, _sinclair_typebox.TLiteral<"device">, _sinclair_typebox.TLiteral<"passkey">, ..._sinclair_typebox.TLiteral<"google" | "apple" | "github" | "kakao" | "naver" | "superself">[]]>;
|
|
1214
|
+
/**
|
|
1215
|
+
* auth.login - 로그인 성공 이벤트
|
|
1216
|
+
*
|
|
1217
|
+
* 발행 시점:
|
|
1218
|
+
* - 이메일/전화 로그인 성공 시
|
|
1219
|
+
* - OAuth 기존 사용자 로그인 시
|
|
1220
|
+
* - 기기 코드 승인이 소비되어 새 기기 키가 등록될 때 (provider: 'device')
|
|
1221
|
+
*
|
|
1222
|
+
* `mfaEnrolled` is computed as the event is emitted (#95) and is the whole of
|
|
1223
|
+
* the package's opinion about the second factor: it never blocks an account
|
|
1224
|
+
* that has none, and this is the hook an app uses to offer enrolment at a first
|
|
1225
|
+
* login. It says nothing about *which* factor and carries no secret.
|
|
1226
|
+
*
|
|
1227
|
+
* @example
|
|
1228
|
+
* ```typescript
|
|
1229
|
+
* authLoginEvent.subscribe(async (payload) => {
|
|
1230
|
+
* await analytics.trackLogin(payload.userId, payload.provider);
|
|
1231
|
+
* if (!payload.mfaEnrolled) await suggestSecondFactor(payload.userId);
|
|
1232
|
+
* });
|
|
1233
|
+
* ```
|
|
1234
|
+
*/
|
|
1235
|
+
declare const authLoginEvent: _spfn_core_event.EventDef<{
|
|
1236
|
+
email?: string | undefined;
|
|
1237
|
+
phone?: string | undefined;
|
|
1238
|
+
userId: string;
|
|
1239
|
+
provider: "email" | "phone" | "passkey" | "google" | "apple" | "github" | "kakao" | "naver" | "superself" | "device";
|
|
1240
|
+
mfaEnrolled: boolean;
|
|
1241
|
+
}>;
|
|
1242
|
+
/**
|
|
1243
|
+
* Where a device key was registered — the door the new device came through.
|
|
1145
1244
|
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1245
|
+
* Required on `RegisterPublicKeyParams` rather than optional with a default: a
|
|
1246
|
+
* new *call site* for key registration must choose one, and a default would let
|
|
1247
|
+
* it inherit somebody else's answer silently. `'register'` and `'signup-link'`
|
|
1248
|
+
* both arrive at `createVerifiedAccount`, so the two name themselves there;
|
|
1249
|
+
* `'invitation'` is the one path that stores a key without the key service.
|
|
1149
1250
|
*/
|
|
1150
|
-
declare
|
|
1251
|
+
declare const DeviceRegistrationChannelSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"register">, _sinclair_typebox.TLiteral<"signup-link">, _sinclair_typebox.TLiteral<"invitation">, _sinclair_typebox.TLiteral<"password">, _sinclair_typebox.TLiteral<"oauth">, _sinclair_typebox.TLiteral<"oauth-native">, _sinclair_typebox.TLiteral<"device-code">, _sinclair_typebox.TLiteral<"password-reset">, _sinclair_typebox.TLiteral<"passkey">, _sinclair_typebox.TLiteral<"renewal">]>;
|
|
1252
|
+
/** The ten doors a device key is registered through. */
|
|
1253
|
+
type DeviceRegistrationChannel = Static<typeof DeviceRegistrationChannelSchema>;
|
|
1151
1254
|
/**
|
|
1152
|
-
*
|
|
1255
|
+
* auth.device.registered — a new device key was added to an account
|
|
1153
1256
|
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1257
|
+
* 발행 시점:
|
|
1258
|
+
* - a key row was created for an account and the transaction that created it
|
|
1259
|
+
* committed, on every one of the ten channels above
|
|
1260
|
+
*
|
|
1261
|
+
* This is the notice an account owner needs and could not get before: a stolen
|
|
1262
|
+
* password used to sign in on a new device was silent, because a login event
|
|
1263
|
+
* says a session began and not what it began on. Rotation is deliberately not
|
|
1264
|
+
* announced — replacing the key of a device that is already signed in is not a
|
|
1265
|
+
* new device, and a notice for it would train the owner to ignore the ones that
|
|
1266
|
+
* matter.
|
|
1267
|
+
*
|
|
1268
|
+
* `ip` and `userAgent` are what the registering request said about itself. Both
|
|
1269
|
+
* are unauthenticated display material: nothing is decided by them, and a field
|
|
1270
|
+
* is absent rather than carrying a placeholder when the request resolved none.
|
|
1271
|
+
*
|
|
1272
|
+
* Neither the full fingerprint nor the public key is carried. The prefix is
|
|
1273
|
+
* enough to point at one entry of `listKeys`, which is what a notice needs.
|
|
1274
|
+
*
|
|
1275
|
+
* `mfaEnrolled` is computed as the event is emitted (#95), so a notice about a
|
|
1276
|
+
* new device can also be the moment an app offers a second factor to the
|
|
1277
|
+
* accounts that have none.
|
|
1278
|
+
*
|
|
1279
|
+
* @example
|
|
1280
|
+
* ```typescript
|
|
1281
|
+
* authDeviceRegisteredEvent.subscribe(async ({ userId, deviceName, ip, channel }) => {
|
|
1282
|
+
* await notifyOwner(userId, `A new device signed in (${deviceName ?? channel})`);
|
|
1283
|
+
* });
|
|
1284
|
+
* ```
|
|
1158
1285
|
*/
|
|
1159
|
-
declare
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1286
|
+
declare const authDeviceRegisteredEvent: _spfn_core_event.EventDef<{
|
|
1287
|
+
deviceName?: string | undefined;
|
|
1288
|
+
platform?: string | undefined;
|
|
1289
|
+
ip?: string | undefined;
|
|
1290
|
+
userAgent?: string | undefined;
|
|
1163
1291
|
keyId: string;
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
/** `user-agent` of the request, already truncated at the route. */
|
|
1172
|
-
userAgent?: string;
|
|
1173
|
-
/** Whether proxy-guard recognised the trusted Next.js proxy, from the same helper. */
|
|
1174
|
-
webProxy?: boolean;
|
|
1175
|
-
}
|
|
1292
|
+
algorithm: string;
|
|
1293
|
+
userId: string;
|
|
1294
|
+
mfaEnrolled: boolean;
|
|
1295
|
+
fingerprintPrefix: string;
|
|
1296
|
+
createdAtMillis: number;
|
|
1297
|
+
channel: "password" | "register" | "passkey" | "oauth-native" | "renewal" | "signup-link" | "invitation" | "oauth" | "device-code" | "password-reset";
|
|
1298
|
+
}>;
|
|
1176
1299
|
/**
|
|
1177
|
-
*
|
|
1178
|
-
* login does.
|
|
1300
|
+
* auth.register - 회원가입 성공 이벤트
|
|
1179
1301
|
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1302
|
+
* 발행 시점:
|
|
1303
|
+
* - 이메일/전화 회원가입 성공 시
|
|
1304
|
+
* - OAuth 신규 사용자 가입 시
|
|
1305
|
+
*
|
|
1306
|
+
* @example
|
|
1307
|
+
* ```typescript
|
|
1308
|
+
* authRegisterEvent.subscribe(async (payload) => {
|
|
1309
|
+
* await emailService.sendWelcome(payload.email);
|
|
1310
|
+
* });
|
|
1311
|
+
* ```
|
|
1312
|
+
*/
|
|
1313
|
+
declare const authRegisterEvent: _spfn_core_event.EventDef<{
|
|
1314
|
+
email?: string | undefined;
|
|
1315
|
+
phone?: string | undefined;
|
|
1316
|
+
metadata?: {
|
|
1317
|
+
[x: string]: unknown;
|
|
1318
|
+
} | undefined;
|
|
1319
|
+
userId: string;
|
|
1320
|
+
provider: "email" | "phone" | "google" | "apple" | "github" | "kakao" | "naver" | "superself";
|
|
1321
|
+
}>;
|
|
1322
|
+
/**
|
|
1323
|
+
* auth.invitation.created - 초대 생성 이벤트
|
|
1324
|
+
*
|
|
1325
|
+
* 발행 시점:
|
|
1326
|
+
* - createInvitation() 성공 시
|
|
1327
|
+
* - resendInvitation() 성공 시
|
|
1328
|
+
*
|
|
1329
|
+
* @example
|
|
1330
|
+
* ```typescript
|
|
1331
|
+
* invitationCreatedEvent.subscribe(async (payload) => {
|
|
1332
|
+
* const inviteUrl = `${APP_URL}/invite/${payload.token}`;
|
|
1333
|
+
* await notificationService.send({
|
|
1334
|
+
* channel: 'email',
|
|
1335
|
+
* to: payload.email,
|
|
1336
|
+
* subject: 'You are invited!',
|
|
1337
|
+
* html: renderInviteEmail({ inviteUrl, ...payload.metadata }),
|
|
1338
|
+
* });
|
|
1339
|
+
* });
|
|
1340
|
+
* ```
|
|
1341
|
+
*/
|
|
1342
|
+
declare const invitationCreatedEvent: _spfn_core_event.EventDef<{
|
|
1343
|
+
metadata?: {
|
|
1344
|
+
[x: string]: unknown;
|
|
1345
|
+
} | undefined;
|
|
1346
|
+
email: string;
|
|
1347
|
+
token: string;
|
|
1348
|
+
expiresAt: string;
|
|
1349
|
+
roleId: number;
|
|
1350
|
+
invitedBy: string;
|
|
1351
|
+
invitationId: string;
|
|
1352
|
+
isResend: boolean;
|
|
1353
|
+
}>;
|
|
1354
|
+
/**
|
|
1355
|
+
* auth.invitation.accepted - 초대 수락 이벤트
|
|
1356
|
+
*
|
|
1357
|
+
* 발행 시점:
|
|
1358
|
+
* - acceptInvitation() 성공 시
|
|
1359
|
+
*
|
|
1360
|
+
* @example
|
|
1361
|
+
* ```typescript
|
|
1362
|
+
* invitationAcceptedEvent.subscribe(async (payload) => {
|
|
1363
|
+
* await onboardingService.start(payload.userId);
|
|
1364
|
+
* });
|
|
1365
|
+
* ```
|
|
1366
|
+
*/
|
|
1367
|
+
declare const invitationAcceptedEvent: _spfn_core_event.EventDef<{
|
|
1368
|
+
metadata?: {
|
|
1369
|
+
[x: string]: unknown;
|
|
1370
|
+
} | undefined;
|
|
1371
|
+
email: string;
|
|
1372
|
+
userId: string;
|
|
1373
|
+
roleId: number;
|
|
1374
|
+
invitedBy: string;
|
|
1375
|
+
invitationId: string;
|
|
1376
|
+
}>;
|
|
1377
|
+
/**
|
|
1378
|
+
* auth.deletion.requested - 계정 탈퇴 요청 이벤트
|
|
1379
|
+
*
|
|
1380
|
+
* 발행 시점:
|
|
1381
|
+
* - requestAccountDeletionService() 성공 시 (self/admin 공통)
|
|
1382
|
+
*
|
|
1383
|
+
* @example
|
|
1384
|
+
* ```typescript
|
|
1385
|
+
* authDeletionRequestedEvent.subscribe(async (payload) => {
|
|
1386
|
+
* await analytics.trackChurnRisk(payload.userId);
|
|
1387
|
+
* });
|
|
1388
|
+
* ```
|
|
1389
|
+
*/
|
|
1390
|
+
declare const authDeletionRequestedEvent: _spfn_core_event.EventDef<{
|
|
1391
|
+
userId: string;
|
|
1392
|
+
purgeScheduledAt: string;
|
|
1393
|
+
userPublicId: string;
|
|
1394
|
+
requestedBy: "admin" | "self";
|
|
1395
|
+
}>;
|
|
1396
|
+
/**
|
|
1397
|
+
* auth.deletion.cancelled - 계정 탈퇴 복구 이벤트
|
|
1398
|
+
*
|
|
1399
|
+
* 발행 시점:
|
|
1400
|
+
* - cancelAccountDeletionService() 성공 시 (유예 기간 내 복구)
|
|
1401
|
+
*/
|
|
1402
|
+
declare const authDeletionCancelledEvent: _spfn_core_event.EventDef<{
|
|
1403
|
+
userId: string;
|
|
1404
|
+
userPublicId: string;
|
|
1405
|
+
}>;
|
|
1406
|
+
/**
|
|
1407
|
+
* auth.deletion.completed - 계정 파기 완료 이벤트
|
|
1408
|
+
*
|
|
1409
|
+
* 발행 시점:
|
|
1410
|
+
* - purge job(또는 즉시 파기 경로)이 유저를 파기한 직후
|
|
1411
|
+
*
|
|
1412
|
+
* PII를 담지 않는다 — userId(내부 순번)/email/phone 없이 userPublicId만 실어
|
|
1413
|
+
* 파기 완료 이후에도 구독자가 식별 정보를 다시 축적하지 않도록 한다.
|
|
1414
|
+
*/
|
|
1415
|
+
declare const authDeletionCompletedEvent: _spfn_core_event.EventDef<{
|
|
1416
|
+
userPublicId: string;
|
|
1417
|
+
purgeStrategy: "anonymize" | "hard-delete";
|
|
1418
|
+
}>;
|
|
1419
|
+
/**
|
|
1420
|
+
* auth.oauth.unlinked - provider발 연동 해제 이벤트
|
|
1421
|
+
*
|
|
1422
|
+
* 발행 시점:
|
|
1423
|
+
* - provider(카카오·네이버 등)가 unlink-notify 웹훅으로 연동 해제를 알려와
|
|
1424
|
+
* 소셜 계정 연결과 저장 토큰이 삭제된 직후
|
|
1425
|
+
*
|
|
1426
|
+
* 연결 삭제까지는 프레임워크가 수행하고, 그 이후(계정 탈퇴로 이어갈지 등)는
|
|
1427
|
+
* 앱 정책이므로 이 이벤트를 구독해 처리한다.
|
|
1428
|
+
*
|
|
1429
|
+
* @example
|
|
1430
|
+
* ```typescript
|
|
1431
|
+
* oauthUnlinkedEvent.subscribe(async (payload) => {
|
|
1432
|
+
* await requestAccountDeletionService({ userId: payload.userId, requestedBy: 'self' });
|
|
1433
|
+
* });
|
|
1434
|
+
* ```
|
|
1435
|
+
*/
|
|
1436
|
+
declare const oauthUnlinkedEvent: _spfn_core_event.EventDef<{
|
|
1437
|
+
reason?: string | undefined;
|
|
1438
|
+
userId: string;
|
|
1439
|
+
provider: "email" | "phone" | "google" | "apple" | "github" | "kakao" | "naver" | "superself";
|
|
1440
|
+
providerUserId: string;
|
|
1441
|
+
}>;
|
|
1442
|
+
/**
|
|
1443
|
+
* auth.password.reset — an account's password was replaced through a reset link
|
|
1444
|
+
*
|
|
1445
|
+
* 발행 시점:
|
|
1446
|
+
* - completePasswordResetService()가 커밋된 직후
|
|
1447
|
+
*
|
|
1448
|
+
* Distinct from a password *change*, which is made from a session that already
|
|
1449
|
+
* proved itself. This one is made by whoever opened a link in a mailbox, so it
|
|
1450
|
+
* is the event an app hangs a "your password was reset" notice on — and the
|
|
1451
|
+
* signal to look at, if the owner says they did not ask for it.
|
|
1452
|
+
*
|
|
1453
|
+
* @example
|
|
1454
|
+
* ```typescript
|
|
1455
|
+
* authPasswordResetEvent.subscribe(async (payload) => {
|
|
1456
|
+
* await notifyOwner(payload.userId, 'Your password was reset');
|
|
1457
|
+
* });
|
|
1458
|
+
* ```
|
|
1184
1459
|
*/
|
|
1185
|
-
declare
|
|
1460
|
+
declare const authPasswordResetEvent: _spfn_core_event.EventDef<{
|
|
1461
|
+
email: string;
|
|
1462
|
+
userId: string;
|
|
1463
|
+
}>;
|
|
1186
1464
|
/**
|
|
1187
|
-
*
|
|
1465
|
+
* Auth event payload types
|
|
1188
1466
|
*/
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1467
|
+
type AuthLoginPayload = typeof authLoginEvent._payload;
|
|
1468
|
+
type AuthRegisterPayload = typeof authRegisterEvent._payload;
|
|
1469
|
+
type AuthPasswordResetPayload = typeof authPasswordResetEvent._payload;
|
|
1470
|
+
type AuthDeviceRegisteredPayload = typeof authDeviceRegisteredEvent._payload;
|
|
1471
|
+
type InvitationCreatedPayload = typeof invitationCreatedEvent._payload;
|
|
1472
|
+
type InvitationAcceptedPayload = typeof invitationAcceptedEvent._payload;
|
|
1473
|
+
type AuthDeletionRequestedPayload = typeof authDeletionRequestedEvent._payload;
|
|
1474
|
+
type AuthDeletionCancelledPayload = typeof authDeletionCancelledEvent._payload;
|
|
1475
|
+
type AuthDeletionCompletedPayload = typeof authDeletionCompletedEvent._payload;
|
|
1476
|
+
type OAuthUnlinkedPayload = typeof oauthUnlinkedEvent._payload;
|
|
1477
|
+
|
|
1195
1478
|
/**
|
|
1196
|
-
*
|
|
1197
|
-
* about it is disclosed.
|
|
1479
|
+
* The four doors a second factor is asked for at.
|
|
1198
1480
|
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1481
|
+
* A subset of `DeviceRegistrationChannel`, and the subset is the decision: these
|
|
1482
|
+
* are the four where a stolen first credential — a password, a social account, a
|
|
1483
|
+
* mailbox — is enough to reach a new device on its own. `device-code` and
|
|
1484
|
+
* `passkey` are exempt because each already carried a second proof, and
|
|
1485
|
+
* `register`, `signup-link`, `invitation` and `renewal` cannot apply (a
|
|
1486
|
+
* brand-new account has nothing enrolled, and a renewal replaces a key on a
|
|
1487
|
+
* device that is already signed in).
|
|
1201
1488
|
*/
|
|
1202
|
-
declare
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
interface RevokePasskeyParams {
|
|
1207
|
-
userId: number;
|
|
1208
|
-
keyId: string;
|
|
1209
|
-
passkeyId: string;
|
|
1210
|
-
currentPassword?: string;
|
|
1211
|
-
}
|
|
1489
|
+
declare const MFA_CHALLENGE_CHANNELS: readonly ["password", "oauth", "oauth-native", "password-reset"];
|
|
1490
|
+
type MfaChallengeChannel = typeof MFA_CHALLENGE_CHANNELS[number];
|
|
1491
|
+
/** How many wrong proofs a challenge survives before it is spent for good. */
|
|
1492
|
+
declare const MFA_CHALLENGE_ATTEMPT_LIMIT = 5;
|
|
1212
1493
|
/**
|
|
1213
|
-
*
|
|
1494
|
+
* The `authLoginEvent` payload this registration would have emitted.
|
|
1214
1495
|
*
|
|
1215
|
-
*
|
|
1216
|
-
*
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
* would leave.
|
|
1496
|
+
* Carried rather than recomputed because it cannot be recomputed: the app's own
|
|
1497
|
+
* `metadata` reached the sign-in in a sealed OAuth state or a request body that
|
|
1498
|
+
* is gone by the time `verify` runs, and the provider is the social provider on
|
|
1499
|
+
* the OAuth channels rather than anything the user row says. Null on
|
|
1500
|
+
* `password-reset`, which announces a reset and never a login.
|
|
1221
1501
|
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1225
|
-
* fires two revokes at once has both count two live credentials, both pass, and
|
|
1226
|
-
* both revoke — the exact state the guard exists to refuse. Locking makes the
|
|
1227
|
-
* second revoke count one.
|
|
1502
|
+
* `mfaEnrolled` is not stored: the account is enrolled by construction — that is
|
|
1503
|
+
* why there is a challenge — and re-reading it at verify keeps the payload
|
|
1504
|
+
* honest if the second factor went away in between.
|
|
1228
1505
|
*/
|
|
1229
|
-
|
|
1230
|
-
|
|
1506
|
+
interface DeferredLoginEvent {
|
|
1507
|
+
provider: AuthLoginProvider;
|
|
1508
|
+
email?: string;
|
|
1509
|
+
phone?: string;
|
|
1510
|
+
metadata?: Record<string, unknown>;
|
|
1511
|
+
}
|
|
1512
|
+
declare const mfaChallenges: drizzle_orm_pg_core.PgTableWithColumns<{
|
|
1513
|
+
name: "mfa_challenges";
|
|
1514
|
+
schema: string;
|
|
1515
|
+
columns: {
|
|
1516
|
+
createdAt: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.PgTimestampBuilder>>, {
|
|
1517
|
+
name: string;
|
|
1518
|
+
tableName: "mfa_challenges";
|
|
1519
|
+
dataType: "object date";
|
|
1520
|
+
data: Date;
|
|
1521
|
+
driverParam: string;
|
|
1522
|
+
notNull: true;
|
|
1523
|
+
hasDefault: true;
|
|
1524
|
+
isPrimaryKey: false;
|
|
1525
|
+
isAutoincrement: false;
|
|
1526
|
+
hasRuntimeDefault: false;
|
|
1527
|
+
enumValues: undefined;
|
|
1528
|
+
identity: undefined;
|
|
1529
|
+
generated: undefined;
|
|
1530
|
+
}>;
|
|
1531
|
+
updatedAt: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.PgTimestampBuilder>>>, {
|
|
1532
|
+
name: string;
|
|
1533
|
+
tableName: "mfa_challenges";
|
|
1534
|
+
dataType: "object date";
|
|
1535
|
+
data: Date;
|
|
1536
|
+
driverParam: string;
|
|
1537
|
+
notNull: true;
|
|
1538
|
+
hasDefault: true;
|
|
1539
|
+
isPrimaryKey: false;
|
|
1540
|
+
isAutoincrement: false;
|
|
1541
|
+
hasRuntimeDefault: false;
|
|
1542
|
+
enumValues: undefined;
|
|
1543
|
+
identity: undefined;
|
|
1544
|
+
generated: undefined;
|
|
1545
|
+
}>;
|
|
1546
|
+
id: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetIsPrimaryKey<drizzle_orm_pg_core.PgBigSerial53Builder>, {
|
|
1547
|
+
name: string;
|
|
1548
|
+
tableName: "mfa_challenges";
|
|
1549
|
+
dataType: "number int53";
|
|
1550
|
+
data: number;
|
|
1551
|
+
driverParam: number;
|
|
1552
|
+
notNull: true;
|
|
1553
|
+
hasDefault: true;
|
|
1554
|
+
isPrimaryKey: false;
|
|
1555
|
+
isAutoincrement: false;
|
|
1556
|
+
hasRuntimeDefault: false;
|
|
1557
|
+
enumValues: undefined;
|
|
1558
|
+
identity: undefined;
|
|
1559
|
+
generated: undefined;
|
|
1560
|
+
}>;
|
|
1561
|
+
userId: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgBigInt53Builder>, {
|
|
1562
|
+
name: string;
|
|
1563
|
+
tableName: "mfa_challenges";
|
|
1564
|
+
dataType: "number int53";
|
|
1565
|
+
data: number;
|
|
1566
|
+
driverParam: string | number;
|
|
1567
|
+
notNull: true;
|
|
1568
|
+
hasDefault: false;
|
|
1569
|
+
isPrimaryKey: false;
|
|
1570
|
+
isAutoincrement: false;
|
|
1571
|
+
hasRuntimeDefault: false;
|
|
1572
|
+
enumValues: undefined;
|
|
1573
|
+
identity: undefined;
|
|
1574
|
+
generated: undefined;
|
|
1575
|
+
}>;
|
|
1576
|
+
challengeHash: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>>, {
|
|
1577
|
+
name: string;
|
|
1578
|
+
tableName: "mfa_challenges";
|
|
1579
|
+
dataType: "string";
|
|
1580
|
+
data: string;
|
|
1581
|
+
driverParam: string;
|
|
1582
|
+
notNull: true;
|
|
1583
|
+
hasDefault: false;
|
|
1584
|
+
isPrimaryKey: false;
|
|
1585
|
+
isAutoincrement: false;
|
|
1586
|
+
hasRuntimeDefault: false;
|
|
1587
|
+
enumValues: undefined;
|
|
1588
|
+
identity: undefined;
|
|
1589
|
+
generated: undefined;
|
|
1590
|
+
}>;
|
|
1591
|
+
keyId: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<[string, ...string[]]>>, {
|
|
1592
|
+
name: string;
|
|
1593
|
+
tableName: "mfa_challenges";
|
|
1594
|
+
dataType: "string";
|
|
1595
|
+
data: string;
|
|
1596
|
+
driverParam: string;
|
|
1597
|
+
notNull: true;
|
|
1598
|
+
hasDefault: false;
|
|
1599
|
+
isPrimaryKey: false;
|
|
1600
|
+
isAutoincrement: false;
|
|
1601
|
+
hasRuntimeDefault: false;
|
|
1602
|
+
enumValues: undefined;
|
|
1603
|
+
identity: undefined;
|
|
1604
|
+
generated: undefined;
|
|
1605
|
+
}>;
|
|
1606
|
+
channel: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTextBuilder<["password", "oauth", "oauth-native", "password-reset"] & [string, ...string[]]>>, {
|
|
1607
|
+
name: string;
|
|
1608
|
+
tableName: "mfa_challenges";
|
|
1609
|
+
dataType: "string enum";
|
|
1610
|
+
data: "password" | "oauth-native" | "oauth" | "password-reset";
|
|
1611
|
+
driverParam: string;
|
|
1612
|
+
notNull: true;
|
|
1613
|
+
hasDefault: false;
|
|
1614
|
+
isPrimaryKey: false;
|
|
1615
|
+
isAutoincrement: false;
|
|
1616
|
+
hasRuntimeDefault: false;
|
|
1617
|
+
enumValues: ["password", "oauth", "oauth-native", "password-reset"] & [string, ...string[]];
|
|
1618
|
+
identity: undefined;
|
|
1619
|
+
generated: undefined;
|
|
1620
|
+
}>;
|
|
1621
|
+
keyEpoch: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgIntegerBuilder>, {
|
|
1622
|
+
name: string;
|
|
1623
|
+
tableName: "mfa_challenges";
|
|
1624
|
+
dataType: "number int32";
|
|
1625
|
+
data: number;
|
|
1626
|
+
driverParam: string | number;
|
|
1627
|
+
notNull: true;
|
|
1628
|
+
hasDefault: false;
|
|
1629
|
+
isPrimaryKey: false;
|
|
1630
|
+
isAutoincrement: false;
|
|
1631
|
+
hasRuntimeDefault: false;
|
|
1632
|
+
enumValues: undefined;
|
|
1633
|
+
identity: undefined;
|
|
1634
|
+
generated: undefined;
|
|
1635
|
+
}>;
|
|
1636
|
+
attempts: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgIntegerBuilder>>, {
|
|
1637
|
+
name: string;
|
|
1638
|
+
tableName: "mfa_challenges";
|
|
1639
|
+
dataType: "number int32";
|
|
1640
|
+
data: number;
|
|
1641
|
+
driverParam: string | number;
|
|
1642
|
+
notNull: true;
|
|
1643
|
+
hasDefault: true;
|
|
1644
|
+
isPrimaryKey: false;
|
|
1645
|
+
isAutoincrement: false;
|
|
1646
|
+
hasRuntimeDefault: false;
|
|
1647
|
+
enumValues: undefined;
|
|
1648
|
+
identity: undefined;
|
|
1649
|
+
generated: undefined;
|
|
1650
|
+
}>;
|
|
1651
|
+
expiresAt: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTimestampBuilder>, {
|
|
1652
|
+
name: string;
|
|
1653
|
+
tableName: "mfa_challenges";
|
|
1654
|
+
dataType: "object date";
|
|
1655
|
+
data: Date;
|
|
1656
|
+
driverParam: string;
|
|
1657
|
+
notNull: true;
|
|
1658
|
+
hasDefault: false;
|
|
1659
|
+
isPrimaryKey: false;
|
|
1660
|
+
isAutoincrement: false;
|
|
1661
|
+
hasRuntimeDefault: false;
|
|
1662
|
+
enumValues: undefined;
|
|
1663
|
+
identity: undefined;
|
|
1664
|
+
generated: undefined;
|
|
1665
|
+
}>;
|
|
1666
|
+
verifiedAt: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.PgTimestampBuilder, {
|
|
1667
|
+
name: string;
|
|
1668
|
+
tableName: "mfa_challenges";
|
|
1669
|
+
dataType: "object date";
|
|
1670
|
+
data: Date;
|
|
1671
|
+
driverParam: string;
|
|
1672
|
+
notNull: false;
|
|
1673
|
+
hasDefault: false;
|
|
1674
|
+
isPrimaryKey: false;
|
|
1675
|
+
isAutoincrement: false;
|
|
1676
|
+
hasRuntimeDefault: false;
|
|
1677
|
+
enumValues: undefined;
|
|
1678
|
+
identity: undefined;
|
|
1679
|
+
generated: undefined;
|
|
1680
|
+
}>;
|
|
1681
|
+
loginEvent: drizzle_orm_pg_core.PgBuildColumn<"mfa_challenges", drizzle_orm_pg_core.Set$Type<drizzle_orm_pg_core.PgJsonbBuilder, DeferredLoginEvent>, {
|
|
1682
|
+
name: string;
|
|
1683
|
+
tableName: "mfa_challenges";
|
|
1684
|
+
dataType: "object json";
|
|
1685
|
+
data: DeferredLoginEvent;
|
|
1686
|
+
driverParam: unknown;
|
|
1687
|
+
notNull: false;
|
|
1688
|
+
hasDefault: false;
|
|
1689
|
+
isPrimaryKey: false;
|
|
1690
|
+
isAutoincrement: false;
|
|
1691
|
+
hasRuntimeDefault: false;
|
|
1692
|
+
enumValues: undefined;
|
|
1693
|
+
identity: undefined;
|
|
1694
|
+
generated: undefined;
|
|
1695
|
+
}>;
|
|
1696
|
+
};
|
|
1697
|
+
dialect: "pg";
|
|
1231
1698
|
}>;
|
|
1699
|
+
type MfaChallenge = typeof mfaChallenges.$inferSelect;
|
|
1700
|
+
type NewMfaChallenge = typeof mfaChallenges.$inferInsert;
|
|
1232
1701
|
|
|
1233
1702
|
/**
|
|
1234
1703
|
* @spfn/auth - Second-Factor Verification Entity
|
|
@@ -1374,22 +1843,131 @@ interface AssertStepUpParams {
|
|
|
1374
1843
|
* the passkey routes' `assertRecentAuthentication` — and they still run it
|
|
1375
1844
|
* afterwards; this adds a rule for enrolled accounts rather than replacing one.
|
|
1376
1845
|
*
|
|
1377
|
-
* The unenrolled path therefore costs one indexed lookup and touches no passkey
|
|
1378
|
-
* configuration, which is what lets an app with no passkeys at all call it.
|
|
1846
|
+
* The unenrolled path therefore costs one indexed lookup and touches no passkey
|
|
1847
|
+
* configuration, which is what lets an app with no passkeys at all call it.
|
|
1848
|
+
*
|
|
1849
|
+
* @throws StepUpRequiredError when the account is enrolled and the window has passed
|
|
1850
|
+
*/
|
|
1851
|
+
declare function assertStepUp(params: AssertStepUpParams): Promise<void>;
|
|
1852
|
+
/**
|
|
1853
|
+
* Carry a device's verification onto the key that replaces it.
|
|
1854
|
+
*
|
|
1855
|
+
* Called from the two rotation seams — `rotateKeyService`, and the `oldKeyId`
|
|
1856
|
+
* rotation every login path runs through `registerPublicKeyService`. Without
|
|
1857
|
+
* it the window would expire silently on every rotation, which the web proxy
|
|
1858
|
+
* does at each login: a user who stepped up a minute ago would be asked again
|
|
1859
|
+
* with nothing to connect it to.
|
|
1860
|
+
*/
|
|
1861
|
+
declare function carryStepUpVerification(userId: number, fromKeyId: string, toKeyId: string): Promise<void>;
|
|
1862
|
+
interface OpenStepUpChallengeParams {
|
|
1863
|
+
userId: number;
|
|
1864
|
+
/** The inactive key this challenge would activate. */
|
|
1865
|
+
keyId: string;
|
|
1866
|
+
channel: MfaChallengeChannel;
|
|
1867
|
+
/** The account's key generation right now — the challenge dies with it. */
|
|
1868
|
+
keyEpoch: number;
|
|
1869
|
+
/** The login announcement the 202 is holding back, when the channel has one. */
|
|
1870
|
+
loginEvent?: DeferredLoginEvent;
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Mint the challenge a stopped registration hands back.
|
|
1874
|
+
*
|
|
1875
|
+
* The secret is returned once and never stored: only its hash reaches the row,
|
|
1876
|
+
* so a database dump does not yield a spendable challenge, and `verify` finds a
|
|
1877
|
+
* row only for a caller who already had the secret. The row id is not in the
|
|
1878
|
+
* answer at all — it is a sequence, and a sequence on an unauthenticated route
|
|
1879
|
+
* is a thing an attacker walks.
|
|
1880
|
+
*/
|
|
1881
|
+
declare function openStepUpChallengeService(params: OpenStepUpChallengeParams): Promise<MfaChallengeHandle>;
|
|
1882
|
+
/**
|
|
1883
|
+
* The challenge already outstanding for this key, re-secreted, or null.
|
|
1884
|
+
*
|
|
1885
|
+
* Registering the same keyId twice is an ordinary path, not a collision: a
|
|
1886
|
+
* native client reuses its keyId, and an OAuth state replayed from the back
|
|
1887
|
+
* button carries the one it was sealed with. Answering 409 there would refuse a
|
|
1888
|
+
* caller for holding a key that is their own and is waiting on them.
|
|
1889
|
+
*
|
|
1890
|
+
* The row is reused rather than replaced, so the retry inherits the attempts
|
|
1891
|
+
* already spent and the expiry already ticking — retrying is not a way around
|
|
1892
|
+
* either. Only the secret is new, because the first one exists nowhere: the row
|
|
1893
|
+
* holds its hash, and that is the property that keeps a database dump from
|
|
1894
|
+
* yielding a spendable challenge.
|
|
1895
|
+
*/
|
|
1896
|
+
declare function resumeStepUpChallengeService(userId: number, keyId: string): Promise<MfaChallengeHandle | null>;
|
|
1897
|
+
interface VerifyMfaChallengeParams {
|
|
1898
|
+
/** The secret from the 202 body, the callback query, or the pending page. */
|
|
1899
|
+
challenge: string;
|
|
1900
|
+
code?: string;
|
|
1901
|
+
recoveryCode?: string;
|
|
1902
|
+
response?: AuthenticationResponseJSON;
|
|
1903
|
+
}
|
|
1904
|
+
/**
|
|
1905
|
+
* The sign-in the challenge was standing in for, plus what the proxy needs.
|
|
1906
|
+
*
|
|
1907
|
+
* `keyId` and `challengeHash` are for the Next.js interceptor and nothing else:
|
|
1908
|
+
* it seals a session only when both match the pending cookie it baked at the
|
|
1909
|
+
* 202, which is what stops a cookie minted for one flow from sealing a session
|
|
1910
|
+
* around another flow's key. Neither is a credential — the hash is what the
|
|
1911
|
+
* server already stores, and the key is inactive to anyone without the private
|
|
1912
|
+
* half the proxy is holding.
|
|
1913
|
+
*/
|
|
1914
|
+
interface VerifyMfaChallengeResult extends LoginResult {
|
|
1915
|
+
keyId: string;
|
|
1916
|
+
challengeHash: string;
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Spend a new-device challenge, which activates the key and starts the session.
|
|
1920
|
+
*
|
|
1921
|
+
* Unauthenticated by construction: the key this would activate is the only one
|
|
1922
|
+
* the caller has and it cannot sign anything yet. The challenge secret is the
|
|
1923
|
+
* whole credential, and it authorizes exactly this — no other route reads it.
|
|
1924
|
+
*
|
|
1925
|
+
* The verified mark is a conditional UPDATE, so two requests carrying the same
|
|
1926
|
+
* secret produce one session and one 401. The key is activated only after that
|
|
1927
|
+
* mark is won, which is what makes "a pending challenge never yields a usable
|
|
1928
|
+
* key" true even under a race.
|
|
1379
1929
|
*
|
|
1380
|
-
*
|
|
1930
|
+
* The binding the answer carries was decided at **registration**, not here, and
|
|
1931
|
+
* is read back off the key row. It has to be: the decision reads the owner's
|
|
1932
|
+
* `session_binding` setting together with whether the request came through the
|
|
1933
|
+
* trusted Next.js proxy, and this route is unauthenticated and may be called
|
|
1934
|
+
* from anywhere — deciding it here would let a direct caller ask for a cookie
|
|
1935
|
+
* that believes a bound key is an ordinary one. A bound account that steps up
|
|
1936
|
+
* therefore gets exactly the binding and the expiry its sign-in registered, and
|
|
1937
|
+
* the ten minutes a challenge may sit for come out of that key's short life.
|
|
1938
|
+
*
|
|
1939
|
+
* @throws ValidationError when the body names none or more than one proof
|
|
1940
|
+
* @throws MfaVerificationFailedError for every other refusal, in one body
|
|
1381
1941
|
*/
|
|
1382
|
-
declare function
|
|
1942
|
+
declare function verifyMfaChallengeService(params: VerifyMfaChallengeParams): Promise<VerifyMfaChallengeResult>;
|
|
1383
1943
|
/**
|
|
1384
|
-
*
|
|
1944
|
+
* Options for finishing a new-device step-up with a passkey.
|
|
1385
1945
|
*
|
|
1386
|
-
*
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*
|
|
1946
|
+
* The step-up challenge stands in for the session this caller does not have yet:
|
|
1947
|
+
* it is what names the account, so the WebAuthn challenge can be minted for the
|
|
1948
|
+
* right owner without the request having to say who that is. `allowCredentials`
|
|
1949
|
+
* is empty for the reason it is everywhere else here (D3), and the ceremony kind
|
|
1950
|
+
* is `'mfa'`, so what comes back cannot be spent as a sign-in.
|
|
1951
|
+
*
|
|
1952
|
+
* @throws MfaVerificationFailedError when the challenge cannot be spent
|
|
1391
1953
|
*/
|
|
1392
|
-
declare function
|
|
1954
|
+
declare function startMfaChallengeAssertionService(challenge: string): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
1955
|
+
/**
|
|
1956
|
+
* Drop expired and spent challenges, and the keys they were holding.
|
|
1957
|
+
*
|
|
1958
|
+
* A pending key is unusable by construction, but it is still a key row on an
|
|
1959
|
+
* account nobody is watching, and its challenge is what the owner would be shown
|
|
1960
|
+
* if anything ever listed it. Both go once the challenge can no longer do
|
|
1961
|
+
* anything. A spent one is kept for the same span as an expired one, so a replay
|
|
1962
|
+
* inside the window is answered "already verified" from a row rather than
|
|
1963
|
+
* "unknown" from an absence — the same 401 either way, but the record survives
|
|
1964
|
+
* long enough to be read in a log.
|
|
1965
|
+
*
|
|
1966
|
+
* @returns number of challenge rows deleted
|
|
1967
|
+
*/
|
|
1968
|
+
declare function sweepMfaChallengesService(): Promise<{
|
|
1969
|
+
deleted: number;
|
|
1970
|
+
}>;
|
|
1393
1971
|
interface TotpEnrolmentResult {
|
|
1394
1972
|
/** The base32 secret, shown once. Never logged, never returned again. */
|
|
1395
1973
|
secret: string;
|
|
@@ -1484,333 +2062,75 @@ interface MfaStatus {
|
|
|
1484
2062
|
* The account's second-factor state.
|
|
1485
2063
|
*
|
|
1486
2064
|
* Carries no secret, no otpauth URI and no recovery code — only the counts and
|
|
1487
|
-
* names an account screen needs. An unconfirmed enrolment is not a method: it
|
|
1488
|
-
* gates nothing, so reporting it would tell the owner they are protected when
|
|
1489
|
-
* they are not.
|
|
1490
|
-
*/
|
|
1491
|
-
declare function mfaStatusService(userId: number): Promise<MfaStatus>;
|
|
1492
|
-
/**
|
|
1493
|
-
* Options for a step-up by passkey assertion.
|
|
1494
|
-
*
|
|
1495
|
-
* `allowCredentials` is empty, as it is for a sign-in and for the same reason
|
|
1496
|
-
* (D3): the discoverable credential on the device names itself, and the owner
|
|
1497
|
-
* and the second-factor mark are checked when the assertion comes back. The
|
|
1498
|
-
* challenge is minted with kind `'mfa'` and this account's id, so it cannot be
|
|
1499
|
-
* presented to `passkeys/login/verify` and a sign-in challenge cannot be
|
|
1500
|
-
* presented here.
|
|
1501
|
-
*/
|
|
1502
|
-
declare function startStepUpService(userId: number): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
1503
|
-
interface StepUpParams {
|
|
1504
|
-
userId: number;
|
|
1505
|
-
/** The device the verification is recorded against. */
|
|
1506
|
-
keyId: string;
|
|
1507
|
-
code?: string;
|
|
1508
|
-
recoveryCode?: string;
|
|
1509
|
-
response?: AuthenticationResponseJSON;
|
|
1510
|
-
}
|
|
1511
|
-
/**
|
|
1512
|
-
* Re-prove the second factor on this device, refreshing its window.
|
|
1513
|
-
*
|
|
1514
|
-
* The escape hatch the exempt registration channels need: a device-code
|
|
1515
|
-
* approval and a passkey sign-in register a key with no verification against
|
|
1516
|
-
* it, by design, so the session they create would otherwise fail every
|
|
1517
|
-
* sensitive change with no way forward.
|
|
1518
|
-
*
|
|
1519
|
-
* @throws ValidationError when the body names none or more than one input
|
|
1520
|
-
* @throws MfaNotEnrolledError | MfaVerificationFailedError
|
|
1521
|
-
*/
|
|
1522
|
-
declare function stepUpService(params: StepUpParams): Promise<void>;
|
|
1523
|
-
/**
|
|
1524
|
-
* Check exactly one of the three proofs, and say which one it was.
|
|
1525
|
-
*
|
|
1526
|
-
* Exactly one: two inputs in a body is a caller trying combinations, and none
|
|
1527
|
-
* is a malformed request. Neither is a failed verification, so both are a 400
|
|
1528
|
-
* rather than the uniform 401 a wrong proof gets.
|
|
1529
|
-
*
|
|
1530
|
-
* @throws ValidationError | MfaVerificationFailedError
|
|
1531
|
-
*/
|
|
1532
|
-
declare function verifySecondFactor(params: StepUpParams): Promise<MfaVerificationMethod>;
|
|
1533
|
-
/**
|
|
1534
|
-
* Drop enrolments nobody ever confirmed.
|
|
1535
|
-
*
|
|
1536
|
-
* A secret handed out and abandoned is a credential sitting in a table doing
|
|
1537
|
-
* nothing; a day is long enough for anyone who meant to finish.
|
|
1538
|
-
*
|
|
1539
|
-
* @returns number of rows deleted
|
|
1540
|
-
*/
|
|
1541
|
-
declare function sweepUnconfirmedMfaService(): Promise<{
|
|
1542
|
-
deleted: number;
|
|
1543
|
-
}>;
|
|
1544
|
-
|
|
1545
|
-
/**
|
|
1546
|
-
* Auth provider type
|
|
1547
|
-
*
|
|
1548
|
-
* 직접 인증(email/phone) + 등록 가능한 모든 소셜 provider(SOCIAL_PROVIDERS).
|
|
1549
|
-
*/
|
|
1550
|
-
declare const AuthProviderSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"email">, _sinclair_typebox.TLiteral<"phone">, ..._sinclair_typebox.TLiteral<"google" | "apple" | "github" | "kakao" | "naver" | "superself">[]]>;
|
|
1551
|
-
/**
|
|
1552
|
-
* auth.login - 로그인 성공 이벤트
|
|
1553
|
-
*
|
|
1554
|
-
* 발행 시점:
|
|
1555
|
-
* - 이메일/전화 로그인 성공 시
|
|
1556
|
-
* - OAuth 기존 사용자 로그인 시
|
|
1557
|
-
* - 기기 코드 승인이 소비되어 새 기기 키가 등록될 때 (provider: 'device')
|
|
1558
|
-
*
|
|
1559
|
-
* `mfaEnrolled` is computed as the event is emitted (#95) and is the whole of
|
|
1560
|
-
* the package's opinion about the second factor: it never blocks an account
|
|
1561
|
-
* that has none, and this is the hook an app uses to offer enrolment at a first
|
|
1562
|
-
* login. It says nothing about *which* factor and carries no secret.
|
|
1563
|
-
*
|
|
1564
|
-
* @example
|
|
1565
|
-
* ```typescript
|
|
1566
|
-
* authLoginEvent.subscribe(async (payload) => {
|
|
1567
|
-
* await analytics.trackLogin(payload.userId, payload.provider);
|
|
1568
|
-
* if (!payload.mfaEnrolled) await suggestSecondFactor(payload.userId);
|
|
1569
|
-
* });
|
|
1570
|
-
* ```
|
|
1571
|
-
*/
|
|
1572
|
-
declare const authLoginEvent: _spfn_core_event.EventDef<{
|
|
1573
|
-
email?: string | undefined;
|
|
1574
|
-
phone?: string | undefined;
|
|
1575
|
-
userId: string;
|
|
1576
|
-
provider: "email" | "phone" | "passkey" | "google" | "apple" | "github" | "kakao" | "naver" | "superself" | "device";
|
|
1577
|
-
mfaEnrolled: boolean;
|
|
1578
|
-
}>;
|
|
1579
|
-
/**
|
|
1580
|
-
* Where a device key was registered — the door the new device came through.
|
|
1581
|
-
*
|
|
1582
|
-
* Required on `RegisterPublicKeyParams` rather than optional with a default: a
|
|
1583
|
-
* new *call site* for key registration must choose one, and a default would let
|
|
1584
|
-
* it inherit somebody else's answer silently. `'register'` and `'signup-link'`
|
|
1585
|
-
* both arrive at `createVerifiedAccount`, so the two name themselves there;
|
|
1586
|
-
* `'invitation'` is the one path that stores a key without the key service.
|
|
1587
|
-
*/
|
|
1588
|
-
declare const DeviceRegistrationChannelSchema: _sinclair_typebox.TUnion<[_sinclair_typebox.TLiteral<"register">, _sinclair_typebox.TLiteral<"signup-link">, _sinclair_typebox.TLiteral<"invitation">, _sinclair_typebox.TLiteral<"password">, _sinclair_typebox.TLiteral<"oauth">, _sinclair_typebox.TLiteral<"oauth-native">, _sinclair_typebox.TLiteral<"device-code">, _sinclair_typebox.TLiteral<"password-reset">, _sinclair_typebox.TLiteral<"passkey">, _sinclair_typebox.TLiteral<"renewal">]>;
|
|
1589
|
-
/** The ten doors a device key is registered through. */
|
|
1590
|
-
type DeviceRegistrationChannel = Static<typeof DeviceRegistrationChannelSchema>;
|
|
1591
|
-
/**
|
|
1592
|
-
* auth.device.registered — a new device key was added to an account
|
|
1593
|
-
*
|
|
1594
|
-
* 발행 시점:
|
|
1595
|
-
* - a key row was created for an account and the transaction that created it
|
|
1596
|
-
* committed, on every one of the ten channels above
|
|
1597
|
-
*
|
|
1598
|
-
* This is the notice an account owner needs and could not get before: a stolen
|
|
1599
|
-
* password used to sign in on a new device was silent, because a login event
|
|
1600
|
-
* says a session began and not what it began on. Rotation is deliberately not
|
|
1601
|
-
* announced — replacing the key of a device that is already signed in is not a
|
|
1602
|
-
* new device, and a notice for it would train the owner to ignore the ones that
|
|
1603
|
-
* matter.
|
|
1604
|
-
*
|
|
1605
|
-
* `ip` and `userAgent` are what the registering request said about itself. Both
|
|
1606
|
-
* are unauthenticated display material: nothing is decided by them, and a field
|
|
1607
|
-
* is absent rather than carrying a placeholder when the request resolved none.
|
|
1608
|
-
*
|
|
1609
|
-
* Neither the full fingerprint nor the public key is carried. The prefix is
|
|
1610
|
-
* enough to point at one entry of `listKeys`, which is what a notice needs.
|
|
1611
|
-
*
|
|
1612
|
-
* `mfaEnrolled` is computed as the event is emitted (#95), so a notice about a
|
|
1613
|
-
* new device can also be the moment an app offers a second factor to the
|
|
1614
|
-
* accounts that have none.
|
|
1615
|
-
*
|
|
1616
|
-
* @example
|
|
1617
|
-
* ```typescript
|
|
1618
|
-
* authDeviceRegisteredEvent.subscribe(async ({ userId, deviceName, ip, channel }) => {
|
|
1619
|
-
* await notifyOwner(userId, `A new device signed in (${deviceName ?? channel})`);
|
|
1620
|
-
* });
|
|
1621
|
-
* ```
|
|
1622
|
-
*/
|
|
1623
|
-
declare const authDeviceRegisteredEvent: _spfn_core_event.EventDef<{
|
|
1624
|
-
deviceName?: string | undefined;
|
|
1625
|
-
platform?: string | undefined;
|
|
1626
|
-
ip?: string | undefined;
|
|
1627
|
-
userAgent?: string | undefined;
|
|
1628
|
-
keyId: string;
|
|
1629
|
-
algorithm: string;
|
|
1630
|
-
userId: string;
|
|
1631
|
-
fingerprintPrefix: string;
|
|
1632
|
-
createdAtMillis: number;
|
|
1633
|
-
mfaEnrolled: boolean;
|
|
1634
|
-
channel: "password" | "register" | "passkey" | "oauth-native" | "renewal" | "oauth" | "invitation" | "signup-link" | "password-reset" | "device-code";
|
|
1635
|
-
}>;
|
|
1636
|
-
/**
|
|
1637
|
-
* auth.register - 회원가입 성공 이벤트
|
|
1638
|
-
*
|
|
1639
|
-
* 발행 시점:
|
|
1640
|
-
* - 이메일/전화 회원가입 성공 시
|
|
1641
|
-
* - OAuth 신규 사용자 가입 시
|
|
1642
|
-
*
|
|
1643
|
-
* @example
|
|
1644
|
-
* ```typescript
|
|
1645
|
-
* authRegisterEvent.subscribe(async (payload) => {
|
|
1646
|
-
* await emailService.sendWelcome(payload.email);
|
|
1647
|
-
* });
|
|
1648
|
-
* ```
|
|
1649
|
-
*/
|
|
1650
|
-
declare const authRegisterEvent: _spfn_core_event.EventDef<{
|
|
1651
|
-
email?: string | undefined;
|
|
1652
|
-
phone?: string | undefined;
|
|
1653
|
-
metadata?: {
|
|
1654
|
-
[x: string]: unknown;
|
|
1655
|
-
} | undefined;
|
|
1656
|
-
userId: string;
|
|
1657
|
-
provider: "email" | "phone" | "google" | "apple" | "github" | "kakao" | "naver" | "superself";
|
|
1658
|
-
}>;
|
|
1659
|
-
/**
|
|
1660
|
-
* auth.invitation.created - 초대 생성 이벤트
|
|
1661
|
-
*
|
|
1662
|
-
* 발행 시점:
|
|
1663
|
-
* - createInvitation() 성공 시
|
|
1664
|
-
* - resendInvitation() 성공 시
|
|
1665
|
-
*
|
|
1666
|
-
* @example
|
|
1667
|
-
* ```typescript
|
|
1668
|
-
* invitationCreatedEvent.subscribe(async (payload) => {
|
|
1669
|
-
* const inviteUrl = `${APP_URL}/invite/${payload.token}`;
|
|
1670
|
-
* await notificationService.send({
|
|
1671
|
-
* channel: 'email',
|
|
1672
|
-
* to: payload.email,
|
|
1673
|
-
* subject: 'You are invited!',
|
|
1674
|
-
* html: renderInviteEmail({ inviteUrl, ...payload.metadata }),
|
|
1675
|
-
* });
|
|
1676
|
-
* });
|
|
1677
|
-
* ```
|
|
1678
|
-
*/
|
|
1679
|
-
declare const invitationCreatedEvent: _spfn_core_event.EventDef<{
|
|
1680
|
-
metadata?: {
|
|
1681
|
-
[x: string]: unknown;
|
|
1682
|
-
} | undefined;
|
|
1683
|
-
email: string;
|
|
1684
|
-
token: string;
|
|
1685
|
-
expiresAt: string;
|
|
1686
|
-
roleId: number;
|
|
1687
|
-
invitedBy: string;
|
|
1688
|
-
invitationId: string;
|
|
1689
|
-
isResend: boolean;
|
|
1690
|
-
}>;
|
|
1691
|
-
/**
|
|
1692
|
-
* auth.invitation.accepted - 초대 수락 이벤트
|
|
1693
|
-
*
|
|
1694
|
-
* 발행 시점:
|
|
1695
|
-
* - acceptInvitation() 성공 시
|
|
1696
|
-
*
|
|
1697
|
-
* @example
|
|
1698
|
-
* ```typescript
|
|
1699
|
-
* invitationAcceptedEvent.subscribe(async (payload) => {
|
|
1700
|
-
* await onboardingService.start(payload.userId);
|
|
1701
|
-
* });
|
|
1702
|
-
* ```
|
|
1703
|
-
*/
|
|
1704
|
-
declare const invitationAcceptedEvent: _spfn_core_event.EventDef<{
|
|
1705
|
-
metadata?: {
|
|
1706
|
-
[x: string]: unknown;
|
|
1707
|
-
} | undefined;
|
|
1708
|
-
email: string;
|
|
1709
|
-
userId: string;
|
|
1710
|
-
roleId: number;
|
|
1711
|
-
invitedBy: string;
|
|
1712
|
-
invitationId: string;
|
|
1713
|
-
}>;
|
|
1714
|
-
/**
|
|
1715
|
-
* auth.deletion.requested - 계정 탈퇴 요청 이벤트
|
|
1716
|
-
*
|
|
1717
|
-
* 발행 시점:
|
|
1718
|
-
* - requestAccountDeletionService() 성공 시 (self/admin 공통)
|
|
1719
|
-
*
|
|
1720
|
-
* @example
|
|
1721
|
-
* ```typescript
|
|
1722
|
-
* authDeletionRequestedEvent.subscribe(async (payload) => {
|
|
1723
|
-
* await analytics.trackChurnRisk(payload.userId);
|
|
1724
|
-
* });
|
|
1725
|
-
* ```
|
|
2065
|
+
* names an account screen needs. An unconfirmed enrolment is not a method: it
|
|
2066
|
+
* gates nothing, so reporting it would tell the owner they are protected when
|
|
2067
|
+
* they are not.
|
|
1726
2068
|
*/
|
|
1727
|
-
declare
|
|
1728
|
-
userId: string;
|
|
1729
|
-
purgeScheduledAt: string;
|
|
1730
|
-
userPublicId: string;
|
|
1731
|
-
requestedBy: "admin" | "self";
|
|
1732
|
-
}>;
|
|
2069
|
+
declare function mfaStatusService(userId: number): Promise<MfaStatus>;
|
|
1733
2070
|
/**
|
|
1734
|
-
*
|
|
2071
|
+
* Options for a step-up by passkey assertion.
|
|
1735
2072
|
*
|
|
1736
|
-
*
|
|
1737
|
-
*
|
|
2073
|
+
* `allowCredentials` is empty, as it is for a sign-in and for the same reason
|
|
2074
|
+
* (D3): the discoverable credential on the device names itself, and the owner
|
|
2075
|
+
* and the second-factor mark are checked when the assertion comes back. The
|
|
2076
|
+
* challenge is minted with kind `'mfa'` and this account's id, so it cannot be
|
|
2077
|
+
* presented to `passkeys/login/verify` and a sign-in challenge cannot be
|
|
2078
|
+
* presented here.
|
|
1738
2079
|
*/
|
|
1739
|
-
declare
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2080
|
+
declare function startStepUpService(userId: number): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
2081
|
+
interface StepUpParams {
|
|
2082
|
+
userId: number;
|
|
2083
|
+
/** The device the verification is recorded against. */
|
|
2084
|
+
keyId: string;
|
|
2085
|
+
code?: string;
|
|
2086
|
+
recoveryCode?: string;
|
|
2087
|
+
response?: AuthenticationResponseJSON;
|
|
2088
|
+
}
|
|
1743
2089
|
/**
|
|
1744
|
-
*
|
|
2090
|
+
* Re-prove the second factor on this device, refreshing its window.
|
|
1745
2091
|
*
|
|
1746
|
-
*
|
|
1747
|
-
* -
|
|
2092
|
+
* The escape hatch the exempt registration channels need: a device-code
|
|
2093
|
+
* approval and a passkey sign-in register a key with no verification against
|
|
2094
|
+
* it, by design, so the session they create would otherwise fail every
|
|
2095
|
+
* sensitive change with no way forward.
|
|
1748
2096
|
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
2097
|
+
* @throws ValidationError when the body names none or more than one input
|
|
2098
|
+
* @throws MfaNotEnrolledError | MfaVerificationFailedError
|
|
1751
2099
|
*/
|
|
1752
|
-
declare
|
|
1753
|
-
userPublicId: string;
|
|
1754
|
-
purgeStrategy: "anonymize" | "hard-delete";
|
|
1755
|
-
}>;
|
|
2100
|
+
declare function stepUpService(params: StepUpParams): Promise<void>;
|
|
1756
2101
|
/**
|
|
1757
|
-
*
|
|
2102
|
+
* Check exactly one of the three proofs, and say which one it was — or null.
|
|
1758
2103
|
*
|
|
1759
|
-
*
|
|
1760
|
-
*
|
|
1761
|
-
*
|
|
2104
|
+
* Exactly one: two inputs in a body is a caller trying combinations, and none
|
|
2105
|
+
* is a malformed request. Neither is a failed verification, so both are a 400
|
|
2106
|
+
* rather than the uniform 401 a wrong proof gets.
|
|
1762
2107
|
*
|
|
1763
|
-
*
|
|
1764
|
-
*
|
|
2108
|
+
* Null rather than a throw for the failure itself, because one caller has
|
|
2109
|
+
* bookkeeping to do before it refuses: the new-device challenge counts the
|
|
2110
|
+
* attempt, and counting it inside a `catch` around the thing that threw would be
|
|
2111
|
+
* two control flows for one answer.
|
|
1765
2112
|
*
|
|
1766
|
-
* @
|
|
1767
|
-
* ```typescript
|
|
1768
|
-
* oauthUnlinkedEvent.subscribe(async (payload) => {
|
|
1769
|
-
* await requestAccountDeletionService({ userId: payload.userId, requestedBy: 'self' });
|
|
1770
|
-
* });
|
|
1771
|
-
* ```
|
|
2113
|
+
* @throws ValidationError when the body names none or more than one input
|
|
1772
2114
|
*/
|
|
1773
|
-
declare
|
|
1774
|
-
reason?: string | undefined;
|
|
1775
|
-
userId: string;
|
|
1776
|
-
provider: "email" | "phone" | "google" | "apple" | "github" | "kakao" | "naver" | "superself";
|
|
1777
|
-
providerUserId: string;
|
|
1778
|
-
}>;
|
|
2115
|
+
declare function attemptSecondFactor(params: StepUpParams): Promise<MfaVerificationMethod | null>;
|
|
1779
2116
|
/**
|
|
1780
|
-
*
|
|
2117
|
+
* `attemptSecondFactor`, for the callers whose only answer to a wrong proof is
|
|
2118
|
+
* the uniform 401.
|
|
1781
2119
|
*
|
|
1782
|
-
*
|
|
1783
|
-
|
|
2120
|
+
* @throws ValidationError | MfaVerificationFailedError
|
|
2121
|
+
*/
|
|
2122
|
+
declare function verifySecondFactor(params: StepUpParams): Promise<MfaVerificationMethod>;
|
|
2123
|
+
/**
|
|
2124
|
+
* Drop enrolments nobody ever confirmed.
|
|
1784
2125
|
*
|
|
1785
|
-
*
|
|
1786
|
-
*
|
|
1787
|
-
* is the event an app hangs a "your password was reset" notice on — and the
|
|
1788
|
-
* signal to look at, if the owner says they did not ask for it.
|
|
2126
|
+
* A secret handed out and abandoned is a credential sitting in a table doing
|
|
2127
|
+
* nothing; a day is long enough for anyone who meant to finish.
|
|
1789
2128
|
*
|
|
1790
|
-
* @
|
|
1791
|
-
* ```typescript
|
|
1792
|
-
* authPasswordResetEvent.subscribe(async (payload) => {
|
|
1793
|
-
* await notifyOwner(payload.userId, 'Your password was reset');
|
|
1794
|
-
* });
|
|
1795
|
-
* ```
|
|
2129
|
+
* @returns number of rows deleted
|
|
1796
2130
|
*/
|
|
1797
|
-
declare
|
|
1798
|
-
|
|
1799
|
-
userId: string;
|
|
2131
|
+
declare function sweepUnconfirmedMfaService(): Promise<{
|
|
2132
|
+
deleted: number;
|
|
1800
2133
|
}>;
|
|
1801
|
-
/**
|
|
1802
|
-
* Auth event payload types
|
|
1803
|
-
*/
|
|
1804
|
-
type AuthLoginPayload = typeof authLoginEvent._payload;
|
|
1805
|
-
type AuthRegisterPayload = typeof authRegisterEvent._payload;
|
|
1806
|
-
type AuthPasswordResetPayload = typeof authPasswordResetEvent._payload;
|
|
1807
|
-
type AuthDeviceRegisteredPayload = typeof authDeviceRegisteredEvent._payload;
|
|
1808
|
-
type InvitationCreatedPayload = typeof invitationCreatedEvent._payload;
|
|
1809
|
-
type InvitationAcceptedPayload = typeof invitationAcceptedEvent._payload;
|
|
1810
|
-
type AuthDeletionRequestedPayload = typeof authDeletionRequestedEvent._payload;
|
|
1811
|
-
type AuthDeletionCancelledPayload = typeof authDeletionCancelledEvent._payload;
|
|
1812
|
-
type AuthDeletionCompletedPayload = typeof authDeletionCompletedEvent._payload;
|
|
1813
|
-
type OAuthUnlinkedPayload = typeof oauthUnlinkedEvent._payload;
|
|
1814
2134
|
|
|
1815
2135
|
/**
|
|
1816
2136
|
* @spfn/auth - Key Service
|
|
@@ -1859,7 +2179,40 @@ interface RegisterPublicKeyParams {
|
|
|
1859
2179
|
* register a device with the owner's notice switched off.
|
|
1860
2180
|
*/
|
|
1861
2181
|
replacesKeyId?: string;
|
|
2182
|
+
/**
|
|
2183
|
+
* What to announce if this registration is stopped and later verified (#95).
|
|
2184
|
+
*
|
|
2185
|
+
* Read only on a channel that can step up, and only when it does. The login
|
|
2186
|
+
* event has to fire once and with the original channel's payload, and the
|
|
2187
|
+
* app's own `metadata` and the social provider that carried it are gone by
|
|
2188
|
+
* the time `POST /_auth/mfa/verify` runs — so the announcement rides the
|
|
2189
|
+
* challenge row rather than being guessed at from the user row.
|
|
2190
|
+
*/
|
|
2191
|
+
loginEvent?: DeferredLoginEvent;
|
|
1862
2192
|
}
|
|
2193
|
+
/**
|
|
2194
|
+
* What a registration settled on: a key, or a second factor still to prove.
|
|
2195
|
+
*
|
|
2196
|
+
* The `pending` branch is the 202 (#95). Its callers answer it to the client and
|
|
2197
|
+
* stop — no session, no event, no `lastLoginAt` — and everything they would have
|
|
2198
|
+
* done happens at `verify` instead.
|
|
2199
|
+
*/
|
|
2200
|
+
type RegisterPublicKeyResult = ({
|
|
2201
|
+
pending: false;
|
|
2202
|
+
} & RegisteredKeyBinding) | {
|
|
2203
|
+
pending: true;
|
|
2204
|
+
challenge: MfaChallengeHandle;
|
|
2205
|
+
};
|
|
2206
|
+
/**
|
|
2207
|
+
* The binding half of a registration that could not have been stepped up.
|
|
2208
|
+
*
|
|
2209
|
+
* Only four channels can answer `pending`, so a caller on any other one is
|
|
2210
|
+
* reading a case its channel cannot reach. Throwing rather than defaulting is
|
|
2211
|
+
* the honest answer if that ever stops being true: a silent `{}` would seal an
|
|
2212
|
+
* unbound session for a key nobody had proved, which is the one outcome this
|
|
2213
|
+
* whole feature exists to prevent.
|
|
2214
|
+
*/
|
|
2215
|
+
declare function registeredBinding(result: RegisterPublicKeyResult): RegisteredKeyBinding;
|
|
1863
2216
|
interface RotateKeyParams {
|
|
1864
2217
|
userId: number;
|
|
1865
2218
|
oldKeyId: string;
|
|
@@ -1988,11 +2341,16 @@ interface RegisteredKeyBinding {
|
|
|
1988
2341
|
* index, rolling the whole login transaction back into a 500. Reuse is refused
|
|
1989
2342
|
* with a domain error instead, telling the client to generate a fresh keyId.
|
|
1990
2343
|
*
|
|
2344
|
+
* An enrolled account arriving on a new device gets the key written **inactive**
|
|
2345
|
+
* and a challenge back (#95). Nothing else happens: no device event, no login
|
|
2346
|
+
* event, no `lastLoginAt` — those are what the challenge is holding, and they
|
|
2347
|
+
* fire at `POST /_auth/mfa/verify` or not at all.
|
|
2348
|
+
*
|
|
1991
2349
|
* @throws KeyIdAlreadyRegisteredError keyId가 이미 쓰인 값일 때 (자기 폐기 키 재사용 · 남의 키)
|
|
1992
2350
|
* @throws InvalidKeyFingerprintError fingerprint가 publicKey와 맞지 않을 때
|
|
1993
2351
|
* @throws KeyAlgorithmMismatchError 키의 SPKI 타입이 선언된 algorithm과 다를 때
|
|
1994
2352
|
*/
|
|
1995
|
-
declare function registerPublicKeyService(params: RegisterPublicKeyParams): Promise<
|
|
2353
|
+
declare function registerPublicKeyService(params: RegisterPublicKeyParams): Promise<RegisterPublicKeyResult>;
|
|
1996
2354
|
/**
|
|
1997
2355
|
* Rotate user's public key (revoke old, register new)
|
|
1998
2356
|
*
|
|
@@ -2358,6 +2716,15 @@ interface OAuthCallbackResult {
|
|
|
2358
2716
|
userId: string;
|
|
2359
2717
|
keyId: string;
|
|
2360
2718
|
isNewUser: boolean;
|
|
2719
|
+
/**
|
|
2720
|
+
* The step-up challenge this callback is carrying, when it is carrying one.
|
|
2721
|
+
*
|
|
2722
|
+
* Present exactly when the redirect names `mfaChallenge` instead of
|
|
2723
|
+
* `userId`/`keyId` (#95). Returned beside the URL so a caller that does not
|
|
2724
|
+
* parse the query — a test, or an app mounting its own handler — can tell
|
|
2725
|
+
* the two redirects apart.
|
|
2726
|
+
*/
|
|
2727
|
+
mfaChallenge?: string;
|
|
2361
2728
|
}
|
|
2362
2729
|
/**
|
|
2363
2730
|
* registry에서 provider를 찾아 사용 가능한지 검증 후 반환
|
|
@@ -2466,10 +2833,22 @@ interface OAuthNativeParams {
|
|
|
2466
2833
|
};
|
|
2467
2834
|
metadata?: Record<string, unknown>;
|
|
2468
2835
|
}
|
|
2836
|
+
/**
|
|
2837
|
+
* What a native social sign-in answers with.
|
|
2838
|
+
*
|
|
2839
|
+
* Shaped like `LoginResult` and for the same reason (#95): this channel steps up
|
|
2840
|
+
* too, so the answer is one type carrying a required discriminant and optional
|
|
2841
|
+
* fields rather than a union the typed client and the mobile contract could not
|
|
2842
|
+
* both express. Narrow on `mfaRequired` before reading `userId`.
|
|
2843
|
+
*/
|
|
2469
2844
|
interface OAuthNativeResult {
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2845
|
+
/** true means no key was activated: verify the challenge below first. */
|
|
2846
|
+
mfaRequired: boolean;
|
|
2847
|
+
/** Present exactly when `mfaRequired` is true. */
|
|
2848
|
+
challenge?: MfaChallengeHandle;
|
|
2849
|
+
userId?: string;
|
|
2850
|
+
keyId?: string;
|
|
2851
|
+
isNewUser?: boolean;
|
|
2473
2852
|
}
|
|
2474
2853
|
/**
|
|
2475
2854
|
* native id_token 로그인 처리
|
|
@@ -2715,6 +3094,115 @@ declare function disableSessionBindingService(params: DisableSessionBindingParam
|
|
|
2715
3094
|
/** The challenge the disabling ceremony signs. Same ceremony renewal uses. */
|
|
2716
3095
|
declare function startSessionBindingDisableService(userId: number): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
2717
3096
|
|
|
3097
|
+
/**
|
|
3098
|
+
* @spfn/auth - Session Renewal Service
|
|
3099
|
+
*
|
|
3100
|
+
* What a bound session does when its key runs out: prove, with a fresh WebAuthn
|
|
3101
|
+
* assertion, that the person who enrolled the passkey is still at the machine,
|
|
3102
|
+
* and get a new short-lived key sealed into the cookie.
|
|
3103
|
+
*
|
|
3104
|
+
* Neither step is public. The expiring key is named by `expiredKeyId`, and that
|
|
3105
|
+
* value reaches the service from `authenticateForRenewal` — the `keyId` of a
|
|
3106
|
+
* bearer JWT this very key signed — rather than from the request body, so a
|
|
3107
|
+
* caller who does not hold the private half cannot name a key at all. The
|
|
3108
|
+
* assertion still has to be signed by a passkey that key's owner enrolled: the
|
|
3109
|
+
* signature proves the cookie, and the cookie is the thing that may have been
|
|
3110
|
+
* copied.
|
|
3111
|
+
*
|
|
3112
|
+
* The admission below is run again here all the same. The middleware and the
|
|
3113
|
+
* service ask the same four questions of the row, and a service that trusted its
|
|
3114
|
+
* caller to have asked them would be one refactor away from not being asked at
|
|
3115
|
+
* all.
|
|
3116
|
+
*
|
|
3117
|
+
* Every refusal is the same refusal. A key that never existed, a stranger's key,
|
|
3118
|
+
* an unbound key, a revoked one, one past its grace, an inactive account, a spent
|
|
3119
|
+
* challenge, an assertion that did not verify — all `SessionRenewalRefusedError`,
|
|
3120
|
+
* with the same body, because anything finer would answer "is this key id live"
|
|
3121
|
+
* to whoever asked.
|
|
3122
|
+
*
|
|
3123
|
+
* Renewal announces nothing. No `auth.login`, no `auth.device.registered`, and
|
|
3124
|
+
* `lastLoginAt` does not move: this is the same person on the same device
|
|
3125
|
+
* continuing the session they already had, and a subscriber mailing "new sign-in"
|
|
3126
|
+
* once a day per device would train its reader to ignore the notice that matters.
|
|
3127
|
+
* A `lastLoginAt` that moved every day would make dormant-account detection
|
|
3128
|
+
* meaningless for exactly the accounts that turned this protection on.
|
|
3129
|
+
*/
|
|
3130
|
+
|
|
3131
|
+
interface StartSessionRenewParams {
|
|
3132
|
+
/** The key that ran out, read off the JWT the request was signed with. */
|
|
3133
|
+
expiredKeyId: string;
|
|
3134
|
+
}
|
|
3135
|
+
interface FinishSessionRenewParams extends StartSessionRenewParams {
|
|
3136
|
+
/** The assertion, from `navigator.credentials.get()`. */
|
|
3137
|
+
response: AuthenticationResponseJSON;
|
|
3138
|
+
/**
|
|
3139
|
+
* The new key pair, in the vocabulary the Next.js login interceptor already
|
|
3140
|
+
* writes: `renew/verify` is on that interceptor's path list, so these arrive
|
|
3141
|
+
* exactly as they do on a login.
|
|
3142
|
+
*/
|
|
3143
|
+
keyId: string;
|
|
3144
|
+
publicKey: string;
|
|
3145
|
+
fingerprint: string;
|
|
3146
|
+
algorithm?: KeyAlgorithmType;
|
|
3147
|
+
}
|
|
3148
|
+
/**
|
|
3149
|
+
* What a completed renewal answers: a sign-in result, plus the new key's id.
|
|
3150
|
+
*
|
|
3151
|
+
* The id is the one thing a renewal has that a sign-in does not need to say —
|
|
3152
|
+
* `renewSession()` promises it to the app, which has no other way to learn it
|
|
3153
|
+
* (the key pair is minted in the proxy and the private half never leaves the
|
|
3154
|
+
* cookie). It is not a contract operation, so nothing generated reads it.
|
|
3155
|
+
*/
|
|
3156
|
+
interface SessionRenewResult extends LoginResult {
|
|
3157
|
+
/** The key this renewal registered, the one the session now signs with. */
|
|
3158
|
+
keyId: string;
|
|
3159
|
+
}
|
|
3160
|
+
/**
|
|
3161
|
+
* Step 1 — the challenge the authenticator signs.
|
|
3162
|
+
*
|
|
3163
|
+
* `allowCredentials` is empty and the account lives only on the challenge row.
|
|
3164
|
+
* See `startRenewalCeremonyService`.
|
|
3165
|
+
*
|
|
3166
|
+
* @throws SessionRenewalRefusedError 갱신할 수 없는 키·계정일 때 (모든 사유 동일)
|
|
3167
|
+
*/
|
|
3168
|
+
declare function startSessionRenewService(params: StartSessionRenewParams): Promise<PublicKeyCredentialRequestOptionsJSON>;
|
|
3169
|
+
/**
|
|
3170
|
+
* Step 2 — verify the assertion, put a new bound key in place of the old one.
|
|
3171
|
+
*
|
|
3172
|
+
* The revocation runs first and its answer is the race winner: two verifies that
|
|
3173
|
+
* both got past their own challenges meet at the same conditional UPDATE, and
|
|
3174
|
+
* only the one that actually revoked the key goes on to register a replacement.
|
|
3175
|
+
*
|
|
3176
|
+
* The new key inherits the old row's provenance, so the device list keeps saying
|
|
3177
|
+
* where this device first appeared rather than re-stamping itself every day. Its
|
|
3178
|
+
* expiry is a fresh window from now — renewal is a renewal, not an extension of
|
|
3179
|
+
* what the old key had.
|
|
3180
|
+
*
|
|
3181
|
+
* @throws SessionRenewalRefusedError 갱신할 수 없을 때 (증명 실패 포함, 모든 사유 동일)
|
|
3182
|
+
*/
|
|
3183
|
+
declare function finishSessionRenewService(params: FinishSessionRenewParams): Promise<SessionRenewResult>;
|
|
3184
|
+
|
|
3185
|
+
/**
|
|
3186
|
+
* What `POST /_auth/oauth/finalize` answers with, in both of its branches.
|
|
3187
|
+
*
|
|
3188
|
+
* One type with a required discriminant rather than a union, on the same
|
|
3189
|
+
* reasoning as `LoginResult` (#95): `authApi.oauthFinalize` infers its result
|
|
3190
|
+
* from this, and a union would make every `result.userId` in every callback page
|
|
3191
|
+
* stop compiling. `mfaRequired` false is the 200 and carries `userId`/`keyId`;
|
|
3192
|
+
* true is the 202 and carries `challenge`.
|
|
3193
|
+
*/
|
|
3194
|
+
interface OAuthFinalizeResponse {
|
|
3195
|
+
success: boolean;
|
|
3196
|
+
mfaRequired: boolean;
|
|
3197
|
+
/** The second-factor challenge, echoed back. Present exactly on the 202. */
|
|
3198
|
+
challenge?: string;
|
|
3199
|
+
userId?: string;
|
|
3200
|
+
keyId?: string;
|
|
3201
|
+
returnUrl: string;
|
|
3202
|
+
sessionBinding?: SessionBindingType;
|
|
3203
|
+
keyExpiresAtMillis?: number;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
2718
3206
|
/**
|
|
2719
3207
|
* @spfn/auth - Main Router
|
|
2720
3208
|
*
|
|
@@ -2822,7 +3310,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
2822
3310
|
deviceName: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
2823
3311
|
platform: _sinclair_typebox.TOptional<_sinclair_typebox.TUnion<_sinclair_typebox.TLiteral<"ios" | "android" | "web" | "desktop">[]>>;
|
|
2824
3312
|
}>;
|
|
2825
|
-
},
|
|
3313
|
+
}, LoginResult>;
|
|
2826
3314
|
login: _spfn_core_route.RouteDef<{
|
|
2827
3315
|
body: _sinclair_typebox.TObject<{
|
|
2828
3316
|
email: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
@@ -2863,6 +3351,7 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
2863
3351
|
sessionBinding?: "none" | "passkey" | undefined;
|
|
2864
3352
|
keyExpiresAtMillis?: number | undefined;
|
|
2865
3353
|
status: "approved";
|
|
3354
|
+
mfaRequired: boolean;
|
|
2866
3355
|
userId: string;
|
|
2867
3356
|
publicId: string;
|
|
2868
3357
|
passwordChangeRequired: boolean;
|
|
@@ -2966,6 +3455,19 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
2966
3455
|
mfaStepUpOptions: _spfn_core_route.RouteDef<{
|
|
2967
3456
|
body: _sinclair_typebox.TObject<{}>;
|
|
2968
3457
|
}, {}, _simplewebauthn_server.PublicKeyCredentialRequestOptionsJSON>;
|
|
3458
|
+
mfaVerify: _spfn_core_route.RouteDef<{
|
|
3459
|
+
body: _sinclair_typebox.TObject<{
|
|
3460
|
+
challenge: _sinclair_typebox.TString;
|
|
3461
|
+
code: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
3462
|
+
recoveryCode: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
3463
|
+
response: _sinclair_typebox.TOptional<_sinclair_typebox.TUnknown>;
|
|
3464
|
+
}>;
|
|
3465
|
+
}, {}, VerifyMfaChallengeResult>;
|
|
3466
|
+
mfaVerifyOptions: _spfn_core_route.RouteDef<{
|
|
3467
|
+
body: _sinclair_typebox.TObject<{
|
|
3468
|
+
challenge: _sinclair_typebox.TString;
|
|
3469
|
+
}>;
|
|
3470
|
+
}, {}, _simplewebauthn_server.PublicKeyCredentialRequestOptionsJSON>;
|
|
2969
3471
|
logout: _spfn_core_route.RouteDef<{}, {}, void>;
|
|
2970
3472
|
rotateKey: _spfn_core_route.RouteDef<{}, {
|
|
2971
3473
|
body: _sinclair_typebox.TObject<{
|
|
@@ -3121,18 +3623,12 @@ declare const mainAuthRouter: _spfn_core_route.Router<{
|
|
|
3121
3623
|
}>;
|
|
3122
3624
|
oauthFinalize: _spfn_core_route.RouteDef<{
|
|
3123
3625
|
body: _sinclair_typebox.TObject<{
|
|
3124
|
-
userId: _sinclair_typebox.TString
|
|
3125
|
-
keyId: _sinclair_typebox.TString
|
|
3626
|
+
userId: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
3627
|
+
keyId: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
3628
|
+
mfaChallenge: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
3126
3629
|
returnUrl: _sinclair_typebox.TOptional<_sinclair_typebox.TString>;
|
|
3127
3630
|
}>;
|
|
3128
|
-
}, {},
|
|
3129
|
-
sessionBinding?: SessionBindingType;
|
|
3130
|
-
keyExpiresAtMillis?: number;
|
|
3131
|
-
success: boolean;
|
|
3132
|
-
userId: string;
|
|
3133
|
-
keyId: string;
|
|
3134
|
-
returnUrl: string;
|
|
3135
|
-
}>;
|
|
3631
|
+
}, {}, OAuthFinalizeResponse>;
|
|
3136
3632
|
oauthProviderStart: _spfn_core_route.RouteDef<{
|
|
3137
3633
|
params: _sinclair_typebox.TObject<{
|
|
3138
3634
|
provider: _sinclair_typebox.TUnion<_sinclair_typebox.TLiteral<"google" | "apple" | "github" | "kakao" | "naver" | "superself">[]>;
|
|
@@ -3830,6 +4326,21 @@ declare const userPublicKeys: drizzle_orm_pg_core.PgTableWithColumns<{
|
|
|
3830
4326
|
identity: undefined;
|
|
3831
4327
|
generated: undefined;
|
|
3832
4328
|
}>;
|
|
4329
|
+
pendingMfaChallengeId: drizzle_orm_pg_core.PgBuildColumn<"user_public_keys", drizzle_orm_pg_core.PgBigInt53Builder, {
|
|
4330
|
+
name: string;
|
|
4331
|
+
tableName: "user_public_keys";
|
|
4332
|
+
dataType: "number int53";
|
|
4333
|
+
data: number;
|
|
4334
|
+
driverParam: string | number;
|
|
4335
|
+
notNull: false;
|
|
4336
|
+
hasDefault: false;
|
|
4337
|
+
isPrimaryKey: false;
|
|
4338
|
+
isAutoincrement: false;
|
|
4339
|
+
hasRuntimeDefault: false;
|
|
4340
|
+
enumValues: undefined;
|
|
4341
|
+
identity: undefined;
|
|
4342
|
+
generated: undefined;
|
|
4343
|
+
}>;
|
|
3833
4344
|
createdAt: drizzle_orm_pg_core.PgBuildColumn<"user_public_keys", drizzle_orm_pg_core.SetHasDefault<drizzle_orm_pg_core.SetNotNull<drizzle_orm_pg_core.PgTimestampBuilder>>, {
|
|
3834
4345
|
name: string;
|
|
3835
4346
|
tableName: "user_public_keys";
|
|
@@ -4243,4 +4754,4 @@ declare const machineAuth: _spfn_core_route.NamedMiddleware<"machineAuth">;
|
|
|
4243
4754
|
*/
|
|
4244
4755
|
declare const requireMachineScope: _spfn_core_route.NamedMiddlewareFactory<"machineScope", string[]>;
|
|
4245
4756
|
|
|
4246
|
-
export { type
|
|
4757
|
+
export { type AuthDeletionCompletedPayload as $, type AuthInitOptions as A, type NewPasskey as B, type ConfirmSignupLinkResult as C, type DeviceAuthInfoResult as D, type Passkey as E, type FinishPasskeyEnrollmentResult as F, type MfaVerification as G, type MfaVerificationMethod as H, type IssueOneTimeTokenResult as I, type NewMfaChallenge as J, type KeySummary as K, type LoginResult as L, type MfaStatus as M, type NewUserPublicKey as N, type OAuthStartResult as O, type PermissionConfig as P, type MfaChallenge as Q, type RoleConfig as R, type SendVerificationCodeResult as S, type TotpEnrolmentResult as T, type UserProfile as U, type VerifyMfaChallengeResult as V, type UserPublicKey as W, type AuthContext as X, type ApproveDeviceAuthParams as Y, type AssertStepUpParams as Z, type AuthDeletionCancelledPayload as _, type RegisterResult as a, type PollDeviceAuthParams as a$, type AuthDeletionRequestedPayload as a0, type AuthDeviceRegisteredPayload as a1, type AuthLoginPayload as a2, type AuthPasswordResetPayload as a3, type AuthProfileOutcome as a4, type AuthProfileVerifier as a5, AuthProviderSchema as a6, type AuthRegisterPayload as a7, type BearerOutcome as a8, type BearerRefusal as a9, MFA_CHALLENGE_ATTEMPT_LIMIT as aA, MFA_CHALLENGE_CHANNELS as aB, MFA_VERIFICATION_METHODS as aC, type MachinePrincipal as aD, type MachineVerifierRegistration as aE, type MarkPasskeyParams as aF, type MfaChallengeChannel as aG, type MfaChallengeHandle as aH, type NativeVerifyOptions as aI, type NewMfaVerification as aJ, type NormalizedIdentity as aK, type OAuth2AuthorizeParams as aL, type OAuth2ScopeDescription as aM, type OAuthCallbackParams as aN, type OAuthCallbackResult as aO, type OAuthCodeExchangeOptions as aP, type OAuthNativeParams as aQ, type OAuthStartParams as aR, type OAuthTokens as aS, type OAuthUnlinkedPayload as aT, type OpenStepUpChallengeParams as aU, PASSKEY_DEVICE_TYPES as aV, PASSKEY_LABEL_MAX_LENGTH as aW, type PasskeyDeviceType as aX, PasswordSchema as aY, PhoneSchema as aZ, PlatformSchema as a_, type ChangePasswordParams as aa, type CompletePasswordResetParams as ab, type CompleteSignupParams as ac, type ConfirmPasswordResetParams as ad, type ConfirmSignupLinkParams as ae, type ConfirmTotpParams as af, type DeferredLoginEvent as ag, type DenyDeviceAuthParams as ah, type DeviceAuthApprovedResult as ai, type DeviceAuthInfoParams as aj, type DeviceAuthPendingResult as ak, DeviceAuthPollResponseSchema as al, DeviceNameSchema as am, type DeviceRegistrationChannel as an, type DisableSessionBindingParams as ao, EmailSchema as ap, FingerprintSchema as aq, type FinishPasskeyEnrollmentParams as ar, type FinishPasskeyLoginParams as as, type FinishSessionRenewParams as at, type InvitationAcceptedPayload as au, type InvitationCreatedPayload as av, KEY_FINGERPRINT_PREFIX_LENGTH as aw, KeyIdSchema as ax, type LoginParams as ay, type LogoutParams as az, type RequestSignupLinkResult as b, getEnabledOAuthProviders as b$, type PollDeviceAuthResult as b0, PublicKeySchema as b1, type RecentAuthenticationParams as b2, type RegisterParams as b3, type RegisterPublicKeyParams as b4, type RegisterPublicKeyResult as b5, type RenamePasskeyParams as b6, type RequestPasswordResetParams as b7, type RequestSignupLinkParams as b8, type RevokeAllKeysParams as b9, authDeletionCancelledEvent as bA, authDeletionCompletedEvent as bB, authDeletionRequestedEvent as bC, authDeviceRegisteredEvent as bD, authLoginEvent as bE, authPasswordResetEvent as bF, authRegisterEvent as bG, authenticate as bH, bearerAuthContext as bI, buildOAuthErrorUrl as bJ, carryStepUpVerification as bK, changePasswordService as bL, completePasswordResetService as bM, completeSignupService as bN, confirmPasswordResetService as bO, confirmSignupLinkService as bP, confirmTotpEnrolmentService as bQ, denyDeviceAuthService as bR, denyOAuth2AuthorizeService as bS, describeOAuth2AuthorizeRequestService as bT, disableMfaService as bU, disableSessionBindingService as bV, enableSessionBindingService as bW, finishPasskeyEnrollmentService as bX, finishPasskeyLoginService as bY, finishSessionRenewService as bZ, getDeviceAuthInfoService as b_, type RevokeKeyParams as ba, type RevokePasskeyParams as bb, type RotateKeyParams as bc, type SendVerificationCodeParams as bd, type SessionBindingParams as be, type StartDeviceAuthParams as bf, type StartPasskeyEnrollmentParams as bg, type StartSessionRenewParams as bh, type StepUpParams as bi, TargetTypeSchema as bj, type UnlinkNotification as bk, UnlinkNotifyRejection as bl, type UnlinkNotifyRequest as bm, type UnlinkNotifyResult as bn, UserCodeSchema as bo, VerificationPurposeSchema as bp, type VerifyCodeParams as bq, type VerifyCodeResult as br, type VerifyMfaChallengeParams as bs, admitBearerKey as bt, approveDeviceAuthService as bu, approveOAuth2AuthorizeService as bv, assertNotLastRecoveryCredential as bw, assertRecentAuthentication as bx, assertStepUp as by, attemptSecondFactor as bz, type RequestPasswordResetResult as c, sweepUnconfirmedMfaService as c$, getGoogleAccessToken as c0, getMachinePrincipal as c1, getOAuthProvider as c2, getRegisteredProviders as c3, getSessionBindingService as c4, invitationAcceptedEvent as c5, invitationCreatedEvent as c6, isOAuthProviderEnabled as c7, issueOneTimeTokenService as c8, keySessionBindingService as c9, registeredBinding as cA, renamePasskeyService as cB, requestPasswordResetService as cC, requestSignupLinkService as cD, requireEnabledProvider as cE, requireMachineScope as cF, resolveAuthenticatedUser as cG, resumeStepUpChallengeService as cH, revokeAllKeysService as cI, revokeAllOAuth2GrantsForUser as cJ, revokeKeyService as cK, revokeOAuth2GrantService as cL, revokePasskeyService as cM, rotateKeyService as cN, runAuthProfile as cO, selectAuthProfile as cP, sendVerificationCodeService as cQ, startDeviceAuthService as cR, startMfaChallengeAssertionService as cS, startPasskeyEnrollmentService as cT, startPasskeyLoginService as cU, startSessionBindingDisableService as cV, startSessionRenewService as cW, startStepUpService as cX, startTotpEnrolmentService as cY, stepUpService as cZ, sweepMfaChallengesService as c_, listKeysService as ca, listOAuth2GrantsService as cb, listPasskeysService as cc, loginService as cd, logoutService as ce, machineAuth as cf, markPasskeySecondFactorService as cg, mfaChallenges as ch, mfaEnrolledForUser as ci, mfaStatusService as cj, mfaVerifications as ck, oauthCallbackService as cl, oauthNativeService as cm, oauthStartService as cn, oauthUnlinkNotifyService as co, oauthUnlinkedEvent as cp, openStepUpChallengeService as cq, optionalAuth as cr, passkeys as cs, pollDeviceAuthService as ct, regenerateRecoveryCodesService as cu, registerAuthProfile as cv, registerMachineVerifier as cw, registerOAuthProvider as cx, registerPublicKeyService as cy, registerService as cz, type ConfirmPasswordResetResult as d, userPublicKeys as d0, verifyCodeService as d1, verifyMfaChallengeService as d2, verifyOneTimeTokenService as d3, verifySecondFactor as d4, type StartDeviceAuthResult as e, type PasskeySummary as f, type ConfirmTotpResult as g, type RotateKeyResult as h, type RevokeAllKeysResult as i, type SessionBindingResult as j, type SessionRenewResult as k, type OAuthFinalizeResponse as l, mainAuthRouter as m, type OAuthNativeResult as n, type ProfileInfo as o, type OAuth2ConsentView as p, type OAuth2AuthorizationCodeIssued as q, type OAuth2GrantSummary as r, type AuthSession as s, PERMISSION_CATEGORIES as t, type PermissionCategory as u, VERIFICATION_PURPOSES as v, VERIFICATION_TARGET_TYPES as w, type VerificationPurpose as x, type VerificationTargetType as y, type OAuthProvider as z };
|