@ultimat3/auth 22.15.0 → 24.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 +79 -36
- package/README.md +178 -52
- package/package.json +5 -4
- package/src/adapter.ts +43 -16
- package/src/api-key-resolver.ts +55 -0
- package/src/api-keys.ts +59 -12
- package/src/auth.ts +48 -45
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +61 -6
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/errors.ts +77 -45
- package/src/index.ts +48 -20
- package/src/jwks.ts +11 -0
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +41 -0
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +9 -23
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +8 -4
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-login.ts +50 -9
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +28 -16
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +47 -13
- package/src/revocation.ts +25 -25
- package/src/session.ts +4 -30
- package/src/tables.ts +7 -9
- package/src/verify.ts +2 -2
- package/src/auth-fixture.ts +0 -26
- package/src/id-token-fixture.ts +0 -20
- package/src/oauth-login-fixture.ts +0 -53
package/src/rate-limit.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
accountLocked,
|
|
11
11
|
authLimiterNotShared,
|
|
12
12
|
authLimiterPolicyMismatch,
|
|
13
|
+
ORG_KEY_PREFIX,
|
|
13
14
|
} from './errors';
|
|
14
15
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
15
16
|
|
|
@@ -65,11 +66,22 @@ export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
|
|
|
65
66
|
scope: 'process',
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
/** One counted attempt, as `reserve` took it — what `refund` gives back, and nothing else. */
|
|
70
|
+
export interface AuthReservation {
|
|
71
|
+
readonly key: string;
|
|
72
|
+
/** The limiter's own clock at the take. It identifies the entry inside the window. */
|
|
73
|
+
readonly atMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
/**
|
|
69
77
|
* Async on every member, so a shared implementation can exist at all: a lockout that holds across
|
|
70
78
|
* replicas is a network round trip, and a synchronous signature has no way to wait for one. The
|
|
71
79
|
* in-memory limiter below answers immediately — the cost is one already-settled promise per call,
|
|
72
80
|
* on a path that is about to run a KDF.
|
|
81
|
+
*
|
|
82
|
+
* The allowance is RESERVED, never checked: `reserve` counts the attempt in the same step that
|
|
83
|
+
* decides whether it may run, so N concurrent guesses spend N of the allowance before any of them
|
|
84
|
+
* reaches the KDF. A check followed later by a record let every one of them pass the check.
|
|
73
85
|
*/
|
|
74
86
|
export interface AuthLimiter {
|
|
75
87
|
/**
|
|
@@ -79,9 +91,18 @@ export interface AuthLimiter {
|
|
|
79
91
|
* operator reads and nothing enforces.
|
|
80
92
|
*/
|
|
81
93
|
readonly policy: AuthRateLimitPolicy;
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Count one attempt against the key, atomically, BEFORE any KDF work. Rejects with
|
|
96
|
+
* `X_ACCOUNT_LOCKED` — counting nothing — while the key is inside a lockout; the attempt that
|
|
97
|
+
* fills the window is admitted and starts the lockout. A failed attempt needs no further call:
|
|
98
|
+
* the reservation IS the recorded failure.
|
|
99
|
+
*/
|
|
100
|
+
reserve(key: string): Promise<AuthReservation>;
|
|
101
|
+
/**
|
|
102
|
+
* Give one reservation back — the attempt succeeded, or never reached a verdict. Removes that
|
|
103
|
+
* one entry and lifts a lockout the window no longer justifies. Never clears the key.
|
|
104
|
+
*/
|
|
105
|
+
refund(reservation: AuthReservation): Promise<void>;
|
|
85
106
|
/** A success clears the window: a legitimate user is not punished for a typo yesterday. */
|
|
86
107
|
recordSuccess(key: string): Promise<void>;
|
|
87
108
|
lockedUntil(key: string): Promise<Date | null>;
|
|
@@ -113,7 +134,7 @@ export const ipKey = (ip: string): string => `ip:${ip}`;
|
|
|
113
134
|
* in `@ultimat3/http` and `@ultimat3/jobs`: three limiters that spell one tenant three ways cannot
|
|
114
135
|
* be read together during an incident.
|
|
115
136
|
*/
|
|
116
|
-
export const orgKey = (orgId: string): string =>
|
|
137
|
+
export const orgKey = (orgId: string): string => `${ORG_KEY_PREFIX}${orgId}`;
|
|
117
138
|
|
|
118
139
|
/**
|
|
119
140
|
* The policy the tenant limiter enforces — the same window and lockout, a wider allowance. One
|
|
@@ -204,25 +225,38 @@ export function createAuthLimiter(
|
|
|
204
225
|
get size() {
|
|
205
226
|
return buckets.size;
|
|
206
227
|
},
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const nowMs = clock.now().getTime();
|
|
211
|
-
if (bucket.lockedUntilMs <= nowMs) return;
|
|
212
|
-
throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
|
|
213
|
-
},
|
|
214
|
-
async recordFailure(key) {
|
|
228
|
+
// No `await` anywhere in the body: the check and the count are one synchronous step, which
|
|
229
|
+
// is the whole guarantee — concurrent callers are admitted one at a time, in order.
|
|
230
|
+
async reserve(key) {
|
|
215
231
|
const nowMs = clock.now().getTime();
|
|
232
|
+
const existing = buckets.get(key);
|
|
233
|
+
if (existing !== undefined && existing.lockedUntilMs > nowMs) {
|
|
234
|
+
throw accountLocked(key, Math.ceil((existing.lockedUntilMs - nowMs) / 1000));
|
|
235
|
+
}
|
|
216
236
|
const bucket = bucketFor(key);
|
|
217
237
|
bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
|
|
218
238
|
bucket.failures.push(nowMs);
|
|
219
239
|
if (bucket.failures.length >= policy.maxAttempts) {
|
|
220
240
|
bucket.lockedUntilMs = nowMs + policy.lockoutMs;
|
|
221
241
|
}
|
|
222
|
-
// The newest
|
|
242
|
+
// The newest attempt leaves the window last, so that is the earliest this entry is free —
|
|
223
243
|
// unless a lockout outlives it. Recorded here because this is the only growth path.
|
|
224
244
|
bucket.forgetAtMs = Math.max(bucket.lockedUntilMs, nowMs + policy.windowMs);
|
|
225
245
|
if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
|
|
246
|
+
return { key, atMs: nowMs };
|
|
247
|
+
},
|
|
248
|
+
async refund(reservation) {
|
|
249
|
+
const bucket = buckets.get(reservation.key);
|
|
250
|
+
if (bucket === undefined) return;
|
|
251
|
+
const index = bucket.failures.indexOf(reservation.atMs);
|
|
252
|
+
if (index < 0) return;
|
|
253
|
+
bucket.failures.splice(index, 1);
|
|
254
|
+
const nowMs = clock.now().getTime();
|
|
255
|
+
const live = bucket.failures.filter((at) => at > nowMs - policy.windowMs).length;
|
|
256
|
+
// A lockout this reservation completed is not one the remaining failures justify.
|
|
257
|
+
if (live < policy.maxAttempts) bucket.lockedUntilMs = 0;
|
|
258
|
+
if (bucket.failures.length === 0 && bucket.lockedUntilMs === 0)
|
|
259
|
+
buckets.delete(reservation.key);
|
|
226
260
|
},
|
|
227
261
|
async recordSuccess(key) {
|
|
228
262
|
buckets.delete(key);
|
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,6 +5,7 @@
|
|
|
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 } from '@ultimat3/core';
|
|
8
9
|
import type { AuthSession, SessionStore } from './adapter';
|
|
9
10
|
import { sessionExpired, sessionUnknown } from './errors';
|
|
10
11
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
@@ -278,36 +279,9 @@ export function clearSessionCookie(policy: SessionPolicy, name?: string): string
|
|
|
278
279
|
}
|
|
279
280
|
|
|
280
281
|
/**
|
|
281
|
-
* The
|
|
282
|
-
* throws: a missing or unreadable cookie is `null` or the raw value
|
|
282
|
+
* The session token off the request, through core's `readCookie` — the oauth handshake reads
|
|
283
|
+
* through the same one, so neither throws: a missing or unreadable cookie is `null` or the raw value.
|
|
283
284
|
*/
|
|
284
|
-
export function readCookie(request: RequestLike, name: string): string | null {
|
|
285
|
-
const header = request.headers.get('cookie');
|
|
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;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* A `Cookie:` header is attacker-controlled, and `decodeURIComponent('%')` throws a bare
|
|
298
|
-
* `URIError` — which escapes every coded path that reads through here: an OAuth callback would
|
|
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.
|
|
302
|
-
*/
|
|
303
|
-
function decodeCookieValue(raw: string): string {
|
|
304
|
-
try {
|
|
305
|
-
return decodeURIComponent(raw);
|
|
306
|
-
} catch {
|
|
307
|
-
return raw;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
285
|
export function readSessionCookie(request: RequestLike, policy: SessionPolicy): string | null {
|
|
312
|
-
return readCookie(request, policy.cookieName);
|
|
286
|
+
return readCookie(request.headers.get('cookie'), policy.cookieName);
|
|
313
287
|
}
|
package/src/tables.ts
CHANGED
|
@@ -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/verify.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
7
|
import type { AuthVerification, VerificationStore } from './adapter';
|
package/src/auth-fixture.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
// Single responsibility: the three values every credential-flow suite needs — cheap KDF
|
|
2
|
-
// parameters, the password they all sign in with, and the `AuthError` catcher. Three test files
|
|
3
|
-
// share them, and three private copies is three chances for one to drift from what the flow
|
|
4
|
-
// actually enforces. Not part of the public API — `index.ts` deliberately does not re-export it.
|
|
5
|
-
|
|
6
|
-
import { AuthError } from './errors';
|
|
7
|
-
import type { PasswordParams } from './password';
|
|
8
|
-
|
|
9
|
-
// Fast KDF parameters: these tests are about the credential flow, not argon2's cost.
|
|
10
|
-
export const FAST_PARAMS: PasswordParams = { algorithm: 'argon2id', memoryCost: 8192, timeCost: 1 };
|
|
11
|
-
|
|
12
|
-
export const PASSWORD = 'correct-horse-battery-staple-42';
|
|
13
|
-
|
|
14
|
-
/** Captures the thrown `AuthError`, or `undefined` when the call unexpectedly resolved. The
|
|
15
|
-
* caller's `expect(error?.code).toBe(...)` is then the assertion that fails, naming the code it
|
|
16
|
-
* wanted — a sentinel thrown from in here would carry no code and no fix. Anything that is not
|
|
17
|
-
* an `AuthError` is rethrown untouched: this helper never swallows an unexpected failure. */
|
|
18
|
-
export const caught = async (fn: () => Promise<unknown>): Promise<AuthError | undefined> => {
|
|
19
|
-
try {
|
|
20
|
-
await fn();
|
|
21
|
-
return undefined;
|
|
22
|
-
} catch (error) {
|
|
23
|
-
if (error instanceof AuthError) return error;
|
|
24
|
-
throw error;
|
|
25
|
-
}
|
|
26
|
-
};
|
package/src/id-token-fixture.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// Single responsibility: the one way this package's tests mint an id token. Three OAuth test
|
|
2
|
-
// files each needs a base64url-encoded JWT, and three private copies of the encoder is three
|
|
3
|
-
// chances for one to drift from what `decodeIdToken` actually parses. Not part of the public
|
|
4
|
-
// API — `index.ts` deliberately does not re-export it.
|
|
5
|
-
|
|
6
|
-
import type { IdTokenClaims } from './id-token';
|
|
7
|
-
import { base64Url } from './tokens';
|
|
8
|
-
|
|
9
|
-
/** `base64Url` takes bytes because every real secret is bytes; a JWT segment is text. */
|
|
10
|
-
export const base64UrlText = (value: string): string => base64Url(new TextEncoder().encode(value));
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Header, payload, and a signature that is not one. Signatures are never checked here — the
|
|
14
|
-
* token is only ever read straight off the token endpoint — so a fixture needs no signer.
|
|
15
|
-
*/
|
|
16
|
-
// The union, and not `Record<string, unknown>` alone: `IdTokenClaims` is an `interface`, so it has
|
|
17
|
-
// no implicit index signature and the one shape this fixture exists to serialise was the one shape
|
|
18
|
-
// it refused. The record arm stays for the malformed payloads `id-token.test.ts` builds by hand.
|
|
19
|
-
export const unsignedJwt = (claims: IdTokenClaims | Readonly<Record<string, unknown>>): string =>
|
|
20
|
-
`${base64UrlText('{"alg":"RS256"}')}.${base64UrlText(JSON.stringify(claims))}.signature`;
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// The fixtures the three `oauth-login` suites share: the frozen instant, a fresh
|
|
2
|
-
// `MemoryAdapter`-backed `Auth`, the two request bodies, the code of a rejection and a JSON
|
|
3
|
-
// `Response`. Shared rather than copied — three suites building their own `Auth` would be three
|
|
4
|
-
// flows that agree only by construction, the same reason `backfill-pass-fixture.ts` exists.
|
|
5
|
-
|
|
6
|
-
import { frozenClock, isUltimateError } from '@ultimat3/core';
|
|
7
|
-
import { type Auth, defineAuth } from './auth';
|
|
8
|
-
import { MemoryAdapter } from './memory-adapter';
|
|
9
|
-
import type { OAuthTokens } from './oauth-exchange';
|
|
10
|
-
import type { OAuthProfile } from './oauth-profile';
|
|
11
|
-
|
|
12
|
-
export const NOW = new Date('2026-08-09T12:00:00.000Z');
|
|
13
|
-
|
|
14
|
-
export const credentials = { clientId: 'client-id', clientSecret: 'client-secret' };
|
|
15
|
-
|
|
16
|
-
/** One adapter and the `Auth` over it, minted per `beforeEach` — never shared between tests. */
|
|
17
|
-
export const freshAuth = (): { adapter: MemoryAdapter; auth: Auth } => {
|
|
18
|
-
const adapter = new MemoryAdapter();
|
|
19
|
-
return {
|
|
20
|
-
adapter,
|
|
21
|
-
auth: defineAuth({ adapter, clock: frozenClock(NOW), providers: ['github', 'google'] }),
|
|
22
|
-
};
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export const profile = (overrides: Partial<OAuthProfile> = {}): OAuthProfile => ({
|
|
26
|
-
provider: 'github',
|
|
27
|
-
providerAccountId: '583231',
|
|
28
|
-
email: 'ada@example.com',
|
|
29
|
-
emailVerified: true,
|
|
30
|
-
name: 'Ada Lovelace',
|
|
31
|
-
...overrides,
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
export const tokens = (overrides: Partial<OAuthTokens> = {}): OAuthTokens => ({
|
|
35
|
-
accessToken: 'gho_first',
|
|
36
|
-
refreshToken: null,
|
|
37
|
-
expiresAt: null,
|
|
38
|
-
idToken: null,
|
|
39
|
-
claims: null,
|
|
40
|
-
...overrides,
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
export const codeOf = async (call: Promise<unknown>): Promise<string> => {
|
|
44
|
-
try {
|
|
45
|
-
await call;
|
|
46
|
-
} catch (error) {
|
|
47
|
-
return isUltimateError(error) ? error.code : `not-an-UltimateError: ${String(error)}`;
|
|
48
|
-
}
|
|
49
|
-
return 'did-not-throw';
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
export const json = (body: unknown): Response =>
|
|
53
|
-
new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
|