@ultimat3/auth 23.0.0 → 25.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +95 -46
- package/README.md +245 -93
- package/package.json +5 -5
- package/src/adapter.ts +49 -18
- package/src/api-key-resolver.ts +14 -9
- package/src/api-keys.ts +61 -14
- package/src/auth.ts +56 -53
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +77 -9
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/email.ts +1 -1
- package/src/errors.ts +80 -48
- package/src/guards.ts +2 -2
- package/src/id-token.ts +2 -3
- package/src/index.ts +57 -30
- package/src/jwks.ts +18 -7
- package/src/kdf-gate.ts +6 -6
- package/src/limiter-install.ts +1 -1
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +73 -7
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +14 -28
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +17 -7
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-grant-context.ts +106 -0
- package/src/oauth-login.ts +78 -23
- package/src/oauth-profile.ts +48 -11
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/oauth.ts +4 -3
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +46 -18
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +50 -16
- package/src/revocation.ts +25 -25
- package/src/session.ts +69 -37
- package/src/tables.ts +8 -10
- package/src/tokens.ts +2 -5
- package/src/verify.ts +4 -3
package/src/builtin-adapter.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// drive it without a database. Rows arrive as `unknown` and are read through the small typed
|
|
4
4
|
// readers below — no `any`, and a column rename fails loudly instead of producing `undefined`.
|
|
5
5
|
|
|
6
|
+
import { type Clock, systemClock } from '@ultimat3/core';
|
|
6
7
|
import { type DbClient, db, sql } from '@ultimat3/db';
|
|
7
8
|
import type {
|
|
8
9
|
AuthAccount,
|
|
@@ -13,9 +14,11 @@ import type {
|
|
|
13
14
|
AuthVerification,
|
|
14
15
|
CreateUserInput,
|
|
15
16
|
SessionPatch,
|
|
17
|
+
StoredMfaSecret,
|
|
16
18
|
UserPatch,
|
|
17
19
|
UserQuery,
|
|
18
20
|
} from './adapter';
|
|
21
|
+
import { usersUniqueViolation } from './builtin-adapter-violation';
|
|
19
22
|
import { authWriteFailed } from './errors';
|
|
20
23
|
|
|
21
24
|
type Row = Readonly<Record<string, unknown>>;
|
|
@@ -114,12 +117,27 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
|
|
|
114
117
|
* assembled into dynamic SQL: `null` stays a meaningful value ("clear this field") and the
|
|
115
118
|
* statement text remains constant, so the query plan is cached and nothing is interpolated.
|
|
116
119
|
*/
|
|
117
|
-
export class
|
|
120
|
+
export class PostgresAuthAdapter implements AuthAdapter {
|
|
118
121
|
readonly name = 'builtin-postgres';
|
|
119
122
|
readonly #db: DbClient;
|
|
123
|
+
readonly #clock: Clock;
|
|
120
124
|
|
|
121
|
-
|
|
125
|
+
/**
|
|
126
|
+
* `clock` is the one `MemoryAuthAdapter` takes, for the same stamp: hand over the clock
|
|
127
|
+
* `defineAuth` was given, so a redemption is dated by the clock that judged its expiry.
|
|
128
|
+
*/
|
|
129
|
+
constructor(client: DbClient = db(), clock: Clock = systemClock) {
|
|
122
130
|
this.#db = client;
|
|
131
|
+
this.#clock = clock;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A write to `x_users`, with the table's own unique violations answered as this package's. */
|
|
135
|
+
async #writeUser(operation: string, statement: Parameters<DbClient['one']>[0]) {
|
|
136
|
+
try {
|
|
137
|
+
return await this.#db.one<Row>(statement);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
throw usersUniqueViolation(operation, error) ?? error;
|
|
140
|
+
}
|
|
123
141
|
}
|
|
124
142
|
|
|
125
143
|
async findUserByEmail(email: string): Promise<AuthUser | null> {
|
|
@@ -133,13 +151,16 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
136
|
-
const row = await this.#
|
|
154
|
+
const row = await this.#writeUser(
|
|
155
|
+
'createUser',
|
|
156
|
+
sql`
|
|
137
157
|
insert into x_users (id, email, password_hash, org_id, roles, scopes, external_id,
|
|
138
158
|
created_at)
|
|
139
159
|
values (${input.id}, ${input.email}, ${input.passwordHash}, ${input.orgId},
|
|
140
160
|
${[...input.roles]}, ${[...(input.scopes ?? [])]}, ${input.externalId ?? null},
|
|
141
161
|
${input.createdAt})
|
|
142
|
-
returning
|
|
162
|
+
returning *`,
|
|
163
|
+
);
|
|
143
164
|
// An empty `returning` means no row landed. A user fabricated from `{}` would travel back
|
|
144
165
|
// out of `register()` as a successful registration with no identity in it.
|
|
145
166
|
if (row === null) throw authWriteFailed('createUser', 'x_users');
|
|
@@ -147,7 +168,9 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
async updateUser(id: string, patch: UserPatch): Promise<AuthUser | null> {
|
|
150
|
-
const row = await this.#
|
|
171
|
+
const row = await this.#writeUser(
|
|
172
|
+
'updateUser',
|
|
173
|
+
sql`
|
|
151
174
|
update x_users set
|
|
152
175
|
password_hash = case when ${patch.passwordHash !== undefined}
|
|
153
176
|
then ${patch.passwordHash ?? null} else password_hash end,
|
|
@@ -170,10 +193,39 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
170
193
|
external_id = case when ${patch.externalId !== undefined}
|
|
171
194
|
then ${patch.externalId ?? null} else external_id end
|
|
172
195
|
where id = ${id}
|
|
173
|
-
returning
|
|
196
|
+
returning *`,
|
|
197
|
+
);
|
|
174
198
|
return row === null ? null : toUser(row);
|
|
175
199
|
}
|
|
176
200
|
|
|
201
|
+
async listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]> {
|
|
202
|
+
const rows = await this.#db.query<Row>(
|
|
203
|
+
sql`select id, mfa_secret from x_users where mfa_secret is not null order by id`,
|
|
204
|
+
);
|
|
205
|
+
return rows.map((row) => ({ userId: text(row, 'id'), mfaSecret: text(row, 'mfa_secret') }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean> {
|
|
209
|
+
const row = await this.#db.one<Row>(sql`
|
|
210
|
+
update x_users set mfa_secret = ${next}
|
|
211
|
+
where id = ${userId} and mfa_secret = ${expected}
|
|
212
|
+
returning id`);
|
|
213
|
+
return row !== null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One conditional UPDATE: the row is locked, and the `where` is re-checked against the version
|
|
218
|
+
* a concurrent redemption just committed, so the second of two finds the hash already gone.
|
|
219
|
+
*/
|
|
220
|
+
async consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean> {
|
|
221
|
+
const row = await this.#db.one<Row>(sql`
|
|
222
|
+
update x_users
|
|
223
|
+
set recovery_code_hashes = array_remove(recovery_code_hashes, ${codeHash})
|
|
224
|
+
where id = ${userId} and ${codeHash} = any(recovery_code_hashes)
|
|
225
|
+
returning id`);
|
|
226
|
+
return row !== null;
|
|
227
|
+
}
|
|
228
|
+
|
|
177
229
|
async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
|
|
178
230
|
const row = await this.#db.one<Row>(
|
|
179
231
|
sql`select * from x_users where external_id = ${externalId}`,
|
|
@@ -185,7 +237,9 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
185
237
|
* One statement, and the two filters are conditional predicates rather than assembled SQL, for
|
|
186
238
|
* the reason `updateUser`'s columns are: the statement text stays constant and nothing is
|
|
187
239
|
* interpolated. Disabled members are excluded by default — an access review reads who can sign
|
|
188
|
-
* in today — and `includeDisabled` is what an offboarding audit passes.
|
|
240
|
+
* in today — and `includeDisabled` is what an offboarding audit passes. Ordered `collate "C"`
|
|
241
|
+
* (byte order, which `MemoryAuthAdapter` restates): the database default collation is the
|
|
242
|
+
* cluster's locale, so the order changed with the deployment and disagreed with memory's.
|
|
189
243
|
*/
|
|
190
244
|
async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
|
|
191
245
|
const role = query?.role ?? null;
|
|
@@ -194,7 +248,7 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
194
248
|
where org_id = ${orgId}
|
|
195
249
|
and (${query?.includeDisabled === true} or disabled_at is null)
|
|
196
250
|
and (${role === null} or ${role} = any (roles))
|
|
197
|
-
order by email asc`);
|
|
251
|
+
order by email collate "C" asc`);
|
|
198
252
|
return rows.map(toUser);
|
|
199
253
|
}
|
|
200
254
|
|
|
@@ -308,6 +362,9 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
308
362
|
}
|
|
309
363
|
|
|
310
364
|
/**
|
|
365
|
+
* `consumed_at` is the injected clock's instant, bound — never the server's `now()`, which made
|
|
366
|
+
* the stamp the one instant in this package no test could move and no `Clock` agreed with.
|
|
367
|
+
*
|
|
311
368
|
* One conditional UPDATE, and every part of the predicate is load-bearing. `consumed_at is null`
|
|
312
369
|
* on the UPDATE itself is what makes redemption single-use under concurrency — inside the
|
|
313
370
|
* subselect alone, two racing redemptions both pick the row and both consume it. `token_hash`
|
|
@@ -324,7 +381,7 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
324
381
|
tokenHash: string,
|
|
325
382
|
): Promise<AuthVerification | null> {
|
|
326
383
|
const row = await this.#db.one<Row>(sql`
|
|
327
|
-
update x_verifications set consumed_at = now()
|
|
384
|
+
update x_verifications set consumed_at = ${this.#clock.now()}
|
|
328
385
|
where consumed_at is null and id = (
|
|
329
386
|
select id from x_verifications
|
|
330
387
|
where purpose = ${purpose} and identifier = ${identifier}
|
|
@@ -367,3 +424,14 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
367
424
|
return changed > 0;
|
|
368
425
|
}
|
|
369
426
|
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* The one way to build the Postgres adapter — the twin of `memoryAuthAdapter()`. The class is a
|
|
430
|
+
* type in the barrel only (`X_FACTORY_NAME_SPELLING`), so `new` is never a second spelling.
|
|
431
|
+
*/
|
|
432
|
+
export function postgresAuthAdapter(
|
|
433
|
+
client: DbClient = db(),
|
|
434
|
+
clock: Clock = systemClock,
|
|
435
|
+
): PostgresAuthAdapter {
|
|
436
|
+
return new PostgresAuthAdapter(client, clock);
|
|
437
|
+
}
|
package/src/client-scope.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Actor } from '@ultimat3/core';
|
|
7
7
|
import { logger } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { AuthError } from './errors';
|
|
9
9
|
|
|
10
10
|
/** 128 bits: a scope only has to differ between principals, never to authenticate anyone. */
|
|
11
11
|
const SCOPE_HEX_CHARS = 32;
|
|
@@ -46,7 +46,13 @@ function scopeKey(options: ClientScopeOptions): string {
|
|
|
46
46
|
// The same floor as the env path below: an explicit key is a caller's value, so a short one is
|
|
47
47
|
// refused rather than silently swapped for the process key.
|
|
48
48
|
if (options.secret.length < MIN_SECRET_LENGTH) {
|
|
49
|
-
|
|
49
|
+
// Never its length: how long the key handed over is narrows a guess at it.
|
|
50
|
+
throw new AuthError({
|
|
51
|
+
code: 'X_CONFIG_INVALID',
|
|
52
|
+
cause: `clientScopeOf({ secret }) was passed a key shorter than the ${String(MIN_SECRET_LENGTH)} characters required, so the scope id would be keyed by a guessable secret`,
|
|
53
|
+
fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
|
|
54
|
+
meta: { option: 'secret', minLength: MIN_SECRET_LENGTH },
|
|
55
|
+
});
|
|
50
56
|
}
|
|
51
57
|
return options.secret;
|
|
52
58
|
}
|
package/src/directory.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import type { AuthUser, UserQuery } from './adapter';
|
|
8
8
|
import type { Auth } from './auth';
|
|
9
|
-
import { authNotImplemented } from './errors';
|
|
10
9
|
|
|
11
10
|
/** Safe to render in an admin page, return from an MCP tool, or paste into a review ticket. */
|
|
12
11
|
export interface AuthUserSummary {
|
|
@@ -45,21 +44,13 @@ export function describeUser(user: AuthUser): AuthUserSummary {
|
|
|
45
44
|
};
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
const unsupported = (method: string, adapterName: string) =>
|
|
49
|
-
authNotImplemented(
|
|
50
|
-
`${adapterName}.${method}()`,
|
|
51
|
-
`implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
|
|
52
|
-
);
|
|
53
|
-
|
|
54
47
|
/** Every member of one org, as summaries. `query.role` narrows it to one role's holders. */
|
|
55
48
|
export async function listOrgUsers(
|
|
56
49
|
auth: Auth,
|
|
57
50
|
orgId: string,
|
|
58
51
|
query?: UserQuery,
|
|
59
52
|
): Promise<readonly AuthUserSummary[]> {
|
|
60
|
-
|
|
61
|
-
if (list === undefined) throw unsupported('listUsersByOrg', auth.adapter.name);
|
|
62
|
-
return (await list(orgId, query)).map(describeUser);
|
|
53
|
+
return (await auth.adapter.listUsersByOrg(orgId, query)).map(describeUser);
|
|
63
54
|
}
|
|
64
55
|
|
|
65
56
|
/**
|
|
@@ -71,7 +62,5 @@ export async function findUserByExternalId(
|
|
|
71
62
|
auth: Auth,
|
|
72
63
|
externalId: string,
|
|
73
64
|
): Promise<AuthUser | null> {
|
|
74
|
-
|
|
75
|
-
if (find === undefined) throw unsupported('findUserByExternalId', auth.adapter.name);
|
|
76
|
-
return await find(externalId);
|
|
65
|
+
return await auth.adapter.findUserByExternalId(externalId);
|
|
77
66
|
}
|
package/src/email.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single responsibility: the ONE normalisation an email address gets before anything uses it as an
|
|
2
2
|
// identity key — a user lookup, a user insert, or the lockout bucket that has to key the same way
|
|
3
3
|
// those two do. It lives above the `AuthAdapter` seam because an adapter that normalises hides a
|
|
4
|
-
// caller that forgot to: `
|
|
4
|
+
// caller that forgot to: `MemoryAuthAdapter` did and `PostgresAuthAdapter` did not, so "does this account
|
|
5
5
|
// exist" had two answers, one under `x dev` and another in production.
|
|
6
6
|
|
|
7
7
|
/**
|
package/src/errors.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const AUTH_OWNED_ERROR_CODES = [
|
|
|
29
29
|
'X_AUTH_WRITE_FAILED',
|
|
30
30
|
'X_AUTH_LIMITER_NOT_SHARED',
|
|
31
31
|
'X_AUTH_LIMITER_POLICY_MISMATCH',
|
|
32
|
+
'X_MFA_SECRET_UNSEALED',
|
|
32
33
|
] as const;
|
|
33
34
|
|
|
34
35
|
/**
|
|
@@ -74,6 +75,7 @@ export const AUTH_ERROR_TITLES: Readonly<Record<AuthOwnedErrorCode, string>> = {
|
|
|
74
75
|
X_AUTH_WRITE_FAILED: 'an adapter write returned no row, so it cannot be confirmed',
|
|
75
76
|
X_AUTH_LIMITER_NOT_SHARED: 'the lockout is declared fleet-wide and the limiter is per-process',
|
|
76
77
|
X_AUTH_LIMITER_POLICY_MISMATCH: 'the limiter in use enforces other numbers than the app declared',
|
|
78
|
+
X_MFA_SECRET_UNSEALED: 'a stored totp secret is not sealed, so it is never read as one',
|
|
77
79
|
};
|
|
78
80
|
|
|
79
81
|
// Registered unconditionally, in one call: a second package claiming a code auth owns has to fail
|
|
@@ -115,14 +117,14 @@ export class AuthError extends UltimateError {
|
|
|
115
117
|
* `authenticate()` returns the anonymous actor for a missing cookie rather than throwing, which
|
|
116
118
|
* is correct — and is exactly why the failure surfaces here, one layer later, instead of there.
|
|
117
119
|
*/
|
|
118
|
-
export const
|
|
120
|
+
export const authUnauthenticated = (surface: string): AuthError =>
|
|
119
121
|
new AuthError({
|
|
120
122
|
code: 'X_UNAUTHENTICATED',
|
|
121
123
|
cause: `${surface} needs an actor but ctx.actor is anonymous`,
|
|
122
124
|
fix: 'send the __Host-x_session cookie with this request, and resolve it once at the boundary: authenticate(auth, readSessionCookie(request, auth.sessions.policy))',
|
|
123
125
|
});
|
|
124
126
|
|
|
125
|
-
export const
|
|
127
|
+
export const authForbidden = (surface: string, reason: string): AuthError =>
|
|
126
128
|
new AuthError({
|
|
127
129
|
code: 'X_FORBIDDEN',
|
|
128
130
|
cause: `${surface} denied: ${reason}`,
|
|
@@ -162,23 +164,41 @@ export const kdfOverloaded = (active: number, queued: number): AuthError =>
|
|
|
162
164
|
});
|
|
163
165
|
|
|
164
166
|
/**
|
|
165
|
-
* The second leg is
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* route would be unauthenticated by construction and MFA would become the ONLY factor. The design
|
|
170
|
-
* constraint for the real second leg is in `packages/auth/CLAUDE.md`.
|
|
167
|
+
* The second leg is `completeMfa(auth, challenge, code)` (`mfa-challenge.ts`), and the only thing
|
|
168
|
+
* that correlates it with a passed first leg is `meta.challenge`: a sealed, short-lived value the
|
|
169
|
+
* sign-in handler carries to the caller and back. It replaces a bare user id, which made any
|
|
170
|
+
* second leg built on it unauthenticated by construction.
|
|
171
171
|
*
|
|
172
|
-
*
|
|
173
|
-
* (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause`
|
|
174
|
-
* `meta`,
|
|
172
|
+
* The challenge is `meta`, never `cause`: both surfaces that render this code to an anonymous
|
|
173
|
+
* caller (`oauth-route.ts`'s `publicBody`, `@ultimat3/http`'s problem document) publish `cause`
|
|
174
|
+
* and drop `meta`, so the handler decides where it travels — a cookie or its own response body.
|
|
175
175
|
*/
|
|
176
|
-
export const mfaRequired = (
|
|
176
|
+
export const mfaRequired = (challenge: string): AuthError =>
|
|
177
177
|
new AuthError({
|
|
178
178
|
code: 'X_MFA_REQUIRED',
|
|
179
179
|
cause: 'this account has TOTP enrolled and the second factor has not been satisfied',
|
|
180
|
-
fix: '
|
|
181
|
-
meta: {
|
|
180
|
+
fix: 'catch X_MFA_REQUIRED in your sign-in handler, hand error.meta.challenge to the caller, and finish with: await completeMfa(auth, challenge, code)',
|
|
181
|
+
meta: { challenge },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A challenge `completeMfa` will not act on: not one this app sealed, past its expiry, or naming
|
|
186
|
+
* an account that can no longer finish (deleted, disabled, or no longer enrolled). One refusal
|
|
187
|
+
* for all of them — which it was is not the caller's to learn.
|
|
188
|
+
*/
|
|
189
|
+
export const mfaChallengeInvalid = (): AuthError =>
|
|
190
|
+
new AuthError({
|
|
191
|
+
code: 'X_UNAUTHENTICATED',
|
|
192
|
+
cause: 'the second-factor challenge is not one this app issued, or it is no longer usable',
|
|
193
|
+
fix: 'sign in again for a fresh challenge: await login(auth, { email, password })',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/** A wrong or already-spent code. The attempt it cost stays counted against the account. */
|
|
197
|
+
export const mfaCodeRejected = (): AuthError =>
|
|
198
|
+
new AuthError({
|
|
199
|
+
code: 'X_UNAUTHENTICATED',
|
|
200
|
+
cause: 'the second-factor code did not match, or it was already used',
|
|
201
|
+
fix: 'read the current code from the authenticator app and retry: await completeMfa(auth, challenge, code)',
|
|
182
202
|
});
|
|
183
203
|
|
|
184
204
|
/**
|
|
@@ -201,6 +221,19 @@ export const mfaSecretInvalid = (surface: string): AuthError =>
|
|
|
201
221
|
fix: 'issue a fresh one and store it: const { secret } = enrolTotp(auth, { account: user.email }) — an imported secret must be base32 (A-Z and 2-7, padding, spaces and dashes ignored) and decode to at least one byte',
|
|
202
222
|
});
|
|
203
223
|
|
|
224
|
+
/**
|
|
225
|
+
* A stored secret that is not sealed. It is refused rather than read: the plaintext path is the
|
|
226
|
+
* thing sealing removes, and a reader that fell back to it would keep it alive for good. The fix
|
|
227
|
+
* is the one-shot that seals every such row; `x doctor` reports the count before a user does.
|
|
228
|
+
*/
|
|
229
|
+
export const mfaSecretUnsealed = (): AuthError =>
|
|
230
|
+
new AuthError({
|
|
231
|
+
code: 'X_MFA_SECRET_UNSEALED',
|
|
232
|
+
cause:
|
|
233
|
+
'x_users.mfa_secret holds a value that is not sealed — written before second-factor secrets were sealed at rest, or written past saveTotpSecret — and a plaintext secret is never read as one',
|
|
234
|
+
fix: 'x auth seal-mfa --json',
|
|
235
|
+
});
|
|
236
|
+
|
|
204
237
|
/**
|
|
205
238
|
* `defineAuth({ mfa: { required: true } })`, refused where it is declared. The option read as a
|
|
206
239
|
* security guarantee — "this deployment requires a second factor" — and nothing in this package
|
|
@@ -246,23 +279,10 @@ export const authPolicyNumberInvalid = (
|
|
|
246
279
|
meta: { option: key, value: String(value) },
|
|
247
280
|
});
|
|
248
281
|
|
|
249
|
-
/**
|
|
250
|
-
* An explicit key for `clientScopeOf` under the floor `SESSION_SECRET` is held to. The env path
|
|
251
|
-
* already refused a short secret; the option skipped the screen, so `{ secret: 'x' }` keyed every
|
|
252
|
-
* page's scope id with one character. `X_CONFIG_INVALID`, borrowed like the two above.
|
|
253
|
-
*/
|
|
254
|
-
export const clientScopeSecretShort = (length: number, minLength: number): AuthError =>
|
|
255
|
-
new AuthError({
|
|
256
|
-
code: 'X_CONFIG_INVALID',
|
|
257
|
-
cause: `clientScopeOf({ secret }) was passed a key of ${String(length)} characters and at least ${String(minLength)} are required, so the scope id would be keyed by a guessable secret`,
|
|
258
|
-
fix: 'omit secret so clientScopeOf reads SESSION_SECRET, or pass one generated with: openssl rand -hex 32',
|
|
259
|
-
meta: { option: 'secret', minLength },
|
|
260
|
-
});
|
|
261
|
-
|
|
262
282
|
/**
|
|
263
283
|
* An api key `env` the parser cannot read back. `parseApiKey` splits `ult_<env>_<id>_<secret>` on
|
|
264
284
|
* `_`, so `issueApiKey({ env: 'live_eu' })` minted a key its own verifier refused. Refused at
|
|
265
|
-
* issue — `X_CONFIG_INVALID`, borrowed like the
|
|
285
|
+
* issue — `X_CONFIG_INVALID`, borrowed like the two above — never at first use.
|
|
266
286
|
*/
|
|
267
287
|
export const apiKeyEnvInvalid = (env: string): AuthError =>
|
|
268
288
|
new AuthError({
|
|
@@ -274,6 +294,19 @@ export const apiKeyEnvInvalid = (env: string): AuthError =>
|
|
|
274
294
|
meta: { option: 'env' },
|
|
275
295
|
});
|
|
276
296
|
|
|
297
|
+
/**
|
|
298
|
+
* A wildcard scope on an api key. `*` and `<resource>:*` are how a ROLE is granted everything; on
|
|
299
|
+
* a key they are a credential that silently grows with every permission the app adds later.
|
|
300
|
+
* Refused at issue — `X_CONFIG_INVALID`, borrowed like the ones above.
|
|
301
|
+
*/
|
|
302
|
+
export const apiKeyScopeWildcard = (scope: string): AuthError =>
|
|
303
|
+
new AuthError({
|
|
304
|
+
code: 'X_CONFIG_INVALID',
|
|
305
|
+
cause: `api key scope ${renderCauseValue(scope)} is a wildcard, and a key carries exactly the permissions it names`,
|
|
306
|
+
fix: "pass each permission by name to issueApiKey({ scopes }) — e.g. scopes: ['post:read', 'post:publish']",
|
|
307
|
+
meta: { option: 'scopes' },
|
|
308
|
+
});
|
|
309
|
+
|
|
277
310
|
export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
278
311
|
new AuthError({
|
|
279
312
|
code: 'X_PASSWORD_WEAK',
|
|
@@ -281,28 +314,35 @@ export const passwordWeak = (reasons: readonly string[]): AuthError =>
|
|
|
281
314
|
fix: 'choose a longer, uncommon password — or relax defineAuth({ password: { minLength } })',
|
|
282
315
|
});
|
|
283
316
|
|
|
317
|
+
/** The tenant bucket's key prefix. Declared here because the refusal below must recognise it. */
|
|
318
|
+
export const ORG_KEY_PREFIX = 'org:';
|
|
319
|
+
|
|
284
320
|
/**
|
|
285
321
|
* The escape is `recordSuccess(key)`, which is what a successful login already calls: it deletes
|
|
286
322
|
* the bucket, so it clears exactly this one key and nothing else. `reset()` exists too and is the
|
|
287
323
|
* wrong reach — it drops every bucket in the table, including the spray this lockout is holding.
|
|
288
324
|
*
|
|
289
|
-
* `
|
|
290
|
-
*
|
|
325
|
+
* An `ip:` or `account:` key is the caller's own input, so it is echoed (escaped). A tenant key is
|
|
326
|
+
* NOT: the org id was looked up from the address the caller typed, and naming it — or even saying
|
|
327
|
+
* "an organisation" — confirms to an anonymous caller that the address belongs to a member.
|
|
291
328
|
*/
|
|
292
|
-
export const accountLocked = (key: string, retryAfterSeconds: number): AuthError =>
|
|
293
|
-
|
|
329
|
+
export const accountLocked = (key: string, retryAfterSeconds: number): AuthError => {
|
|
330
|
+
const tenant = key.startsWith(ORG_KEY_PREFIX);
|
|
331
|
+
return new AuthError({
|
|
294
332
|
code: 'X_ACCOUNT_LOCKED',
|
|
295
|
-
// `renderCauseValue
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
333
|
+
// `renderCauseValue`: `ipKey(ip)` builds the key from whatever address string its caller
|
|
334
|
+
// passed, so a newline in it would write a second log line an operator reads as genuine.
|
|
335
|
+
cause: tenant
|
|
336
|
+
? `sign-in is locked out for another ${retryAfterSeconds}s after repeated failures`
|
|
337
|
+
: `${renderCauseValue(key)} is locked out for another ${retryAfterSeconds}s after repeated failures`,
|
|
338
|
+
fix: tenant
|
|
339
|
+
? `wait ${retryAfterSeconds}s — or clear the one bucket that is holding: await auth.orgLimiter.recordSuccess(orgKey(user.orgId)) — or raise defineAuth({ rateLimit })`
|
|
340
|
+
: `wait ${retryAfterSeconds}s — or clear this one bucket: await auth.limiter.recordSuccess(${renderFixLiteral(key, '<key>')}) — or raise defineAuth({ rateLimit })`,
|
|
302
341
|
// `kdfOverloaded`'s shape: `@ultimat3/http`'s `retryAfterOf` reads exactly this field. `key`
|
|
303
342
|
// stays out — `cause`/`fix` escape it on purpose and `meta` is read by surfaces that do not.
|
|
304
343
|
meta: { retryAfterSeconds },
|
|
305
344
|
});
|
|
345
|
+
};
|
|
306
346
|
|
|
307
347
|
/** One shape for every api-key rejection: unknown, revoked, expired and wrong all look alike. */
|
|
308
348
|
export const apiKeyInvalid = (): AuthError =>
|
|
@@ -332,7 +372,7 @@ export const authWriteFailed = (operation: string, table: string): AuthError =>
|
|
|
332
372
|
* the same failure to an operator — the row is not there and the caller must not assume it is —
|
|
333
373
|
* and the cause says which of the two happened.
|
|
334
374
|
*
|
|
335
|
-
* `
|
|
375
|
+
* `MemoryAuthAdapter` enforced neither `x_users.email` nor `x_users.external_id`, while `PostgresAuthAdapter`
|
|
336
376
|
* leans on both: two `register()` calls at one address made TWO rows in memory, and the second was
|
|
337
377
|
* unreachable forever because `findUserByEmail` returns the first. That adapter is what `x new`
|
|
338
378
|
* scaffolds and what every test runs against, so the duplicate path was only exercised against the
|
|
@@ -376,11 +416,3 @@ export const authLimiterPolicyMismatch = (
|
|
|
376
416
|
fix: `construct the limiter with ${field}: ${declared} — defineAuth({ rateLimit, limiter }) compares the two, and the declaration is what Auth.rateLimit reports`,
|
|
377
417
|
meta: { field, declared, enforced },
|
|
378
418
|
});
|
|
379
|
-
|
|
380
|
-
/** For a custom `AuthAdapter` that implements part of the seam. Nothing shipped throws it. */
|
|
381
|
-
export const authNotImplemented = (feature: string, fix: string): AuthError =>
|
|
382
|
-
new AuthError({
|
|
383
|
-
code: 'X_NOT_IMPLEMENTED',
|
|
384
|
-
cause: `${feature} is not implemented by the built-in driver`,
|
|
385
|
-
fix,
|
|
386
|
-
});
|
package/src/guards.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Actor } from '@ultimat3/core';
|
|
7
7
|
import { isAnonymous, useContext } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { authUnauthenticated } from './errors';
|
|
9
9
|
|
|
10
10
|
const DEFAULT_SURFACE = 'this request';
|
|
11
11
|
|
|
12
12
|
/** Throws `X_UNAUTHENTICATED` when the ambient actor is anonymous. */
|
|
13
13
|
export function requireActor(surface: string = DEFAULT_SURFACE): Actor {
|
|
14
14
|
const { actor } = useContext();
|
|
15
|
-
if (isAnonymous(actor)) throw
|
|
15
|
+
if (isAnonymous(actor)) throw authUnauthenticated(surface);
|
|
16
16
|
return actor;
|
|
17
17
|
}
|
|
18
18
|
|
package/src/id-token.ts
CHANGED
|
@@ -6,13 +6,12 @@
|
|
|
6
6
|
// default is what silently makes the second door as trusting as the first.
|
|
7
7
|
|
|
8
8
|
import type { Clock } from '@ultimat3/core';
|
|
9
|
-
import { renderCauseValue } from '@ultimat3/core';
|
|
9
|
+
import { renderCauseValue, timingSafeEqual } from '@ultimat3/core';
|
|
10
10
|
import { decodeJwtSegment } from './json';
|
|
11
11
|
import { type IdTokenKeys, verifyJwtSignature } from './jwks';
|
|
12
12
|
import type { OAuthProvider, OAuthProviderId } from './oauth';
|
|
13
13
|
import { oauthStateInvalid, oauthTokenInvalid, restartAt } from './oauth-errors';
|
|
14
14
|
import { providerFor } from './oauth-registry';
|
|
15
|
-
import { timingSafeEqual } from './tokens';
|
|
16
15
|
|
|
17
16
|
/** The subset of OIDC claims this package acts on. Provider-specific extras are ignored. */
|
|
18
17
|
export interface IdTokenClaims {
|
|
@@ -128,7 +127,7 @@ export interface VerifyIdTokenInput {
|
|
|
128
127
|
* Required, and there is deliberately no default. `'token-endpoint-tls'` asserts this token was
|
|
129
128
|
* read off a TLS response from the provider's own token endpoint — the OIDC Core 3.1.3.7 case,
|
|
130
129
|
* and the only one where an unverified JWT is safe. Every other channel passes a key source:
|
|
131
|
-
* `providerJwks(providerFor(id))`, or a `
|
|
130
|
+
* `providerJwks(providerFor(id))`, or a `jwksClient({ jwksUri })` of its own.
|
|
132
131
|
*/
|
|
133
132
|
readonly keys: IdTokenKeys;
|
|
134
133
|
}
|