@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
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Single responsibility: the second leg of a sign-in. The first leg ends in `X_MFA_REQUIRED`
|
|
2
|
+
// carrying a sealed, short-lived challenge; `completeMfa` opens it, meters the code against the
|
|
3
|
+
// same buckets a password guess spends, and only then mints the session.
|
|
4
|
+
|
|
5
|
+
import { isUltimateError, openText, randomHex, type SealKeySource, seal } from '@ultimat3/core';
|
|
6
|
+
import type { Auth, LoginResult } from './auth';
|
|
7
|
+
import { type AuthError, mfaChallengeInvalid, mfaCodeRejected, mfaRequired } from './errors';
|
|
8
|
+
import { isRecord } from './json';
|
|
9
|
+
import { openLoginAttempt } from './login-attempt';
|
|
10
|
+
import { recoveryCodeHash, TOTP_DIGITS, verifyTotp } from './mfa';
|
|
11
|
+
import { openTotpSecret } from './mfa-secret';
|
|
12
|
+
import { resolveActor } from './policy-bridge';
|
|
13
|
+
import { accountKey } from './rate-limit';
|
|
14
|
+
import { createSession, sessionCookie } from './session';
|
|
15
|
+
|
|
16
|
+
/** Bound into the seal's tag: a value sealed for anything else does not open as a challenge. */
|
|
17
|
+
export const MFA_CHALLENGE_PURPOSE = 'auth:mfa-challenge';
|
|
18
|
+
|
|
19
|
+
/** Long enough to unlock a phone and read six digits; short enough that a lifted one is stale. */
|
|
20
|
+
export const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
interface MfaChallenge {
|
|
23
|
+
readonly userId: string;
|
|
24
|
+
/** Makes two challenges for one user at one instant different values. */
|
|
25
|
+
readonly nonce: string;
|
|
26
|
+
readonly expiresAtMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The refusal `login()` and the OAuth path end their first leg with, carrying the challenge in
|
|
31
|
+
* `meta.challenge`. Sealed under the app's master key (`x secrets init`), so it needs no table and
|
|
32
|
+
* no second secret — and a deployment that enrols a second factor without one fails here, loudly,
|
|
33
|
+
* with `X_SEAL_KEY_MISSING`.
|
|
34
|
+
*/
|
|
35
|
+
export async function mfaChallengeRequired(
|
|
36
|
+
auth: Auth,
|
|
37
|
+
userId: string,
|
|
38
|
+
keys: SealKeySource = {},
|
|
39
|
+
): Promise<AuthError> {
|
|
40
|
+
const challenge: MfaChallenge = {
|
|
41
|
+
userId,
|
|
42
|
+
nonce: randomHex(16),
|
|
43
|
+
expiresAtMs: auth.clock.now().getTime() + MFA_CHALLENGE_TTL_MS,
|
|
44
|
+
};
|
|
45
|
+
return mfaRequired(
|
|
46
|
+
await seal(JSON.stringify(challenge), { ...keys, purpose: MFA_CHALLENGE_PURPOSE }),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A seal that would not open is a bad challenge; a missing master key is a broken deployment. */
|
|
51
|
+
const UNOPENABLE: ReadonlySet<string> = new Set(['X_SEAL_INVALID', 'X_SEAL_KEY_UNKNOWN']);
|
|
52
|
+
|
|
53
|
+
async function openChallenge(
|
|
54
|
+
auth: Auth,
|
|
55
|
+
token: unknown,
|
|
56
|
+
keys: SealKeySource,
|
|
57
|
+
): Promise<MfaChallenge> {
|
|
58
|
+
if (typeof token !== 'string' || token.length === 0) throw mfaChallengeInvalid();
|
|
59
|
+
let parsed: unknown;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(await openText(token, { ...keys, purpose: MFA_CHALLENGE_PURPOSE }));
|
|
62
|
+
} catch (thrown) {
|
|
63
|
+
if (isUltimateError(thrown) && !UNOPENABLE.has(thrown.code)) throw thrown;
|
|
64
|
+
throw mfaChallengeInvalid();
|
|
65
|
+
}
|
|
66
|
+
if (!isRecord(parsed)) throw mfaChallengeInvalid();
|
|
67
|
+
const { userId, nonce, expiresAtMs } = parsed;
|
|
68
|
+
if (typeof userId !== 'string' || typeof nonce !== 'string' || typeof expiresAtMs !== 'number') {
|
|
69
|
+
throw mfaChallengeInvalid();
|
|
70
|
+
}
|
|
71
|
+
// The server's clock decides. `>=` on a number that is not one is false, so a NaN is refused
|
|
72
|
+
// by asking the question the way round that fails closed.
|
|
73
|
+
if (!(auth.clock.now().getTime() < expiresAtMs)) throw mfaChallengeInvalid();
|
|
74
|
+
return { userId, nonce, expiresAtMs };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CompleteMfaOptions extends SealKeySource {
|
|
78
|
+
/** The caller's address: a wrong code counts against it, exactly as a wrong password does. */
|
|
79
|
+
readonly ip?: string | null | undefined;
|
|
80
|
+
readonly userAgent?: string | null | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** What an authenticator app shows, once the spaces a person types are gone. */
|
|
84
|
+
const TOTP_SHAPE = new RegExp(`^[0-9]{${TOTP_DIGITS}}$`);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Finish a sign-in that stopped at `X_MFA_REQUIRED`: the challenge from `error.meta.challenge`
|
|
88
|
+
* and either the six digits or one recovery code. Answers what `login()` answers.
|
|
89
|
+
*
|
|
90
|
+
* The guess is RESERVED before the code is looked at, against the account, the address and the
|
|
91
|
+
* tenant — the buckets a password guess spends — so six digits are worth `maxAttempts` tries per
|
|
92
|
+
* window and no more, and a wrong code is followed by `X_ACCOUNT_LOCKED`. A code is good once:
|
|
93
|
+
* a TOTP step is remembered in `auth.totpReplay`, and a recovery code is consumed by the adapter
|
|
94
|
+
* in one atomic step, so two requests carrying one code cannot both finish.
|
|
95
|
+
*/
|
|
96
|
+
export async function completeMfa(
|
|
97
|
+
auth: Auth,
|
|
98
|
+
challengeToken: string,
|
|
99
|
+
code: string,
|
|
100
|
+
options: CompleteMfaOptions = {},
|
|
101
|
+
): Promise<LoginResult> {
|
|
102
|
+
const challenge = await openChallenge(auth, challengeToken, options);
|
|
103
|
+
const user = await auth.adapter.findUserById(challenge.userId);
|
|
104
|
+
if (user === null || user.disabledAt !== null || user.mfaSecret === null) {
|
|
105
|
+
throw mfaChallengeInvalid();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Before anything is reserved: a stored value that is not sealed is the deployment's fault,
|
|
109
|
+
// not a guess, and it is refused (`X_MFA_SECRET_UNSEALED`) rather than read.
|
|
110
|
+
const secret = await openTotpSecret(user.mfaSecret, options);
|
|
111
|
+
|
|
112
|
+
const ip = options.ip ?? null;
|
|
113
|
+
const attempt = await openLoginAttempt(auth, accountKey(user.email), ip);
|
|
114
|
+
await attempt.reserveOrg(user.orgId);
|
|
115
|
+
|
|
116
|
+
const presented = typeof code === 'string' ? code.replaceAll(' ', '') : '';
|
|
117
|
+
if (TOTP_SHAPE.test(presented)) {
|
|
118
|
+
const now = auth.clock.now();
|
|
119
|
+
const verdict = verifyTotp({ secret, code: presented, at: now });
|
|
120
|
+
// Checked and remembered with no `await` between them: two concurrent completions carrying
|
|
121
|
+
// one code cannot both find the step unspent.
|
|
122
|
+
if (!verdict.ok || verdict.step === null || auth.totpReplay.isUsed(user.id, verdict.step)) {
|
|
123
|
+
throw mfaCodeRejected();
|
|
124
|
+
}
|
|
125
|
+
auth.totpReplay.remember(user.id, verdict.step, now);
|
|
126
|
+
} else {
|
|
127
|
+
let consumed: boolean;
|
|
128
|
+
try {
|
|
129
|
+
consumed = await auth.adapter.consumeRecoveryCode(user.id, recoveryCodeHash(presented));
|
|
130
|
+
} catch (thrown) {
|
|
131
|
+
// A store that is down reached no verdict, so the guess is not counted as a wrong one.
|
|
132
|
+
await attempt.release();
|
|
133
|
+
throw thrown;
|
|
134
|
+
}
|
|
135
|
+
if (!consumed) throw mfaCodeRejected();
|
|
136
|
+
}
|
|
137
|
+
await attempt.succeed();
|
|
138
|
+
|
|
139
|
+
const issued = await createSession(auth.sessions, {
|
|
140
|
+
userId: user.id,
|
|
141
|
+
ip,
|
|
142
|
+
userAgent: options.userAgent,
|
|
143
|
+
mfaSatisfied: true,
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
actor: resolveActor({ kind: 'user', user, session: issued.session }),
|
|
147
|
+
session: issued.session,
|
|
148
|
+
token: issued.token,
|
|
149
|
+
cookie: sessionCookie(issued.token, auth.sessions.policy),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Single responsibility: the second-factor secret at rest. A TOTP seed is symmetric — verifying a
|
|
2
|
+
// code needs the seed itself — so it is sealed under the app's master key, never hashed and never
|
|
3
|
+
// stored in the clear. One writer, one reader, and the one-shot that seals what predates them.
|
|
4
|
+
|
|
5
|
+
import { isSealed, openText, type SealKeySource, seal } from '@ultimat3/core';
|
|
6
|
+
import type { UserStore } from './adapter';
|
|
7
|
+
import { authWriteFailed, mfaSecretInvalid, mfaSecretUnsealed } from './errors';
|
|
8
|
+
import { base32Decode } from './mfa';
|
|
9
|
+
|
|
10
|
+
/** The slice of the seam this file reads and writes — what a CLI holding only a database can supply. */
|
|
11
|
+
export interface MfaSecretStore {
|
|
12
|
+
readonly adapter: Pick<UserStore, 'listUsersWithMfaSecret' | 'replaceMfaSecret' | 'updateUser'>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Bound into the tag: a value sealed for any other column does not open as a TOTP secret. */
|
|
16
|
+
export const MFA_SECRET_PURPOSE = 'auth:x_users.mfa_secret';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Enrolment's write: the base32 secret `enrolTotp` answered, sealed, onto the user's row. The
|
|
20
|
+
* ONLY way a secret should reach `x_users.mfa_secret` — a value written past this is refused by
|
|
21
|
+
* every reader until `sealMfaSecrets` has sealed it.
|
|
22
|
+
*/
|
|
23
|
+
export async function saveTotpSecret(
|
|
24
|
+
auth: MfaSecretStore,
|
|
25
|
+
userId: string,
|
|
26
|
+
secret: string,
|
|
27
|
+
keys: SealKeySource = {},
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
// A secret nothing can derive a code from is refused before it is sealed: sealed, it would be
|
|
30
|
+
// an opaque value nobody could tell was a lockout.
|
|
31
|
+
if (typeof secret !== 'string' || base32Decode(secret).length === 0) {
|
|
32
|
+
throw mfaSecretInvalid('saveTotpSecret');
|
|
33
|
+
}
|
|
34
|
+
const sealed = await seal(secret, { ...keys, purpose: MFA_SECRET_PURPOSE });
|
|
35
|
+
const user = await auth.adapter.updateUser(userId, { mfaSecret: sealed });
|
|
36
|
+
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The base32 secret behind a stored value. A value that is not sealed is REFUSED, never read as
|
|
41
|
+
* the secret it probably is: a plaintext read kept "for now" is a plaintext column forever.
|
|
42
|
+
*/
|
|
43
|
+
export async function openTotpSecret(stored: string, keys: SealKeySource = {}): Promise<string> {
|
|
44
|
+
if (!isSealed(stored)) throw mfaSecretUnsealed();
|
|
45
|
+
return await openText(stored, { ...keys, purpose: MFA_SECRET_PURPOSE });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SealMfaSecretsReport {
|
|
49
|
+
/** Values that were plaintext and are now sealed. */
|
|
50
|
+
readonly sealed: number;
|
|
51
|
+
/** Values that were already sealed, and were left exactly as they were. */
|
|
52
|
+
readonly alreadySealed: number;
|
|
53
|
+
/**
|
|
54
|
+
* Rows whose secret changed between the read and the write — the user re-enrolled, or
|
|
55
|
+
* un-enrolled, mid-run — and were left alone. A new enrolment is already sealed.
|
|
56
|
+
*/
|
|
57
|
+
readonly skipped: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** How many stored secrets are still plaintext — what `x doctor` reports before a user finds out. */
|
|
61
|
+
export async function countUnsealedMfaSecrets(auth: MfaSecretStore): Promise<number> {
|
|
62
|
+
const stored = await auth.adapter.listUsersWithMfaSecret();
|
|
63
|
+
return stored.filter((row) => !isSealed(row.mfaSecret)).length;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The one-shot behind `x auth seal-mfa`: every plaintext `mfa_secret` sealed in place. Idempotent
|
|
68
|
+
* — a sealed value is counted and never touched — so a run that stopped half way is finished by
|
|
69
|
+
* running it again. The master key is resolved by the first `seal`, before any row is written.
|
|
70
|
+
*
|
|
71
|
+
* Each write is a compare-and-set on the value that was read: a row that changed underneath is
|
|
72
|
+
* `skipped`, never overwritten with the secret it used to hold.
|
|
73
|
+
*/
|
|
74
|
+
export async function sealMfaSecrets(
|
|
75
|
+
auth: MfaSecretStore,
|
|
76
|
+
keys: SealKeySource = {},
|
|
77
|
+
): Promise<SealMfaSecretsReport> {
|
|
78
|
+
let sealed = 0;
|
|
79
|
+
let alreadySealed = 0;
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
for (const row of await auth.adapter.listUsersWithMfaSecret()) {
|
|
82
|
+
if (isSealed(row.mfaSecret)) {
|
|
83
|
+
alreadySealed += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const value = await seal(row.mfaSecret, { ...keys, purpose: MFA_SECRET_PURPOSE });
|
|
87
|
+
if (await auth.adapter.replaceMfaSecret(row.userId, row.mfaSecret, value)) sealed += 1;
|
|
88
|
+
else skipped += 1;
|
|
89
|
+
}
|
|
90
|
+
return { sealed, alreadySealed, skipped };
|
|
91
|
+
}
|
package/src/mfa.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Single responsibility: TOTP (RFC 6238) and recovery codes. The drift window is explicit and
|
|
2
2
|
// small, and every accepted step is remembered: without replay rejection a code shouted over a
|
|
3
3
|
// phishing page stays valid for the rest of its 30 seconds. Recovery codes are hashed at rest
|
|
4
|
-
// and single-use, so a database dump is not a permanent MFA bypass
|
|
4
|
+
// and single-use, so a database dump is not a permanent MFA bypass; consuming one is the
|
|
5
|
+
// adapter's (`consumeRecoveryCode`), because single-use has to be one atomic step.
|
|
5
6
|
|
|
6
7
|
import { finiteCount } from '@ultimat3/core';
|
|
7
8
|
import type { Auth } from './auth';
|
|
@@ -308,6 +309,12 @@ export interface RecoveryCodeSet {
|
|
|
308
309
|
const normaliseRecoveryCode = (code: string): string =>
|
|
309
310
|
code.replaceAll('-', '').replaceAll(' ', '').toUpperCase();
|
|
310
311
|
|
|
312
|
+
/**
|
|
313
|
+
* What a recovery code is stored and looked up as: SHA-256 of the code with the dashes and spaces
|
|
314
|
+
* a person types taken out. `AuthAdapter.consumeRecoveryCode` takes this, never the code.
|
|
315
|
+
*/
|
|
316
|
+
export const recoveryCodeHash = (code: string): string => sha256Hex(normaliseRecoveryCode(code));
|
|
317
|
+
|
|
311
318
|
/**
|
|
312
319
|
* `count` is a LOOP BOUND, so it fails in both directions and neither is a smaller set of codes.
|
|
313
320
|
* `NaN` — what `Number(process.env.RECOVERY_CODES)` answers for an unset variable, and not nullish,
|
|
@@ -326,26 +333,5 @@ export function generateRecoveryCodes(count = 10): RecoveryCodeSet {
|
|
|
326
333
|
const raw = base32Encode(randomBytes(10)).slice(0, 16);
|
|
327
334
|
codes.push(`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}-${raw.slice(12, 16)}`);
|
|
328
335
|
}
|
|
329
|
-
return { codes, hashes: codes.map(
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Returns the remaining hashes with the redeemed one removed, or `null` if nothing matched.
|
|
334
|
-
* The caller persists the result — that write is what makes a code single-use.
|
|
335
|
-
*/
|
|
336
|
-
export function redeemRecoveryCode(
|
|
337
|
-
code: string,
|
|
338
|
-
hashes: readonly string[],
|
|
339
|
-
): readonly string[] | null {
|
|
340
|
-
const candidate = sha256Hex(normaliseRecoveryCode(code));
|
|
341
|
-
let matched = false;
|
|
342
|
-
const remaining: string[] = [];
|
|
343
|
-
for (const hash of hashes) {
|
|
344
|
-
if (!matched && timingSafeEqual(hash, candidate)) {
|
|
345
|
-
matched = true;
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
remaining.push(hash);
|
|
349
|
-
}
|
|
350
|
-
return matched ? remaining : null;
|
|
336
|
+
return { codes, hashes: codes.map(recoveryCodeHash) };
|
|
351
337
|
}
|
package/src/oauth-builtins.ts
CHANGED
|
@@ -39,6 +39,12 @@ export const GOOGLE_PROVIDER: OAuthProvider = {
|
|
|
39
39
|
clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* UNPROVEN: this flow has never completed a sign-in against Apple, in a test or anywhere else.
|
|
44
|
+
* Apple answers a request that asks for a scope with `response_mode=form_post` — a POST to the
|
|
45
|
+
* callback — and both route descriptors in `oauth-route.ts` are GET and read the query string.
|
|
46
|
+
* Whether this entry ships or is removed is an open owner decision; do not rely on it.
|
|
47
|
+
*/
|
|
42
48
|
export const APPLE_PROVIDER: OAuthProvider = {
|
|
43
49
|
id: 'apple',
|
|
44
50
|
authorizeUrl: 'https://appleid.apple.com/auth/authorize',
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// format at one trust level.
|
|
7
7
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
|
-
import { EnvMissingError, systemClock } from '@ultimat3/core';
|
|
9
|
+
import { EnvMissingError, readCookie, systemClock } from '@ultimat3/core';
|
|
10
10
|
import type { OAuthHandshake, OAuthProviderId } from './oauth';
|
|
11
11
|
import { oauthStateInvalid } from './oauth-errors';
|
|
12
12
|
import { hasOAuthProvider } from './oauth-registry';
|
|
13
13
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
14
|
-
import {
|
|
14
|
+
import type { RequestLike } from './session';
|
|
15
15
|
import { base64Url, timingSafeEqual } from './tokens';
|
|
16
16
|
|
|
17
17
|
/** `__Host-` for the same reason the session cookie carries it: no subdomain can plant one. */
|
|
@@ -67,7 +67,8 @@ export function handshakeSecret(
|
|
|
67
67
|
cause:
|
|
68
68
|
secret === ''
|
|
69
69
|
? 'SESSION_SECRET is not set, so an oauth handshake cannot be signed'
|
|
70
|
-
:
|
|
70
|
+
: // Never its length: how long the configured secret is narrows a guess at it.
|
|
71
|
+
`SESSION_SECRET is shorter than the ${MIN_SECRET_LENGTH} characters required`,
|
|
71
72
|
fix: 'export SESSION_SECRET="$(openssl rand -hex 32)"',
|
|
72
73
|
meta: { key: 'SESSION_SECRET', minLength: MIN_SECRET_LENGTH },
|
|
73
74
|
});
|
|
@@ -208,7 +209,10 @@ export function readHandshakeCookie(
|
|
|
208
209
|
provider: OAuthProviderId,
|
|
209
210
|
options?: HandshakeCookieOptions,
|
|
210
211
|
): OAuthHandshake {
|
|
211
|
-
const sealed = readCookie(
|
|
212
|
+
const sealed = readCookie(
|
|
213
|
+
request.headers.get('cookie'),
|
|
214
|
+
options?.name ?? handshakeCookieName(provider),
|
|
215
|
+
);
|
|
212
216
|
if (sealed === null) {
|
|
213
217
|
throw oauthStateInvalid(provider, 'no handshake cookie arrived with the callback');
|
|
214
218
|
}
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -3,7 +3,12 @@
|
|
|
3
3
|
// boot, no dependency — an enterprise IdP is then three lines instead of a hand-copied table of
|
|
4
4
|
// four endpoints that nobody re-checks when the vendor moves one.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
renderCauseValue,
|
|
8
|
+
renderFixLiteral,
|
|
9
|
+
renderFixShellArg,
|
|
10
|
+
renderThrowable,
|
|
11
|
+
} from '@ultimat3/core';
|
|
7
12
|
import { isRecord } from './json';
|
|
8
13
|
import type { OAuthProvider } from './oauth';
|
|
9
14
|
import { oauthExchangeFailed } from './oauth-errors';
|
|
@@ -16,9 +21,12 @@ const TIMEOUT_CONSEQUENCE =
|
|
|
16
21
|
|
|
17
22
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
18
23
|
|
|
24
|
+
/** An issuer as it is compared and built on: its trailing slashes are not part of its identity. */
|
|
25
|
+
const bareIssuer = (issuer: string): string => issuer.replace(/\/+$/, '');
|
|
26
|
+
|
|
19
27
|
/** The document lives at a fixed suffix off the issuer; the issuer keeps its own path. */
|
|
20
28
|
export const discoveryUrl = (issuer: string): string =>
|
|
21
|
-
`${issuer
|
|
29
|
+
`${bareIssuer(issuer)}/.well-known/openid-configuration`;
|
|
22
30
|
|
|
23
31
|
export interface DiscoverOAuthProviderInput {
|
|
24
32
|
/** The id the URL segment carries: `/auth/oauth/<id>`. Also names the two env vars. */
|
|
@@ -46,9 +54,9 @@ const envPrefix = (id: string): string => id.toUpperCase().replace(/[^A-Z0-9]+/g
|
|
|
46
54
|
* `registerOAuthProvider(await discoverOAuthProvider({ id, issuer }))` keeps one install point,
|
|
47
55
|
* and an app that wants to override one endpoint spreads the result before registering it.
|
|
48
56
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
57
|
+
* A document that declares a different issuer than the one asked for is answering for somebody
|
|
58
|
+
* else and is refused (OIDC Discovery 1.0 §4.3). `issuers` then carries the document's own
|
|
59
|
+
* spelling, which is what its id tokens put in `iss`; only a trailing slash may differ.
|
|
52
60
|
*/
|
|
53
61
|
export async function discoverOAuthProvider(
|
|
54
62
|
input: DiscoverOAuthProviderInput,
|
|
@@ -124,6 +132,17 @@ export async function discoverOAuthProvider(
|
|
|
124
132
|
});
|
|
125
133
|
}
|
|
126
134
|
|
|
135
|
+
if (bareIssuer(issuer) !== bareIssuer(input.issuer)) {
|
|
136
|
+
throw oauthExchangeFailed({
|
|
137
|
+
provider: input.id,
|
|
138
|
+
stage: 'discovery',
|
|
139
|
+
detail:
|
|
140
|
+
`the discovery document declares issuer ${renderCauseValue(issuer)}, and it was fetched ` +
|
|
141
|
+
`for ${renderCauseValue(input.issuer)}, so it is answering for another issuer`,
|
|
142
|
+
fix: `curl -sS -m 5 ${renderFixShellArg(url, '<issuer>/.well-known/openid-configuration')} | jq .issuer`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
const jwksUri = stringOrNull(body, 'jwks_uri');
|
|
128
147
|
if (jwksUri === null) {
|
|
129
148
|
// Without a key set there is nothing to verify an id token against, and the only remaining
|
package/src/oauth-errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Single responsibility: the OAuth
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// `registerErrorCodes()` call stay
|
|
1
|
+
// Single responsibility: the OAuth refusals more than one OAuth module raises, and the one phrase
|
|
2
|
+
// every "start over" fix is built from. A refusal with one thrower lives beside it (`oauthDenied`
|
|
3
|
+
// in `oauth-route.ts`, the linking ones in `oauth-login.ts`); the codes, titles and the single
|
|
4
|
+
// `registerErrorCodes()` call stay in `errors.ts`, so this file adds no code and registers nothing.
|
|
5
5
|
|
|
6
6
|
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
7
|
import { AuthError } from './errors';
|
|
@@ -25,30 +25,6 @@ export const oauthStateInvalid = (provider: string, part: string): AuthError =>
|
|
|
25
25
|
export const restartAt = (provider: string): string =>
|
|
26
26
|
`restart the flow at GET ${oauthStartPath(provider)}`;
|
|
27
27
|
|
|
28
|
-
/**
|
|
29
|
-
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
30
|
-
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
31
|
-
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
32
|
-
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
33
|
-
*/
|
|
34
|
-
export const oauthDenied = (
|
|
35
|
-
provider: string,
|
|
36
|
-
reason: string,
|
|
37
|
-
description: string | null,
|
|
38
|
-
): AuthError =>
|
|
39
|
-
new AuthError({
|
|
40
|
-
code: 'X_OAUTH_DENIED',
|
|
41
|
-
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
42
|
-
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
43
|
-
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
44
|
-
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
45
|
-
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
46
|
-
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
47
|
-
}`,
|
|
48
|
-
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
49
|
-
meta: { provider, reason },
|
|
50
|
-
});
|
|
51
|
-
|
|
52
28
|
/**
|
|
53
29
|
* A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
|
|
54
30
|
* was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
|
|
@@ -79,19 +55,6 @@ export const oauthProviderUnknown = (provider: string, supported: readonly strin
|
|
|
79
55
|
meta: { provider },
|
|
80
56
|
});
|
|
81
57
|
|
|
82
|
-
/**
|
|
83
|
-
* Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
|
|
84
|
-
* module imported second decide where every login for that id goes — including which `issuers`
|
|
85
|
-
* an id token may claim — so the second registration refuses at boot instead.
|
|
86
|
-
*/
|
|
87
|
-
export const oauthProviderDuplicate = (provider: string): AuthError =>
|
|
88
|
-
new AuthError({
|
|
89
|
-
code: 'X_OAUTH_PROVIDER_DUPLICATE',
|
|
90
|
-
cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
|
|
91
|
-
fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
|
|
92
|
-
meta: { provider },
|
|
93
|
-
});
|
|
94
|
-
|
|
95
58
|
export interface OAuthExchangeFailure {
|
|
96
59
|
readonly provider: string;
|
|
97
60
|
/**
|
|
@@ -125,49 +88,6 @@ export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
|
|
|
125
88
|
},
|
|
126
89
|
});
|
|
127
90
|
|
|
128
|
-
/**
|
|
129
|
-
* The address is proven to the provider, and an account that never proved it already holds it.
|
|
130
|
-
* Naming that is not account enumeration — this caller just demonstrated they own the address —
|
|
131
|
-
* and staying silent would leave them with a login that fails forever and no way out.
|
|
132
|
-
*
|
|
133
|
-
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
|
|
134
|
-
* key, and cannot redact an address that was already interpolated into a sentence.
|
|
135
|
-
*/
|
|
136
|
-
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
|
|
137
|
-
new AuthError({
|
|
138
|
-
code: 'X_UNAUTHENTICATED',
|
|
139
|
-
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
|
|
140
|
-
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
|
|
141
|
-
meta: { provider, email },
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* `link: 'never'` and a local account already holds the address. Same code and same disclosure
|
|
146
|
-
* rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
|
|
147
|
-
* so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
|
|
148
|
-
*/
|
|
149
|
-
export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
|
|
150
|
-
new AuthError({
|
|
151
|
-
code: 'X_UNAUTHENTICATED',
|
|
152
|
-
cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
|
|
153
|
-
fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
|
|
154
|
-
meta: { provider, email },
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
159
|
-
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
160
|
-
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
|
|
161
|
-
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
|
|
162
|
-
*/
|
|
163
|
-
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
|
|
164
|
-
new AuthError({
|
|
165
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
166
|
-
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
|
|
167
|
-
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
|
|
168
|
-
meta: { provider, userId },
|
|
169
|
-
});
|
|
170
|
-
|
|
171
91
|
/** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
|
|
172
92
|
export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
|
|
173
93
|
new AuthError({
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -124,6 +124,13 @@ const OPAQUE_BODY =
|
|
|
124
124
|
'the endpoint answered with a body that is not a coded OAuth error, and this leg does not quote ' +
|
|
125
125
|
'one back because its request carries the client secret';
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The one way a remote endpoint's own words reach a cause: cut, then rendered. Every branch that
|
|
129
|
+
* quotes the other end goes through it — a branch that skipped it published 5 KB verbatim.
|
|
130
|
+
*/
|
|
131
|
+
const remoteText = (text: string): string =>
|
|
132
|
+
renderCauseValue(text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text);
|
|
133
|
+
|
|
127
134
|
export async function providerDetail(
|
|
128
135
|
response: Response,
|
|
129
136
|
echo: DetailEcho = 'raw',
|
|
@@ -140,17 +147,13 @@ export async function providerDetail(
|
|
|
140
147
|
parsed['error_description'] ??
|
|
141
148
|
parsed['error'] ??
|
|
142
149
|
(echo === 'coded-only' ? undefined : parsed['message']);
|
|
143
|
-
if (typeof description === 'string' && description !== '')
|
|
144
|
-
return renderCauseValue(description);
|
|
145
|
-
}
|
|
150
|
+
if (typeof description === 'string' && description !== '') return remoteText(description);
|
|
146
151
|
}
|
|
147
152
|
} catch {
|
|
148
153
|
// Not JSON — fall through to the truncated raw text below.
|
|
149
154
|
}
|
|
150
155
|
if (echo === 'coded-only') return OPAQUE_BODY;
|
|
151
|
-
return
|
|
152
|
-
text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text,
|
|
153
|
-
);
|
|
156
|
+
return remoteText(text);
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
function fixForStatus(provider: OAuthProviderId, status: number): string {
|
|
@@ -257,7 +260,10 @@ async function postForm(
|
|
|
257
260
|
throw oauthExchangeFailed({
|
|
258
261
|
provider,
|
|
259
262
|
stage: 'token',
|
|
260
|
-
|
|
263
|
+
// Remote text on a 200, so it is cut and rendered exactly as a non-2xx body is.
|
|
264
|
+
detail: remoteText(
|
|
265
|
+
typeof description === 'string' && description !== '' ? description : error,
|
|
266
|
+
),
|
|
261
267
|
fix:
|
|
262
268
|
error === 'bad_verification_code' || error === 'invalid_grant'
|
|
263
269
|
? `${restartAt(provider)} — an authorization code is single-use and short-lived`
|
package/src/oauth-login.ts
CHANGED
|
@@ -7,15 +7,10 @@ import { ConfigInvalidError, logger, uuid } from '@ultimat3/core';
|
|
|
7
7
|
import type { AuthAccount, AuthUser } from './adapter';
|
|
8
8
|
import type { Auth, LoginResult } from './auth';
|
|
9
9
|
import { normaliseEmail } from './email';
|
|
10
|
-
import {
|
|
10
|
+
import { AuthError, authWriteFailed } from './errors';
|
|
11
|
+
import { mfaChallengeRequired } from './mfa-challenge';
|
|
11
12
|
import type { OAuthCallback, OAuthHandshake } from './oauth';
|
|
12
|
-
import {
|
|
13
|
-
emailVerifiedNotStored,
|
|
14
|
-
oauthAccountNotLinked,
|
|
15
|
-
oauthExchangeFailed,
|
|
16
|
-
oauthLinkingDisabled,
|
|
17
|
-
restartAt,
|
|
18
|
-
} from './oauth-errors';
|
|
13
|
+
import { oauthExchangeFailed, restartAt } from './oauth-errors';
|
|
19
14
|
import {
|
|
20
15
|
exchangeOAuthCode,
|
|
21
16
|
type OAuthClientCredentials,
|
|
@@ -28,6 +23,52 @@ import { resolveActor } from './policy-bridge';
|
|
|
28
23
|
import { loginFailed } from './rate-limit';
|
|
29
24
|
import { createSession, sessionCookie } from './session';
|
|
30
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The address is proven to the provider, and an account that never proved it already holds it.
|
|
28
|
+
* Naming that is not account enumeration — this caller just demonstrated they own the address —
|
|
29
|
+
* and staying silent would leave them with a login that fails forever and no way out.
|
|
30
|
+
*
|
|
31
|
+
* The address itself rides in `meta`, never in `cause`: a log pipeline can redact a field by
|
|
32
|
+
* key, and cannot redact an address that was already interpolated into a sentence.
|
|
33
|
+
*/
|
|
34
|
+
export const oauthAccountNotLinked = (provider: string, email: string): AuthError =>
|
|
35
|
+
new AuthError({
|
|
36
|
+
code: 'X_UNAUTHENTICATED',
|
|
37
|
+
cause: `an account holds this ${provider} address but never verified it, so ${provider} may not claim it`,
|
|
38
|
+
fix: `sign in with that account's password and confirm the email-verify link, then retry ${provider}`,
|
|
39
|
+
meta: { provider, email },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `link: 'never'` and a local account already holds the address. Same code and same disclosure
|
|
44
|
+
* rule as `oauthAccountNotLinked` — the caller proved to the provider that the address is theirs,
|
|
45
|
+
* so naming the collision is not enumeration — and the address rides in `meta`, never in `cause`.
|
|
46
|
+
*/
|
|
47
|
+
export const oauthLinkingDisabled = (provider: string, email: string): AuthError =>
|
|
48
|
+
new AuthError({
|
|
49
|
+
code: 'X_UNAUTHENTICATED',
|
|
50
|
+
cause: `an account already holds this address and defineAuth({ link: 'never' }) forbids ${provider} from claiming it`,
|
|
51
|
+
fix: "sign in with that account's own credentials, or set link: 'verified-email' in defineAuth to let a provider-verified address claim a locally-verified account",
|
|
52
|
+
meta: { provider, email },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `CreateUserInput` carries no `emailVerifiedAt`, so a provider-verified address takes a second
|
|
57
|
+
* write. Falling back to the unstamped row would mint a session for a user every later login
|
|
58
|
+
* reads as unverified — the exact state `resolveUser` refuses to link a provider to — so the
|
|
59
|
+
* flow fails closed on an adapter that loses the stamp instead of half-succeeding.
|
|
60
|
+
*/
|
|
61
|
+
export const emailVerifiedNotStored = (provider: string, userId: string): AuthError =>
|
|
62
|
+
new AuthError({
|
|
63
|
+
// `X_AUTH_WRITE_FAILED`, the code for "an adapter write returned no row". It was
|
|
64
|
+
// `X_NOT_IMPLEMENTED` — a 501 telling an operator a feature is missing, about an adapter that
|
|
65
|
+
// implements `updateUser` and lost a write it accepted.
|
|
66
|
+
code: 'X_AUTH_WRITE_FAILED',
|
|
67
|
+
cause: `the adapter returned no row for new user ${userId}, so the ${provider}-verified address was never stamped verified`,
|
|
68
|
+
fix: 'return the updated row from AuthAdapter.updateUser — MemoryAdapter.updateUser is the reference implementation',
|
|
69
|
+
meta: { provider, userId },
|
|
70
|
+
});
|
|
71
|
+
|
|
31
72
|
/**
|
|
32
73
|
* What the IdP's answer entitles this identity to, in the app's own vocabulary. Which group maps
|
|
33
74
|
* to which role is business convention and never ships (axiom 8) — this is the seam it arrives
|
|
@@ -244,7 +285,7 @@ export async function signInWithOAuth(auth: Auth, input: OAuthSignInInput): Prom
|
|
|
244
285
|
linked === null ? account : { ...account, id: linked.id, createdAt: linked.createdAt },
|
|
245
286
|
);
|
|
246
287
|
|
|
247
|
-
if (user.mfaSecret !== null) throw
|
|
288
|
+
if (user.mfaSecret !== null) throw await mfaChallengeRequired(auth, user.id);
|
|
248
289
|
|
|
249
290
|
const issued = await createSession(auth.sessions, {
|
|
250
291
|
userId: user.id,
|