@ultimat3/auth 23.0.0 → 25.0.0
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/CLAUDE.md +95 -46
- package/README.md +245 -93
- package/package.json +5 -5
- package/src/adapter.ts +49 -18
- package/src/api-key-resolver.ts +14 -9
- package/src/api-keys.ts +61 -14
- package/src/auth.ts +56 -53
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +77 -9
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/email.ts +1 -1
- package/src/errors.ts +80 -48
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +57 -30
- package/src/jwks.ts +18 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +73 -7
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +14 -28
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +17 -7
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +78 -23
- package/src/oauth-profile.ts +48 -11
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +46 -18
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +50 -16
- package/src/revocation.ts +25 -25
- package/src/session.ts +69 -37
- package/src/tables.ts +8 -10
- package/src/tokens.ts +2 -5
- package/src/verify.ts +4 -3
package/src/revocation.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { logger } from '@ultimat3/core';
|
|
8
8
|
import type { AuthUser } from './adapter';
|
|
9
9
|
import type { Auth } from './auth';
|
|
10
|
-
import {
|
|
10
|
+
import { authWriteFailed } from './errors';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Every revocation is logged before it runs, with the reason the caller gave. An incident review
|
|
@@ -18,22 +18,14 @@ const record = (operation: string, scope: string, reason: string): void => {
|
|
|
18
18
|
logger.warn('auth.revocation', { operation, scope, reason });
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
-
const unsupported = (method: string, adapterName: string) =>
|
|
22
|
-
authNotImplemented(
|
|
23
|
-
`${adapterName}.${method}()`,
|
|
24
|
-
`implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
|
|
25
|
-
);
|
|
26
|
-
|
|
27
21
|
/** Every session this user holds, their current one included. Returns how many died. */
|
|
28
22
|
export async function revokeUserSessions(
|
|
29
23
|
auth: Auth,
|
|
30
24
|
userId: string,
|
|
31
25
|
reason: string,
|
|
32
26
|
): Promise<number> {
|
|
33
|
-
const remove = auth.adapter.deleteSessionsForUser?.bind(auth.adapter);
|
|
34
|
-
if (remove === undefined) throw unsupported('deleteSessionsForUser', auth.adapter.name);
|
|
35
27
|
record('revokeUserSessions', userId, reason);
|
|
36
|
-
return await
|
|
28
|
+
return await auth.adapter.deleteSessionsForUser(userId);
|
|
37
29
|
}
|
|
38
30
|
|
|
39
31
|
/**
|
|
@@ -45,10 +37,8 @@ export async function revokeOrgSessions(
|
|
|
45
37
|
orgId: string,
|
|
46
38
|
reason: string,
|
|
47
39
|
): Promise<number> {
|
|
48
|
-
const remove = auth.adapter.deleteSessionsForOrg?.bind(auth.adapter);
|
|
49
|
-
if (remove === undefined) throw unsupported('deleteSessionsForOrg', auth.adapter.name);
|
|
50
40
|
record('revokeOrgSessions', orgId, reason);
|
|
51
|
-
return await
|
|
41
|
+
return await auth.adapter.deleteSessionsForOrg(orgId);
|
|
52
42
|
}
|
|
53
43
|
|
|
54
44
|
/**
|
|
@@ -61,37 +51,47 @@ export async function revokeSessionsCreatedBefore(
|
|
|
61
51
|
before: Date,
|
|
62
52
|
reason: string,
|
|
63
53
|
): Promise<number> {
|
|
64
|
-
const remove = auth.adapter.deleteSessionsCreatedBefore?.bind(auth.adapter);
|
|
65
|
-
if (remove === undefined) throw unsupported('deleteSessionsCreatedBefore', auth.adapter.name);
|
|
66
54
|
record('revokeSessionsCreatedBefore', before.toISOString(), reason);
|
|
67
|
-
return await
|
|
55
|
+
return await auth.adapter.deleteSessionsCreatedBefore(before);
|
|
68
56
|
}
|
|
69
57
|
|
|
70
58
|
export interface DisabledUser {
|
|
71
59
|
readonly user: AuthUser;
|
|
72
60
|
readonly sessionsRevoked: number;
|
|
61
|
+
/** Live api keys this user owned, now revoked. A key is the owner's credential too. */
|
|
62
|
+
readonly apiKeysRevoked: number;
|
|
73
63
|
}
|
|
74
64
|
|
|
75
65
|
/**
|
|
76
|
-
* `disabledAt` is read by `login`, by `authenticate` and by the OAuth path
|
|
77
|
-
*
|
|
66
|
+
* `disabledAt` is read by `login`, by `authenticate`, by `verifyApiKey` and by the OAuth path.
|
|
67
|
+
*
|
|
68
|
+
* Stamping the column is not enough on its own: every one of those readers re-reads the user row,
|
|
69
|
+
* so a disabled account stops working on its next request, but the live session rows are still
|
|
70
|
+
* there and still slide, and the api keys still read as live in every listing. Both go in the
|
|
71
|
+
* same call, in that order — stamp first, so a request racing the revocation finds the row
|
|
72
|
+
* already disabled. Re-enabling restores neither.
|
|
78
73
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* still there and still slides. The sessions go in the same call, in that order — stamp first, so
|
|
82
|
-
* a request racing the revocation finds the row already disabled.
|
|
74
|
+
* The log line is written before the first write, as every revocation's is: an incident review
|
|
75
|
+
* must find the reason even when the write that followed it failed.
|
|
83
76
|
*/
|
|
84
77
|
export async function disableUser(
|
|
85
78
|
auth: Auth,
|
|
86
79
|
userId: string,
|
|
87
80
|
reason: string,
|
|
88
81
|
): Promise<DisabledUser> {
|
|
89
|
-
|
|
82
|
+
record('disableUser', userId, reason);
|
|
83
|
+
const now = auth.clock.now();
|
|
84
|
+
const user = await auth.adapter.updateUser(userId, { disabledAt: now });
|
|
90
85
|
// No row came back: either there is no such user, or the adapter did not return the update.
|
|
91
86
|
// Both mean the account is not known to be disabled, and reporting success would be a lie.
|
|
92
87
|
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
const sessionsRevoked = await auth.adapter.deleteSessionsForUser(userId);
|
|
89
|
+
let apiKeysRevoked = 0;
|
|
90
|
+
for (const key of await auth.adapter.listApiKeys(userId)) {
|
|
91
|
+
if (key.revokedAt !== null) continue;
|
|
92
|
+
if (await auth.adapter.revokeApiKey(key.id, now)) apiKeysRevoked += 1;
|
|
93
|
+
}
|
|
94
|
+
return { user, sessionsRevoked, apiKeysRevoked };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** The inverse. No sessions are restored — a re-enabled account signs in again, deliberately. */
|
package/src/session.ts
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
// to them without this package importing it (same-tier packages must not depend on each other).
|
|
6
6
|
|
|
7
7
|
import type { Clock } from '@ultimat3/core';
|
|
8
|
+
import { readCookie, serializeSetCookie, timingSafeEqual } from '@ultimat3/core';
|
|
8
9
|
import type { AuthSession, SessionStore } from './adapter';
|
|
9
|
-
import { sessionExpired, sessionUnknown } from './errors';
|
|
10
|
+
import { AuthError, sessionExpired, sessionUnknown } from './errors';
|
|
10
11
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
11
|
-
import { randomToken, sha256Hex
|
|
12
|
+
import { randomToken, sha256Hex } from './tokens';
|
|
12
13
|
|
|
13
14
|
export interface SessionPolicy {
|
|
14
15
|
/** Hard ceiling from creation. Never extended. */
|
|
@@ -91,8 +92,38 @@ export interface CreateSessionInput {
|
|
|
91
92
|
readonly ip?: string | null | undefined;
|
|
92
93
|
readonly userAgent?: string | null | undefined;
|
|
93
94
|
readonly mfaSatisfied?: boolean | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* The lifetime a rotation inherits. Absent means a new login: born now, ceiling one
|
|
97
|
+
* `absoluteTtlMs` away. Either way the ceiling is clamped to `createdAt + absoluteTtlMs` and
|
|
98
|
+
* `createdAt` to now, so an inherited value can only ever shorten a session — never buy it time.
|
|
99
|
+
*/
|
|
100
|
+
readonly createdAt?: Date | undefined;
|
|
101
|
+
readonly absoluteExpiresAt?: Date | undefined;
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/**
|
|
105
|
+
* An inherited instant that is not one. `X_CONFIG_INVALID`, core's code borrowed as
|
|
106
|
+
* `authPolicyNumberInvalid` borrows it: a caller handed this package a value it cannot honour. An
|
|
107
|
+
* Invalid Date ceiling is not a short session but one that never ends — `now >= NaN` is false.
|
|
108
|
+
*/
|
|
109
|
+
const inheritedInstantInvalid = (field: 'createdAt' | 'absoluteExpiresAt'): AuthError =>
|
|
110
|
+
new AuthError({
|
|
111
|
+
code: 'X_CONFIG_INVALID',
|
|
112
|
+
cause: `createSession({ ${field} }) is an Invalid Date, and stored as a ceiling it would never expire, because every comparison against NaN is false`,
|
|
113
|
+
fix: `createSession(runtime, { userId, createdAt: session.createdAt, absoluteExpiresAt: session.absoluteExpiresAt }) with the Dates read off a live session, or omit both for a new login`,
|
|
114
|
+
meta: { field },
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const inheritedMs = (
|
|
118
|
+
field: 'createdAt' | 'absoluteExpiresAt',
|
|
119
|
+
value: Date | undefined,
|
|
120
|
+
): number | undefined => {
|
|
121
|
+
if (value === undefined) return undefined;
|
|
122
|
+
const ms = value.getTime();
|
|
123
|
+
if (!Number.isFinite(ms)) throw inheritedInstantInvalid(field);
|
|
124
|
+
return ms;
|
|
125
|
+
};
|
|
126
|
+
|
|
96
127
|
/** `<id>.<secret>`: the id is the row key, the secret is the half that is hashed. */
|
|
97
128
|
export function parseSessionToken(token: string): { id: string; secret: string } | null {
|
|
98
129
|
const dot = token.indexOf('.');
|
|
@@ -105,14 +136,20 @@ export async function createSession(
|
|
|
105
136
|
input: CreateSessionInput,
|
|
106
137
|
): Promise<IssuedSession> {
|
|
107
138
|
const now = runtime.clock.now();
|
|
139
|
+
// Screened ABOVE the write: `Math.min(NaN, …)` is NaN, so an Invalid Date stored a ceiling
|
|
140
|
+
// nothing ever reaches.
|
|
141
|
+
const inheritedBorn = inheritedMs('createdAt', input.createdAt);
|
|
142
|
+
const inheritedCeiling = inheritedMs('absoluteExpiresAt', input.absoluteExpiresAt);
|
|
108
143
|
const id = randomToken(12);
|
|
109
144
|
const secret = randomToken(32);
|
|
145
|
+
const born = Math.min(inheritedBorn ?? now.getTime(), now.getTime());
|
|
146
|
+
const ceiling = born + runtime.policy.absoluteTtlMs;
|
|
110
147
|
const session = await runtime.store.createSession({
|
|
111
148
|
id,
|
|
112
149
|
userId: input.userId,
|
|
113
150
|
tokenHash: sha256Hex(secret),
|
|
114
|
-
createdAt:
|
|
115
|
-
absoluteExpiresAt: new Date(
|
|
151
|
+
createdAt: new Date(born),
|
|
152
|
+
absoluteExpiresAt: new Date(Math.min(inheritedCeiling ?? ceiling, ceiling)),
|
|
116
153
|
lastSeenAt: now,
|
|
117
154
|
ip: input.ip ?? null,
|
|
118
155
|
userAgent: input.userAgent ?? null,
|
|
@@ -183,21 +220,39 @@ export async function verifySession(
|
|
|
183
220
|
/**
|
|
184
221
|
* Privilege change (role grant, MFA satisfied, password change) must not reuse the old id:
|
|
185
222
|
* whoever already holds the old cookie would inherit the new privileges.
|
|
223
|
+
*
|
|
224
|
+
* A new id, never a new lifetime: `createdAt` and the absolute ceiling carry over. Minting them
|
|
225
|
+
* fresh let a session rotated often enough (a weekly role toggle) live forever, and hid it from
|
|
226
|
+
* `revokeSessionsCreatedBefore` — the sweep run after a leak, keyed on exactly that column.
|
|
186
227
|
*/
|
|
187
228
|
export async function rotateSession(
|
|
188
229
|
runtime: SessionRuntime,
|
|
189
230
|
session: AuthSession,
|
|
190
231
|
patch?: { readonly mfaSatisfied?: boolean | undefined },
|
|
191
232
|
): Promise<IssuedSession> {
|
|
192
|
-
|
|
233
|
+
// The delete IS the claim, in one atomic step — the `consumeRecoveryCode` contract. Ignoring its
|
|
234
|
+
// answer resurrected a session revoked between verify and rotate, and let two concurrent
|
|
235
|
+
// rotations fork one session into two live ones. Only the caller whose delete landed mints S'.
|
|
236
|
+
if (!(await runtime.store.deleteSession(session.id))) throw sessionUnknown();
|
|
193
237
|
return await createSession(runtime, {
|
|
194
238
|
userId: session.userId,
|
|
195
239
|
ip: session.ip,
|
|
196
240
|
userAgent: session.userAgent,
|
|
197
241
|
mfaSatisfied: patch?.mfaSatisfied ?? session.mfaSatisfied,
|
|
242
|
+
createdAt: session.createdAt,
|
|
243
|
+
absoluteExpiresAt: session.absoluteExpiresAt,
|
|
198
244
|
});
|
|
199
245
|
}
|
|
200
246
|
|
|
247
|
+
/**
|
|
248
|
+
* `Max-Age` for a cookie carrying `session`: the seconds left until its absolute ceiling, never
|
|
249
|
+
* below zero. A rotated session's cookie needs this rather than the policy's full TTL, or the
|
|
250
|
+
* client keeps the cookie for weeks after the server stopped honouring it.
|
|
251
|
+
*/
|
|
252
|
+
export function remainingMaxAgeSeconds(session: AuthSession, now: Date): number {
|
|
253
|
+
return Math.max(0, Math.floor((session.absoluteExpiresAt.getTime() - now.getTime()) / 1000));
|
|
254
|
+
}
|
|
255
|
+
|
|
201
256
|
export async function revokeSession(runtime: SessionRuntime, sessionId: string): Promise<boolean> {
|
|
202
257
|
return await runtime.store.deleteSession(sessionId);
|
|
203
258
|
}
|
|
@@ -269,45 +324,22 @@ export function sessionCookie(
|
|
|
269
324
|
'the cookie carries an attribute that is not delta-seconds, so the browser drops it and keeps the session cookie for the whole browsing session',
|
|
270
325
|
0,
|
|
271
326
|
);
|
|
272
|
-
return
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/** Same attributes, empty value, `Max-Age=0` — a mismatched attribute set leaves a live twin. */
|
|
276
|
-
export function clearSessionCookie(policy: SessionPolicy, name?: string): string {
|
|
277
|
-
return `${name ?? policy.cookieName}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax`;
|
|
327
|
+
return serializeSetCookie(name, token, { maxAge });
|
|
278
328
|
}
|
|
279
329
|
|
|
280
330
|
/**
|
|
281
|
-
*
|
|
282
|
-
*
|
|
331
|
+
* Same attributes, empty value, `Max-Age=0` — a mismatched attribute set leaves a live twin. Both
|
|
332
|
+
* go through core's `serializeSetCookie`, whose defaults ARE `Path=/; HttpOnly; Secure;
|
|
333
|
+
* SameSite=Lax`, so the two lines cannot drift apart by one hand-edited attribute.
|
|
283
334
|
*/
|
|
284
|
-
export function
|
|
285
|
-
|
|
286
|
-
if (header === null) return null;
|
|
287
|
-
for (const part of header.split(';')) {
|
|
288
|
-
const equals = part.indexOf('=');
|
|
289
|
-
if (equals < 0) continue;
|
|
290
|
-
if (part.slice(0, equals).trim() !== name) continue;
|
|
291
|
-
return decodeCookieValue(part.slice(equals + 1).trim());
|
|
292
|
-
}
|
|
293
|
-
return null;
|
|
335
|
+
export function clearSessionCookie(policy: SessionPolicy, name?: string): string {
|
|
336
|
+
return serializeSetCookie(name ?? policy.cookieName, '', { maxAge: 0 });
|
|
294
337
|
}
|
|
295
338
|
|
|
296
339
|
/**
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
* answer 500 instead of `X_OAUTH_STATE_INVALID`. The raw value is returned instead, so the
|
|
300
|
-
* caller's own rejection stays the readable failure. Nothing is loosened by it: a raw value is
|
|
301
|
-
* still checked against a signature or a stored hash, and neither matches a mangled one.
|
|
340
|
+
* The session token off the request, through core's `readCookie` — the oauth handshake reads
|
|
341
|
+
* through the same one, so neither throws: a missing or unreadable cookie is `null` or the raw value.
|
|
302
342
|
*/
|
|
303
|
-
function decodeCookieValue(raw: string): string {
|
|
304
|
-
try {
|
|
305
|
-
return decodeURIComponent(raw);
|
|
306
|
-
} catch {
|
|
307
|
-
return raw;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
343
|
export function readSessionCookie(request: RequestLike, policy: SessionPolicy): string | null {
|
|
312
|
-
return readCookie(request, policy.cookieName);
|
|
344
|
+
return readCookie(request.headers.get('cookie'), policy.cookieName);
|
|
313
345
|
}
|
package/src/tables.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Single responsibility: the DDL `
|
|
1
|
+
// Single responsibility: the DDL `PostgresAuthAdapter` expects, as `AUTH_TABLES` — applied at every boot
|
|
2
2
|
// by `@ultimat3/cli`'s `FRAMEWORK_SCHEMA`, upgrades of an older table included. Plain strings, so
|
|
3
3
|
// what auth stores is verifiable by reading, never by trusting. There is nothing to paste into a
|
|
4
4
|
// migration any more: the per-table exports and `X_USERS_MIGRATION_1_3` are gone (22.0.0).
|
|
@@ -9,16 +9,15 @@
|
|
|
9
9
|
// |---|---|
|
|
10
10
|
// | `x_users.password_hash` | an argon2id digest, never the password |
|
|
11
11
|
// | `x_users.recovery_code_hashes` | SHA-256 digests, never the codes (`mfa.ts`) |
|
|
12
|
-
// | `x_users.mfa_secret` |
|
|
12
|
+
// | `x_users.mfa_secret` | the TOTP seed SEALED under the app's master key (`mfa-secret.ts`) |
|
|
13
13
|
// | `x_accounts.access_token` / `refresh_token` | **nothing.** The framework writes `null` |
|
|
14
14
|
// | `x_sessions.*`, `x_verifications.token_hash` | digests and metadata, never a token |
|
|
15
15
|
//
|
|
16
|
-
// `mfa_secret`
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// bypass" is true of the recovery CODES and not of the seed.
|
|
16
|
+
// `mfa_secret` cannot be a digest: a TOTP seed is symmetric, so verifying a code needs the seed
|
|
17
|
+
// itself. It is sealed with `purpose: 'auth:x_users.mfa_secret'`, written by `saveTotpSecret` and
|
|
18
|
+
// opened by `openTotpSecret`, which REFUSES a value that is not sealed (`X_MFA_SECRET_UNSEALED`).
|
|
19
|
+
// The column type is unchanged, so there is no DDL to upgrade — a row written before sealing is
|
|
20
|
+
// sealed in place by `x auth seal-mfa`, once.
|
|
22
21
|
//
|
|
23
22
|
// The two `x_accounts` token columns are kept in the DDL and written `null`: they held live
|
|
24
23
|
// provider credentials in the clear and nothing in this package ever read them back
|
|
@@ -36,8 +35,7 @@ export const X_USERS_TABLE = `create table if not exists x_users (
|
|
|
36
35
|
permissions text[] not null default '{}',
|
|
37
36
|
scopes text[] not null default '{}',
|
|
38
37
|
external_id text unique,
|
|
39
|
-
-- The
|
|
40
|
-
-- Encryption at rest is deferred and needs a key-management seam - see the header.
|
|
38
|
+
-- The TOTP seed, sealed (x1.<keyId>.<iv>.<ciphertext>). Never base32 in the clear.
|
|
41
39
|
mfa_secret text,
|
|
42
40
|
recovery_code_hashes text[] not null default '{}',
|
|
43
41
|
disabled_at timestamptz,
|
package/src/tokens.ts
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// Single responsibility: the secret primitives every other file in this package shares —
|
|
2
2
|
// CSPRNG tokens and SHA-256 hashing. Centralised so no call site can quietly reach for `===` on
|
|
3
3
|
// a secret, which leaks the shared prefix length one request at a time. The constant-time
|
|
4
|
-
// comparison itself
|
|
5
|
-
//
|
|
6
|
-
// working.
|
|
4
|
+
// comparison itself is `@ultimat3/core`'s `timingSafeEqual` — `@ultimat3/storage` needs the exact
|
|
5
|
+
// same one — imported from core by every caller, never re-exported here (`X_HELPER_COPY`).
|
|
7
6
|
|
|
8
7
|
import { finiteCount, timingSafeEqual } from '@ultimat3/core';
|
|
9
8
|
|
|
10
|
-
export { timingSafeEqual };
|
|
11
|
-
|
|
12
9
|
const BASE64URL_UNSAFE = /[+/=]/g;
|
|
13
10
|
const BASE64URL_REPLACEMENTS: Readonly<Record<string, string>> = { '+': '-', '/': '_', '=': '' };
|
|
14
11
|
|
package/src/verify.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// Single responsibility: email verification and password reset — the two flows where a link in
|
|
2
2
|
// an inbox is a credential. Tokens are single-use, expiring, stored hashed and compared in
|
|
3
|
-
// constant time. Mail leaves through an injected `MailSender` port:
|
|
4
|
-
//
|
|
3
|
+
// constant time. Mail leaves through an injected structural `MailSender` port: `@ultimat3/mail` is
|
|
4
|
+
// tier 4 and auth is tier 2, so the app wires mail's `send` in and auth imports nothing upward.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
+
import { timingSafeEqual } from '@ultimat3/core';
|
|
7
8
|
import type { AuthVerification, VerificationStore } from './adapter';
|
|
8
9
|
import { normaliseEmail } from './email';
|
|
9
10
|
import { AuthError } from './errors';
|
|
10
11
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
11
|
-
import { randomToken, sha256Hex
|
|
12
|
+
import { randomToken, sha256Hex } from './tokens';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* `locale` is required, not optional: an inferred locale is how a user gets a security email
|