@ultimat3/auth 8.0.0 → 10.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 +87 -5
- package/README.md +35 -5
- package/package.json +4 -4
- package/src/auth.ts +49 -11
- package/src/errors.ts +24 -172
- package/src/id-token.ts +36 -2
- package/src/index.ts +21 -12
- package/src/jwks.ts +17 -5
- package/src/limiter-install.ts +90 -0
- package/src/memory-adapter.ts +19 -0
- package/src/oauth-cookie.ts +1 -1
- package/src/oauth-discovery.ts +1 -1
- package/src/oauth-errors.ts +178 -0
- package/src/oauth-exchange.ts +58 -10
- package/src/oauth-login.ts +17 -6
- package/src/oauth-profile.ts +1 -1
- package/src/oauth-registry.ts +1 -1
- package/src/oauth-route.ts +91 -16
- package/src/oauth.ts +1 -1
- package/src/rate-limit.ts +7 -0
- package/src/tables.ts +29 -2
- package/src/verify.ts +17 -3
package/src/index.ts
CHANGED
|
@@ -57,7 +57,7 @@ export { describeUser, findUserByExternalId, listOrgUsers } from './directory';
|
|
|
57
57
|
// The one normalisation an address gets before it is an identity key. Public because an app
|
|
58
58
|
// writing its own `AuthAdapter`, or its own login route, has to key exactly the way this does.
|
|
59
59
|
export { normaliseEmail } from './email';
|
|
60
|
-
export type { AuthErrorCode, AuthThrowCode
|
|
60
|
+
export type { AuthErrorCode, AuthThrowCode } from './errors';
|
|
61
61
|
export {
|
|
62
62
|
AUTH_BORROWED_ERROR_CODES,
|
|
63
63
|
AUTH_ERROR_CODES,
|
|
@@ -68,28 +68,18 @@ export {
|
|
|
68
68
|
authLimiterNotShared,
|
|
69
69
|
authLimiterPolicyMismatch,
|
|
70
70
|
authNotImplemented,
|
|
71
|
+
authUniqueViolation,
|
|
71
72
|
authWriteFailed,
|
|
72
|
-
emailVerifiedNotStored,
|
|
73
73
|
forbidden,
|
|
74
74
|
kdfOverloaded,
|
|
75
75
|
mfaRequired,
|
|
76
76
|
mfaRequiredUnenforceable,
|
|
77
77
|
mfaSecretInvalid,
|
|
78
|
-
oauthAccountNotLinked,
|
|
79
|
-
oauthDenied,
|
|
80
|
-
oauthExchangeFailed,
|
|
81
|
-
oauthLinkingDisabled,
|
|
82
|
-
oauthProviderDuplicate,
|
|
83
|
-
oauthProviderUnknown,
|
|
84
|
-
oauthStateInvalid,
|
|
85
|
-
oauthTokenInvalid,
|
|
86
78
|
passwordWeak,
|
|
87
|
-
restartAt,
|
|
88
79
|
sessionExpired,
|
|
89
80
|
sessionUnknown,
|
|
90
81
|
unauthenticated,
|
|
91
82
|
} from './errors';
|
|
92
|
-
|
|
93
83
|
export { currentActor, requireActor } from './guards';
|
|
94
84
|
export type { IdTokenClaims, VerifyIdTokenInput } from './id-token';
|
|
95
85
|
export {
|
|
@@ -120,6 +110,10 @@ export {
|
|
|
120
110
|
kdfGate,
|
|
121
111
|
resetKdfGate,
|
|
122
112
|
} from './kdf-gate';
|
|
113
|
+
export type { AuthLimiterFactory } from './limiter-install';
|
|
114
|
+
// `installedAuthLimiter` is deliberately absent: `defineAuth` is the one reader, and a second
|
|
115
|
+
// caller building limiters out of band would be a second answer to where failures are counted.
|
|
116
|
+
export { configureAuthLimiters, purgeAuthLimits, resetAuthLimiters } from './limiter-install';
|
|
123
117
|
export { MemoryAdapter } from './memory-adapter';
|
|
124
118
|
export type {
|
|
125
119
|
EnrolTotpInput,
|
|
@@ -176,6 +170,21 @@ export {
|
|
|
176
170
|
} from './oauth-cookie';
|
|
177
171
|
export type { DiscoverOAuthProviderInput } from './oauth-discovery';
|
|
178
172
|
export { discoverOAuthProvider, discoveryUrl } from './oauth-discovery';
|
|
173
|
+
// The OAuth half of the same contract, split out of `errors.ts` at the 500-line ceiling. Every
|
|
174
|
+
// name below was exported from `./errors` before the split and is exported here after it.
|
|
175
|
+
export type { OAuthExchangeFailure } from './oauth-errors';
|
|
176
|
+
export {
|
|
177
|
+
emailVerifiedNotStored,
|
|
178
|
+
oauthAccountNotLinked,
|
|
179
|
+
oauthDenied,
|
|
180
|
+
oauthExchangeFailed,
|
|
181
|
+
oauthLinkingDisabled,
|
|
182
|
+
oauthProviderDuplicate,
|
|
183
|
+
oauthProviderUnknown,
|
|
184
|
+
oauthStateInvalid,
|
|
185
|
+
oauthTokenInvalid,
|
|
186
|
+
restartAt,
|
|
187
|
+
} from './oauth-errors';
|
|
179
188
|
export type {
|
|
180
189
|
OAuthClientCredentials,
|
|
181
190
|
OAuthExchangeOptions,
|
package/src/jwks.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import type { Clock } from '@ultimat3/core';
|
|
10
10
|
import { renderThrowable, systemClock } from '@ultimat3/core';
|
|
11
|
-
import { oauthExchangeFailed, oauthTokenInvalid } from './errors';
|
|
12
11
|
import { decodeJwtSegment, isRecord } from './json';
|
|
13
12
|
import type { OAuthProvider } from './oauth';
|
|
13
|
+
import { oauthExchangeFailed, oauthTokenInvalid } from './oauth-errors';
|
|
14
14
|
import type { OAuthFetch } from './oauth-exchange';
|
|
15
15
|
import { base64UrlBytes } from './tokens';
|
|
16
16
|
|
|
@@ -200,8 +200,19 @@ export function createJwksClient(options: JwksClientOptions): JwksKeySource {
|
|
|
200
200
|
const clients = new Map<string, JwksKeySource>();
|
|
201
201
|
|
|
202
202
|
/**
|
|
203
|
-
* The provider's own key set
|
|
204
|
-
* a client rebuilt per request refetches the key set per request.
|
|
203
|
+
* The provider's own key set. The DEFAULT client is built once per provider id — memoised because
|
|
204
|
+
* the cache is the point: a client rebuilt per request refetches the key set per request.
|
|
205
|
+
*
|
|
206
|
+
* A caller that SUPPLIES options gets a client built with them, and is not served the memo. The
|
|
207
|
+
* memo was keyed on the provider id alone, so the second caller's `fetch`, `clock`, `ttlMs` and
|
|
208
|
+
* `timeoutMs` were silently discarded: an app pinning a corporate egress proxy got it only if it
|
|
209
|
+
* happened to call first, and nothing said otherwise. That is the `jobs.driver` shape — a key read
|
|
210
|
+
* once and thereafter ignored — except the value quietly substituted here is a network path.
|
|
211
|
+
*
|
|
212
|
+
* Not cached by option identity, deliberately: `fetch` and `clock` are functions and objects, so
|
|
213
|
+
* any canonical key over them either collides (two different proxies, one entry) or never hits.
|
|
214
|
+
* A bespoke client is a bespoke client; `createJwksClient` is what it is, and its own cache still
|
|
215
|
+
* works for as long as the caller holds it.
|
|
205
216
|
*/
|
|
206
217
|
export function providerJwks(
|
|
207
218
|
provider: OAuthProvider,
|
|
@@ -214,10 +225,11 @@ export function providerJwks(
|
|
|
214
225
|
`register ${provider.id} with an explicit jwksUri, or read its id token only through exchangeOAuthCode()`,
|
|
215
226
|
);
|
|
216
227
|
}
|
|
217
|
-
const
|
|
228
|
+
const bespoke = options !== undefined && Object.keys(options).length > 0;
|
|
229
|
+
const existing = bespoke ? undefined : clients.get(provider.id);
|
|
218
230
|
if (existing !== undefined) return existing;
|
|
219
231
|
const client = createJwksClient({ ...options, provider: provider.id, jwksUri: provider.jwksUri });
|
|
220
|
-
clients.set(provider.id, client);
|
|
232
|
+
if (!bespoke) clients.set(provider.id, client);
|
|
221
233
|
return client;
|
|
222
234
|
}
|
|
223
235
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Single responsibility: the ONE ambient install point for where failed credential attempts are
|
|
2
|
+
// counted, plus the purge over whatever it built.
|
|
3
|
+
//
|
|
4
|
+
// WHY a seam and not a `defineAuth` argument: `defineAuth` is the APP's call, and the app is not
|
|
5
|
+
// the thing that knows which database this process opened. A host boot resolves the pool long
|
|
6
|
+
// before it imports app modules (`@ultimat3/cli`'s `startServices` runs before `loadApp`), so
|
|
7
|
+
// until this existed `postgresAuthLimiter` shipped with nowhere to be installed from — account
|
|
8
|
+
// lockouts stayed per-pod while the shipped chart runs three `web` replicas, and an attacker got
|
|
9
|
+
// N x the lockout budget by spreading a spray across them.
|
|
10
|
+
//
|
|
11
|
+
// WHY a FACTORY and not a limiter: `defineAuth` compares what a limiter reports against what the
|
|
12
|
+
// app declared (`assertAuthLimiterPolicy`), and the boot cannot know the app's `maxAttempts`,
|
|
13
|
+
// `windowMs` or `lockoutMs` — it has not imported the app yet. Handing over a built limiter would
|
|
14
|
+
// make every app that tunes its own numbers fail at boot with `X_AUTH_LIMITER_POLICY_MISMATCH`.
|
|
15
|
+
// The factory is called WITH the resolved policy, so the two halves cannot disagree.
|
|
16
|
+
|
|
17
|
+
import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a limiter enforcing exactly `policy`. Called once per bucket — the account/IP bucket and
|
|
21
|
+
* the tenant bucket are separate instances over one store, because they enforce different
|
|
22
|
+
* `maxAttempts` and their keys are prefix-disjoint (`account:` / `ip:` / `org:`).
|
|
23
|
+
*/
|
|
24
|
+
export type AuthLimiterFactory = (policy: AuthRateLimitPolicy) => AuthLimiter;
|
|
25
|
+
|
|
26
|
+
let factory: AuthLimiterFactory | undefined;
|
|
27
|
+
/** Every limiter this process built through the factory above, so a purge can reach them. */
|
|
28
|
+
let built: AuthLimiter[] = [];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The ONE install point, the same shape as `configureKdfGate` beside it: a host that owns the
|
|
32
|
+
* database connection says where failed attempts are counted, and every `defineAuth` in the
|
|
33
|
+
* process picks it up without the app declaring anything.
|
|
34
|
+
*
|
|
35
|
+
* A second install replaces the first and forgets what the first built — a limiter over a pool
|
|
36
|
+
* the previous boot has closed is not something a purge should still be sweeping through.
|
|
37
|
+
*/
|
|
38
|
+
export function configureAuthLimiters(next: AuthLimiterFactory): void {
|
|
39
|
+
factory = next;
|
|
40
|
+
built = [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Back to `createAuthLimiter`, the per-process default. A host that installs one calls this on stop. */
|
|
44
|
+
export function resetAuthLimiters(): void {
|
|
45
|
+
factory = undefined;
|
|
46
|
+
built = [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `defineAuth`'s reader, and deliberately NOT exported from `src/index.ts`: a second caller
|
|
51
|
+
* building limiters out of band would be a second answer to "where are failures counted", which
|
|
52
|
+
* is the ambiguity axiom 1 refuses. `undefined` means no host installed one, and the caller falls
|
|
53
|
+
* back to the in-memory limiter.
|
|
54
|
+
*/
|
|
55
|
+
export function installedAuthLimiter(policy: AuthRateLimitPolicy): AuthLimiter | undefined {
|
|
56
|
+
if (factory === undefined) return undefined;
|
|
57
|
+
const limiter = factory(policy);
|
|
58
|
+
built.push(limiter);
|
|
59
|
+
return limiter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A limiter that keeps rows somebody else has to delete. The memory limiter sweeps itself. */
|
|
63
|
+
type PurgingAuthLimiter = AuthLimiter & { purgeExpired(): Promise<number> };
|
|
64
|
+
|
|
65
|
+
const canPurge = (limiter: AuthLimiter): limiter is PurgingAuthLimiter =>
|
|
66
|
+
typeof limiter.purgeExpired === 'function';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drop every failure past the window and every expired lockout the installed limiters left
|
|
70
|
+
* behind, and answer how many rows went. `0` when nothing was installed, or when what was
|
|
71
|
+
* installed keeps no rows.
|
|
72
|
+
*
|
|
73
|
+
* The WIDEST window wins, and only that limiter is swept. Every limiter here writes to the same
|
|
74
|
+
* two tables, so sweeping the narrow one would delete failures the wide one is still counting —
|
|
75
|
+
* which is a sprayer buying attempts back from the cleanup job. The same defect the http store's
|
|
76
|
+
* `purgeExpired(nowMs)` exists to prevent, one level up.
|
|
77
|
+
*
|
|
78
|
+
* No `nowMs` argument, unlike `postgresRateLimitStore.purgeExpired`: a limiter built through this
|
|
79
|
+
* seam already holds the clock its host handed it, and that is the clock every `at_ms` in those
|
|
80
|
+
* tables was written from. A second clock at the call site is exactly the mismatch that reads a
|
|
81
|
+
* frozen test clock as a 20,000,000-second refill.
|
|
82
|
+
*/
|
|
83
|
+
export async function purgeAuthLimits(): Promise<number> {
|
|
84
|
+
let widest: PurgingAuthLimiter | undefined;
|
|
85
|
+
for (const limiter of built) {
|
|
86
|
+
if (!canPurge(limiter)) continue;
|
|
87
|
+
if (widest === undefined || limiter.policy.windowMs > widest.policy.windowMs) widest = limiter;
|
|
88
|
+
}
|
|
89
|
+
return widest === undefined ? 0 : await widest.purgeExpired();
|
|
90
|
+
}
|
package/src/memory-adapter.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
UserPatch,
|
|
15
15
|
UserQuery,
|
|
16
16
|
} from './adapter';
|
|
17
|
+
import { authUniqueViolation } from './errors';
|
|
17
18
|
import { timingSafeEqual } from './tokens';
|
|
18
19
|
|
|
19
20
|
const verificationKey = (purpose: string, identifier: string): string => `${purpose}:${identifier}`;
|
|
@@ -43,7 +44,25 @@ export class MemoryAdapter implements AuthAdapter {
|
|
|
43
44
|
return this.#users.get(id) ?? null;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The two UNIQUE constraints `x_users` declares, enforced here because `BuiltinAdapter` LEANS on
|
|
49
|
+
* them: `email text not null unique` and `external_id text unique` (`tables.ts`). Without them
|
|
50
|
+
* this adapter — the one `x new` scaffolds and every test runs against — accepted two rows at one
|
|
51
|
+
* address, and the second was unreachable forever, since `findUserByEmail` returns the first.
|
|
52
|
+
*
|
|
53
|
+
* Over the STORED string, exactly as Postgres compares it. No case folding: that is the
|
|
54
|
+
* divergence `adapter-parity.test.ts`'s first case pins, and `normaliseEmail` above the seam is
|
|
55
|
+
* what makes two spellings one address.
|
|
56
|
+
*/
|
|
46
57
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
58
|
+
for (const existing of this.#users.values()) {
|
|
59
|
+
if (existing.email === input.email) {
|
|
60
|
+
throw authUniqueViolation('createUser', 'x_users', 'email');
|
|
61
|
+
}
|
|
62
|
+
if (input.externalId !== undefined && existing.externalId === input.externalId) {
|
|
63
|
+
throw authUniqueViolation('createUser', 'x_users', 'external_id');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
47
66
|
const user: AuthUser = {
|
|
48
67
|
id: input.id,
|
|
49
68
|
// Stored as handed over, exactly as the `insert into x_users` binds it.
|
package/src/oauth-cookie.ts
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
9
|
import { EnvMissingError, systemClock } from '@ultimat3/core';
|
|
10
|
-
import { oauthStateInvalid } from './errors';
|
|
11
10
|
import type { OAuthHandshake, OAuthProviderId } from './oauth';
|
|
11
|
+
import { oauthStateInvalid } from './oauth-errors';
|
|
12
12
|
import { hasOAuthProvider } from './oauth-registry';
|
|
13
13
|
import { type RequestLike, readCookie } from './session';
|
|
14
14
|
import { base64Url, timingSafeEqual } from './tokens';
|
package/src/oauth-discovery.ts
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
// four endpoints that nobody re-checks when the vendor moves one.
|
|
5
5
|
|
|
6
6
|
import { renderThrowable } from '@ultimat3/core';
|
|
7
|
-
import { oauthExchangeFailed } from './errors';
|
|
8
7
|
import { isRecord } from './json';
|
|
9
8
|
import type { OAuthProvider } from './oauth';
|
|
9
|
+
import { oauthExchangeFailed } from './oauth-errors';
|
|
10
10
|
import type { OAuthFetch } from './oauth-exchange';
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Single responsibility: the OAuth half of this package's error factories — every refusal a
|
|
2
|
+
// provider handshake can produce, and the one phrase their fixes are built from.
|
|
3
|
+
// Split from `errors.ts` at the 500-line ceiling; the codes, the titles and the single
|
|
4
|
+
// `registerErrorCodes()` call stay there, so this file adds no code and registers nothing.
|
|
5
|
+
|
|
6
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
7
|
+
import { AuthError } from './errors';
|
|
8
|
+
import { oauthStartPath } from './oauth-paths';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The `fix:` quotes `oauthStartPath` rather than a hand-written path. That is not tidiness: this
|
|
12
|
+
* line shipped naming `GET /auth/oauth/<provider>` while `@ultimat3/auth` mounted no route at all,
|
|
13
|
+
* so every caller who followed it hit a 404. One declaration, read by the mount and by the fix,
|
|
14
|
+
* is what stops that recurring — `oauthLogin()` cannot move without moving this sentence.
|
|
15
|
+
*/
|
|
16
|
+
export const oauthStateInvalid = (provider: string, part: string): AuthError =>
|
|
17
|
+
new AuthError({
|
|
18
|
+
code: 'X_OAUTH_STATE_INVALID',
|
|
19
|
+
cause: `${provider} callback rejected: ${part}`,
|
|
20
|
+
fix: `${restartAt(provider)} — a callback URL is single-use`,
|
|
21
|
+
meta: { provider },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The one phrase every "start over" fix is built from, so none of them can name a dead route. */
|
|
25
|
+
export const restartAt = (provider: string): string =>
|
|
26
|
+
`restart the flow at GET ${oauthStartPath(provider)}`;
|
|
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
|
+
/**
|
|
53
|
+
* A URL segment naming a provider no `registerOAuthProvider` call has claimed, or one that is but
|
|
54
|
+
* was left out of `defineAuth({ providers })`. One refusal for both: which of the two it is
|
|
55
|
+
* describes the app's configuration to an unauthenticated caller, and the fix is the same sentence
|
|
56
|
+
* either way.
|
|
57
|
+
*
|
|
58
|
+
* **`supported` is the CALLER's to scope, because the two callers have two audiences.**
|
|
59
|
+
* `oauth-route.ts` passes `BUILTIN_OAUTH_PROVIDER_IDS` — its reader is an anonymous stranger who
|
|
60
|
+
* typed a URL, and the three built-ins are a framework constant already in the public docs, while
|
|
61
|
+
* the live registry holds whatever internal OP this deployment registered. `providerFor()` passes
|
|
62
|
+
* `oauthProviderIds()` — its reader is a developer holding a stack trace, and there the full list
|
|
63
|
+
* is exactly what makes the fix runnable. Neither ever passes `defineAuth({ providers })`: naming
|
|
64
|
+
* what this deployment turned on is the disclosure the shared refusal exists to prevent.
|
|
65
|
+
*
|
|
66
|
+
* The fix names `registerOAuthProvider` first so it stays executable for the branch the narrowed
|
|
67
|
+
* list cannot cover — a segment nothing registered cannot be added to `providers` at all, so
|
|
68
|
+
* "add it" alone was an instruction that could not be followed.
|
|
69
|
+
*
|
|
70
|
+
* The segment itself is a URL path the caller typed, so it goes through `renderCauseValue` in the
|
|
71
|
+
* sentence and `renderFixLiteral` in the command — a fix has to parse after a hostile value lands
|
|
72
|
+
* in it.
|
|
73
|
+
*/
|
|
74
|
+
export const oauthProviderUnknown = (provider: string, supported: readonly string[]): AuthError =>
|
|
75
|
+
new AuthError({
|
|
76
|
+
code: 'X_OAUTH_PROVIDER_UNKNOWN',
|
|
77
|
+
cause: `no oauth provider is mounted at ${renderCauseValue(oauthStartPath(provider))}`,
|
|
78
|
+
fix: `registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) if it is not built in, then add that id to defineAuth({ providers: [...] }) — known here: ${supported.map((id) => `'${id}'`).join(', ')}`,
|
|
79
|
+
meta: { provider },
|
|
80
|
+
});
|
|
81
|
+
|
|
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
|
+
export interface OAuthExchangeFailure {
|
|
96
|
+
readonly provider: string;
|
|
97
|
+
/**
|
|
98
|
+
* Which leg of the server-to-server conversation failed. `discovery` and `jwks` are the two
|
|
99
|
+
* boot/verification legs an enterprise OP adds: reading `/.well-known/openid-configuration`,
|
|
100
|
+
* and reading the key set an id token's signature is checked against.
|
|
101
|
+
*/
|
|
102
|
+
readonly stage: 'token' | 'userinfo' | 'discovery' | 'jwks';
|
|
103
|
+
readonly detail: string;
|
|
104
|
+
readonly status?: number | undefined;
|
|
105
|
+
readonly fix: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Deliberately specific, unlike every credential error above it. This one describes a
|
|
110
|
+
* conversation between two servers — naming the stage, the provider and its own status
|
|
111
|
+
* discloses nothing about any user, and is the difference between a fixable misconfiguration
|
|
112
|
+
* and a shrug.
|
|
113
|
+
*/
|
|
114
|
+
export const oauthExchangeFailed = (failure: OAuthExchangeFailure): AuthError =>
|
|
115
|
+
new AuthError({
|
|
116
|
+
code: 'X_OAUTH_EXCHANGE_FAILED',
|
|
117
|
+
cause:
|
|
118
|
+
`${failure.provider} ${failure.stage} request failed` +
|
|
119
|
+
`${failure.status === undefined ? '' : ` with HTTP ${failure.status}`}: ${failure.detail}`,
|
|
120
|
+
fix: failure.fix,
|
|
121
|
+
meta: {
|
|
122
|
+
provider: failure.provider,
|
|
123
|
+
stage: failure.stage,
|
|
124
|
+
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
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
|
+
/** The token arrived, and is not one this handshake can trust: wrong `iss`, `aud`, or expired. */
|
|
172
|
+
export const oauthTokenInvalid = (provider: string, reason: string, fix: string): AuthError =>
|
|
173
|
+
new AuthError({
|
|
174
|
+
code: 'X_OAUTH_TOKEN_INVALID',
|
|
175
|
+
cause: `${provider} id token rejected: ${reason}`,
|
|
176
|
+
fix,
|
|
177
|
+
meta: { provider },
|
|
178
|
+
});
|
package/src/oauth-exchange.ts
CHANGED
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
// token is verified here, so no caller downstream can forget to.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
EnvMissingError,
|
|
9
|
+
logger,
|
|
10
|
+
renderCauseValue,
|
|
11
|
+
renderThrowable,
|
|
12
|
+
systemClock,
|
|
13
|
+
} from '@ultimat3/core';
|
|
9
14
|
import { type IdTokenClaims, verifyIdToken } from './id-token';
|
|
10
15
|
import { isRecord } from './json';
|
|
11
16
|
import type { IdTokenKeys } from './jwks';
|
|
@@ -15,6 +20,7 @@ import {
|
|
|
15
20
|
type OAuthHandshake,
|
|
16
21
|
type OAuthProviderId,
|
|
17
22
|
} from './oauth';
|
|
23
|
+
import { oauthExchangeFailed, restartAt } from './oauth-errors';
|
|
18
24
|
import { providerFor } from './oauth-registry';
|
|
19
25
|
|
|
20
26
|
/** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */
|
|
@@ -91,7 +97,31 @@ export function oauthCredentials(
|
|
|
91
97
|
* mostly prose this package authored, and quoting that would make every readable message unread-
|
|
92
98
|
* able. The rule is "remote text is rendered", and this function is where remote text is made.
|
|
93
99
|
*/
|
|
94
|
-
|
|
100
|
+
/**
|
|
101
|
+
* How much of a body this leg may quote back.
|
|
102
|
+
*
|
|
103
|
+
* `'raw'` — the default and what userinfo, discovery and jwks use: those requests carry a bearer
|
|
104
|
+
* token or nothing, and the raw text is what makes a misconfigured enterprise OP debuggable.
|
|
105
|
+
*
|
|
106
|
+
* `'coded-only'` — the `POST /token` leg, and ONLY it. That request carries `client_secret`, and
|
|
107
|
+
* an endpoint that echoes what it was sent (compromised, impersonated, or a misbehaving gateway)
|
|
108
|
+
* put the secret into an `X_OAUTH_EXCHANGE_FAILED` `cause:` that `oauth-route.ts` publishes to an
|
|
109
|
+
* unauthenticated caller. Measured: 38 of 42 characters of the secret reached the response body,
|
|
110
|
+
* and a shorter `redirect_uri` fits all of it inside `MAX_DETAIL_LENGTH`. A coded OAuth error
|
|
111
|
+
* still comes through — `invalid_grant`, `redirect_uri_mismatch` — because that is a field the
|
|
112
|
+
* SPEC defines and the whole reason this string exists.
|
|
113
|
+
*/
|
|
114
|
+
export type DetailEcho = 'raw' | 'coded-only';
|
|
115
|
+
|
|
116
|
+
/** What a `coded-only` leg says instead of the body. Names why, so nobody re-adds the echo. */
|
|
117
|
+
const OPAQUE_BODY =
|
|
118
|
+
'the endpoint answered with a body that is not a coded OAuth error, and this leg does not quote ' +
|
|
119
|
+
'one back because its request carries the client secret';
|
|
120
|
+
|
|
121
|
+
export async function providerDetail(
|
|
122
|
+
response: Response,
|
|
123
|
+
echo: DetailEcho = 'raw',
|
|
124
|
+
): Promise<string> {
|
|
95
125
|
const text = await response.text().catch(() => '');
|
|
96
126
|
if (text === '') return 'the response body was empty';
|
|
97
127
|
try {
|
|
@@ -105,6 +135,7 @@ export async function providerDetail(response: Response): Promise<string> {
|
|
|
105
135
|
} catch {
|
|
106
136
|
// Not JSON — fall through to the truncated raw text below.
|
|
107
137
|
}
|
|
138
|
+
if (echo === 'coded-only') return OPAQUE_BODY;
|
|
108
139
|
return renderCauseValue(
|
|
109
140
|
text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text,
|
|
110
141
|
);
|
|
@@ -143,16 +174,31 @@ async function postForm(
|
|
|
143
174
|
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
144
175
|
});
|
|
145
176
|
} catch (error) {
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
177
|
+
// The rendered value goes to the LOG, never into the `detail:`.
|
|
178
|
+
//
|
|
179
|
+
// `oauth-route.ts` publishes this cause to an unauthenticated caller, and the rejection comes
|
|
180
|
+
// from an injected `OAuthFetch` — an app's own wrapper, a proxy client, an adapter behind it —
|
|
181
|
+
// so its text is a server's internals. Reproduced: a bare
|
|
182
|
+
// `Error('connect ECONNREFUSED 10.4.2.17:5432 (postgres://app:hunter2@db.internal/prod)')`
|
|
183
|
+
// was served as the 502 body of a public callback. `problem()`'s uncoded branch does NOT catch
|
|
184
|
+
// this one: it is coded by the time it gets there.
|
|
185
|
+
//
|
|
186
|
+
// `renderThrowable`, never `error.message` behind an `instanceof`: `instanceof` itself throws
|
|
187
|
+
// on a value whose `getPrototypeOf` trap does, losing the one refusal that tells a caller the
|
|
188
|
+
// code is already spent. What stays in the sentence is `url` and the remedy, both framework
|
|
189
|
+
// constants, so the failure is still fixable without quoting anything this package does not own.
|
|
190
|
+
logger.error('auth.oauth.token_fetch_failed', {
|
|
191
|
+
provider,
|
|
192
|
+
url,
|
|
193
|
+
detail: renderThrowable(error),
|
|
194
|
+
});
|
|
150
195
|
throw oauthExchangeFailed({
|
|
151
196
|
provider,
|
|
152
197
|
stage: 'token',
|
|
153
198
|
detail:
|
|
154
|
-
|
|
155
|
-
'flow once it does, since the code
|
|
199
|
+
`nothing left this host for ${url} (egress, DNS or TLS); the reason is in this process ` +
|
|
200
|
+
'log under auth.oauth.token_fetch_failed. Restart the flow once it does, since the code ' +
|
|
201
|
+
'is already spent',
|
|
156
202
|
fix: `curl -sS -m 5 -o /dev/null ${url}`,
|
|
157
203
|
});
|
|
158
204
|
}
|
|
@@ -161,7 +207,9 @@ async function postForm(
|
|
|
161
207
|
throw oauthExchangeFailed({
|
|
162
208
|
provider,
|
|
163
209
|
stage: 'token',
|
|
164
|
-
|
|
210
|
+
// `coded-only`: this request carried `client_secret`, and `oauth-route.ts` publishes this
|
|
211
|
+
// cause to whoever typed the URL.
|
|
212
|
+
detail: await providerDetail(response, 'coded-only'),
|
|
165
213
|
status: response.status,
|
|
166
214
|
fix: fixForStatus(provider, response.status),
|
|
167
215
|
});
|
package/src/oauth-login.ts
CHANGED
|
@@ -7,16 +7,15 @@ 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 { authWriteFailed, mfaRequired } from './errors';
|
|
11
|
+
import type { OAuthCallback, OAuthHandshake } from './oauth';
|
|
10
12
|
import {
|
|
11
|
-
authWriteFailed,
|
|
12
13
|
emailVerifiedNotStored,
|
|
13
|
-
mfaRequired,
|
|
14
14
|
oauthAccountNotLinked,
|
|
15
15
|
oauthExchangeFailed,
|
|
16
16
|
oauthLinkingDisabled,
|
|
17
17
|
restartAt,
|
|
18
|
-
} from './errors';
|
|
19
|
-
import type { OAuthCallback, OAuthHandshake } from './oauth';
|
|
18
|
+
} from './oauth-errors';
|
|
20
19
|
import {
|
|
21
20
|
exchangeOAuthCode,
|
|
22
21
|
type OAuthClientCredentials,
|
|
@@ -200,8 +199,20 @@ function accountFor(auth: Auth, user: AuthUser, input: OAuthSignInInput): AuthAc
|
|
|
200
199
|
userId: user.id,
|
|
201
200
|
provider: input.profile.provider,
|
|
202
201
|
providerAccountId: input.profile.providerAccountId,
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
// `null`, deliberately — this package does not persist a provider credential.
|
|
203
|
+
//
|
|
204
|
+
// `x_accounts.access_token` and `refresh_token` held live provider tokens in the CLEAR, under
|
|
205
|
+
// a `tables.ts` header promising "no column holds a plaintext secret", and NOTHING read either
|
|
206
|
+
// one back: `oauth-profile.ts` uses the token from the exchange, in flight, and
|
|
207
|
+
// `packages/auth/CLAUDE.md` states that refresh is not implemented. Declared and never wired
|
|
208
|
+
// — the same deletion this repo already ran for `jobs.driver`, except this one made a database
|
|
209
|
+
// dump into a set of usable third-party credentials.
|
|
210
|
+
//
|
|
211
|
+
// The TYPE keeps both fields: `AuthAccount` is the documented adapter seam, and an app that
|
|
212
|
+
// deliberately stores tokens implements `linkAccount` itself. What changed is what the
|
|
213
|
+
// framework writes.
|
|
214
|
+
accessToken: null,
|
|
215
|
+
refreshToken: null,
|
|
205
216
|
expiresAt: input.tokens.expiresAt,
|
|
206
217
|
createdAt: auth.clock.now(),
|
|
207
218
|
};
|
package/src/oauth-profile.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
// decides whether this login may attach itself to an existing account by address.
|
|
5
5
|
|
|
6
6
|
import { logger, renderThrowable } from '@ultimat3/core';
|
|
7
|
-
import { oauthExchangeFailed, restartAt } from './errors';
|
|
8
7
|
import { idTokenEmailVerified, isVerifiedFlag } from './id-token';
|
|
9
8
|
import { isRecord } from './json';
|
|
10
9
|
import type { OAuthProviderId } from './oauth';
|
|
10
|
+
import { oauthExchangeFailed, restartAt } from './oauth-errors';
|
|
11
11
|
import {
|
|
12
12
|
OAUTH_USER_AGENT,
|
|
13
13
|
type OAuthFetch,
|
package/src/oauth-registry.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// account linking with it. The three built-ins register through the same call an app uses, so the
|
|
6
6
|
// opening does not create a second path.
|
|
7
7
|
|
|
8
|
-
import { oauthProviderDuplicate, oauthProviderUnknown } from './errors';
|
|
9
8
|
import type { OAuthProvider } from './oauth';
|
|
10
9
|
import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
|
|
10
|
+
import { oauthProviderDuplicate, oauthProviderUnknown } from './oauth-errors';
|
|
11
11
|
|
|
12
12
|
const registry = new Map<string, OAuthProvider>();
|
|
13
13
|
|