@ultimat3/auth 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +79 -36
- package/README.md +178 -52
- package/package.json +5 -4
- package/src/adapter.ts +43 -16
- package/src/api-key-resolver.ts +55 -0
- package/src/api-keys.ts +59 -12
- package/src/auth.ts +48 -45
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +61 -6
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/errors.ts +77 -45
- package/src/index.ts +48 -20
- package/src/jwks.ts +11 -0
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +41 -0
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +9 -23
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +8 -4
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-login.ts +50 -9
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +28 -16
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +47 -13
- package/src/revocation.ts +25 -25
- package/src/session.ts +4 -30
- package/src/tables.ts +7 -9
- package/src/verify.ts +2 -2
- package/src/auth-fixture.ts +0 -26
- package/src/id-token-fixture.ts +0 -20
- package/src/oauth-login-fixture.ts +0 -53
package/src/adapter.ts
CHANGED
|
@@ -22,7 +22,10 @@ export interface AuthUser {
|
|
|
22
22
|
* the operator's identity away. Empty for almost every account.
|
|
23
23
|
*/
|
|
24
24
|
readonly scopes: readonly string[];
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The TOTP secret AS STORED — sealed (`mfa-secret.ts`), never base32 — or `null` when MFA is
|
|
27
|
+
* not enrolled. Only `openTotpSecret` turns it into something a code can be checked against.
|
|
28
|
+
*/
|
|
26
29
|
readonly mfaSecret: string | null;
|
|
27
30
|
readonly recoveryCodeHashes: readonly string[];
|
|
28
31
|
/**
|
|
@@ -71,14 +74,40 @@ export interface UserStore {
|
|
|
71
74
|
createUser(input: CreateUserInput): Promise<AuthUser>;
|
|
72
75
|
updateUser(id: string, patch: UserPatch): Promise<AuthUser | null>;
|
|
73
76
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
|
|
77
|
+
* Required, like every member of the seam: an adapter that lacks one is a compile error at the
|
|
78
|
+
* `defineAuth({ adapter })` it is handed to, never an `X_NOT_IMPLEMENTED` at the first SCIM
|
|
79
|
+
* lookup in production. Exact match on the stored string, `null` when nobody carries it.
|
|
80
|
+
*/
|
|
81
|
+
findUserByExternalId(externalId: string): Promise<AuthUser | null>;
|
|
82
|
+
/**
|
|
83
|
+
* Enumeration — a quarterly access review needs it. Ordered by email ascending; disabled members
|
|
84
|
+
* are left out unless `query.includeDisabled` is `true`.
|
|
78
85
|
*/
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
|
|
86
|
+
listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]>;
|
|
87
|
+
/**
|
|
88
|
+
* Every user holding a second-factor secret, with the value exactly as stored, ordered by id.
|
|
89
|
+
* The one enumeration `sealMfaSecrets` needs: it cannot seal what it cannot find, and whether a
|
|
90
|
+
* stored value is sealed is core's `isSealed` to say — never a `like` in an adapter.
|
|
91
|
+
*/
|
|
92
|
+
listUsersWithMfaSecret(): Promise<readonly StoredMfaSecret[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Compare-and-set on one user's stored secret: write `next` only while the row still holds
|
|
95
|
+
* `expected`, in ONE atomic step, and answer whether it did. What `sealMfaSecrets` writes
|
|
96
|
+
* through — a user who re-enrolled since the value was read must keep the NEW secret, and a
|
|
97
|
+
* plain write would put the old one back, sealed.
|
|
98
|
+
*/
|
|
99
|
+
replaceMfaSecret(userId: string, expected: string, next: string): Promise<boolean>;
|
|
100
|
+
/**
|
|
101
|
+
* Remove one recovery-code hash from one user, and answer whether it was there — in ONE atomic
|
|
102
|
+
* step. Single-use is a storage guarantee, exactly as `takeVerification`'s is: two concurrent
|
|
103
|
+
* redemptions of one code must not both find it, which a read followed by a write lets happen.
|
|
104
|
+
*/
|
|
105
|
+
consumeRecoveryCode(userId: string, codeHash: string): Promise<boolean>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface StoredMfaSecret {
|
|
109
|
+
readonly userId: string;
|
|
110
|
+
readonly mfaSecret: string;
|
|
82
111
|
}
|
|
83
112
|
|
|
84
113
|
export interface AuthSession {
|
|
@@ -115,17 +144,15 @@ export interface SessionStore {
|
|
|
115
144
|
/**
|
|
116
145
|
* Every session this user holds, including the caller's own. `deleteOtherSessions` cannot
|
|
117
146
|
* express it — there is no session id to keep — and a password change or a disable has to.
|
|
118
|
-
*
|
|
119
|
-
* Optional for the reason `findUserByExternalId` is: adding a required member to a shipped
|
|
120
|
-
* seam breaks every third-party adapter. `revocation.ts` refuses with `X_NOT_IMPLEMENTED` and
|
|
121
|
-
* names the method when an adapter has not got it.
|
|
147
|
+
* Returns how many were killed.
|
|
122
148
|
*/
|
|
123
|
-
deleteSessionsForUser
|
|
149
|
+
deleteSessionsForUser(userId: string): Promise<number>;
|
|
124
150
|
/**
|
|
125
151
|
* Everything issued before an instant. The credential-compromise sweep: rotate the secret,
|
|
126
|
-
* then kill everything minted under the old one, without enumerating users.
|
|
152
|
+
* then kill everything minted under the old one, without enumerating users. Strictly before:
|
|
153
|
+
* a session created AT the instant survives.
|
|
127
154
|
*/
|
|
128
|
-
deleteSessionsCreatedBefore
|
|
155
|
+
deleteSessionsCreatedBefore(before: Date): Promise<number>;
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
export interface AuthAccount {
|
|
@@ -218,5 +245,5 @@ export interface AuthAdapter
|
|
|
218
245
|
* exactly the sessions it was run for. So it joins through `x_users`, which is why it sits on
|
|
219
246
|
* the full adapter rather than on `SessionStore`.
|
|
220
247
|
*/
|
|
221
|
-
deleteSessionsForOrg
|
|
248
|
+
deleteSessionsForOrg(orgId: string): Promise<number>;
|
|
222
249
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Single responsibility: a presented API key → who is calling, in the shape a bearer mount and an
|
|
2
|
+
// MCP endpoint both take as `resolveToken`. `verifyApiKey` answers a record or throws; this is the
|
|
3
|
+
// one place that turns its ONE rejection into the `null` those surfaces answer 401 for — and
|
|
4
|
+
// leaves everything else a throw, because "the database is down" is not "your key is wrong".
|
|
5
|
+
|
|
6
|
+
import { type Clock, isUltimateError, systemClock } from '@ultimat3/core';
|
|
7
|
+
import {
|
|
8
|
+
type ApiKeyActorOptions,
|
|
9
|
+
type ApiKeyVerifyStore,
|
|
10
|
+
apiKeyActor,
|
|
11
|
+
verifyApiKey,
|
|
12
|
+
} from './api-keys';
|
|
13
|
+
import type { PolicyActor } from './policy-bridge';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* What a verified key resolves to. Structurally `@ultimat3/http`'s `BearerCaller` and
|
|
17
|
+
* `@ultimat3/mcp`'s `ResolvedToken` — declared here because this tier can import neither.
|
|
18
|
+
*/
|
|
19
|
+
export interface ApiKeyCaller {
|
|
20
|
+
/** The agent actor: the key's org, and its scopes as the permissions a policy reads. */
|
|
21
|
+
readonly actor: PolicyActor;
|
|
22
|
+
/** The scopes the ACTOR carries — the key's, cut by its owner's grants. The mount's scope map is cut by these. */
|
|
23
|
+
readonly scopes: ReadonlySet<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
|
|
27
|
+
readonly clock?: Clock | undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `resolveToken: apiKeyResolver(() => keys)` on `defineApi({ http: { mounts } })` and on
|
|
32
|
+
* `defineAppMcp()` — one resolver for both.
|
|
33
|
+
*
|
|
34
|
+
* `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
|
|
35
|
+
* its module is evaluated, and `new BuiltinAdapter()` takes the process's database client, which
|
|
36
|
+
* boot installs later. `() => new MemoryAdapter()` would be a new, empty store per request — hold
|
|
37
|
+
* the instance and return it.
|
|
38
|
+
*/
|
|
39
|
+
export function apiKeyResolver(
|
|
40
|
+
store: () => ApiKeyVerifyStore,
|
|
41
|
+
options: ApiKeyResolverOptions = {},
|
|
42
|
+
): (token: string) => Promise<ApiKeyCaller | null> {
|
|
43
|
+
return async (token) => {
|
|
44
|
+
try {
|
|
45
|
+
const verified = await verifyApiKey(store(), token, options.clock ?? systemClock);
|
|
46
|
+
const actor = apiKeyActor(verified, options);
|
|
47
|
+
return { actor, scopes: new Set(actor.scopes) };
|
|
48
|
+
} catch (thrown) {
|
|
49
|
+
// Every way a key can be wrong is this one code, by `verifyApiKey`'s own design: a caller
|
|
50
|
+
// that could tell "revoked" from "unknown" could enumerate ids.
|
|
51
|
+
if (isUltimateError(thrown) && thrown.code === 'X_API_KEY_INVALID') return null;
|
|
52
|
+
throw thrown;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/api-keys.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
// lookup happens by the non-secret id so the secret never appears in a query, an index or a log.
|
|
6
6
|
|
|
7
7
|
import { type Clock, randomHex, systemClock } from '@ultimat3/core';
|
|
8
|
-
import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
|
|
9
|
-
import { apiKeyEnvInvalid, apiKeyInvalid } from './errors';
|
|
8
|
+
import type { ApiKeyStore, AuthApiKeyRecord, AuthUser, UserStore } from './adapter';
|
|
9
|
+
import { apiKeyEnvInvalid, apiKeyInvalid, apiKeyScopeWildcard } from './errors';
|
|
10
10
|
import type { PolicyActor } from './policy-bridge';
|
|
11
|
-
import { actorFromApiKey } from './policy-bridge';
|
|
11
|
+
import { actorFromApiKey, isWildcardScope } from './policy-bridge';
|
|
12
12
|
import { randomToken, sha256Hex, timingSafeEqual } from './tokens';
|
|
13
13
|
|
|
14
14
|
export const API_KEY_NAMESPACE = 'ult';
|
|
@@ -63,6 +63,8 @@ const API_KEY_ENV = /^[a-z0-9-]+$/;
|
|
|
63
63
|
|
|
64
64
|
export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
|
|
65
65
|
if (!API_KEY_ENV.test(input.env)) throw apiKeyEnvInvalid(input.env);
|
|
66
|
+
const wildcard = input.scopes.find(isWildcardScope);
|
|
67
|
+
if (wildcard !== undefined) throw apiKeyScopeWildcard(wildcard);
|
|
66
68
|
const clock = input.clock ?? systemClock;
|
|
67
69
|
// Hex, not base64url: the id sits between two `_` delimiters and must not contain one.
|
|
68
70
|
const id = randomHex(8);
|
|
@@ -85,16 +87,31 @@ export function issueApiKey(input: IssueApiKeyInput): IssuedApiKey {
|
|
|
85
87
|
};
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
/** What verification reads: the key, and the user row of whoever owns it. */
|
|
91
|
+
export type ApiKeyVerifyStore = ApiKeyStore & Pick<UserStore, 'findUserById'>;
|
|
92
|
+
|
|
93
|
+
export interface VerifiedApiKey {
|
|
94
|
+
readonly record: AuthApiKeyRecord;
|
|
95
|
+
/** The user the key was issued to, as the row reads NOW. `null` for a key no user owns. */
|
|
96
|
+
readonly owner: AuthUser | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
/**
|
|
89
|
-
* Every rejection — malformed, unknown, revoked, expired, wrong secret
|
|
90
|
-
* `X_API_KEY_INVALID`. A caller that can tell "revoked" from "unknown"
|
|
100
|
+
* Every rejection — malformed, unknown, revoked, expired, wrong secret, an owner who is gone or
|
|
101
|
+
* disabled — throws the same `X_API_KEY_INVALID`. A caller that can tell "revoked" from "unknown"
|
|
102
|
+
* can enumerate ids.
|
|
103
|
+
*
|
|
104
|
+
* The owner is re-read on every verification, for the reason `authenticate` re-reads the user on
|
|
105
|
+
* every request: a key is the owner's credential, so disabling the owner has to stop it on its
|
|
106
|
+
* next use — not whenever somebody remembers to revoke it. The read happens only after the secret
|
|
107
|
+
* matched, so an unauthenticated guess costs no second lookup.
|
|
91
108
|
*/
|
|
92
109
|
export async function verifyApiKey(
|
|
93
|
-
store:
|
|
110
|
+
store: ApiKeyVerifyStore,
|
|
94
111
|
plaintext: string,
|
|
95
112
|
clock: Clock = systemClock,
|
|
96
|
-
): Promise<
|
|
97
|
-
const parsed = parseApiKey(plaintext);
|
|
113
|
+
): Promise<VerifiedApiKey> {
|
|
114
|
+
const parsed = typeof plaintext === 'string' ? parseApiKey(plaintext) : null;
|
|
98
115
|
if (parsed === null) throw apiKeyInvalid();
|
|
99
116
|
const record = await store.findApiKeyById(parsed.id);
|
|
100
117
|
if (record === null) throw apiKeyInvalid();
|
|
@@ -104,8 +121,12 @@ export async function verifyApiKey(
|
|
|
104
121
|
throw apiKeyInvalid();
|
|
105
122
|
}
|
|
106
123
|
if (!timingSafeEqual(sha256Hex(parsed.secret), record.keyHash)) throw apiKeyInvalid();
|
|
124
|
+
const owner = record.userId === null ? null : await store.findUserById(record.userId);
|
|
125
|
+
if (record.userId !== null && (owner === null || owner.disabledAt !== null)) {
|
|
126
|
+
throw apiKeyInvalid();
|
|
127
|
+
}
|
|
107
128
|
await store.touchApiKey(record.id, now);
|
|
108
|
-
return record;
|
|
129
|
+
return { record, owner };
|
|
109
130
|
}
|
|
110
131
|
|
|
111
132
|
export async function revokeApiKey(
|
|
@@ -116,9 +137,35 @@ export async function revokeApiKey(
|
|
|
116
137
|
return await store.revokeApiKey(id, clock.now());
|
|
117
138
|
}
|
|
118
139
|
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
/**
|
|
141
|
+
* What an owner may do, as far as this package can read it: the row's direct `permissions` and
|
|
142
|
+
* `scopes`. ROLES are not here — expanding a role to permissions is `@ultimat3/policy`'s, which
|
|
143
|
+
* this tier cannot import — so an app whose users hold roles passes `grantsOf` and expands them.
|
|
144
|
+
*/
|
|
145
|
+
export const directGrants = (owner: AuthUser): readonly string[] => [
|
|
146
|
+
...owner.permissions,
|
|
147
|
+
...owner.scopes,
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
export interface ApiKeyActorOptions {
|
|
151
|
+
/**
|
|
152
|
+
* The owner's effective grants, which a key's scopes are cut down to. Defaults to
|
|
153
|
+
* `directGrants`. A role-based app passes its own expansion, e.g.
|
|
154
|
+
* `(owner) => [...directGrants(owner), ...owner.roles.flatMap((role) => ROLE_GRANTS[role] ?? [])]`.
|
|
155
|
+
*/
|
|
156
|
+
readonly grantsOf?: ((owner: AuthUser) => readonly string[]) | undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The agent actor for a verified key. Nothing is added: the key's scopes, minus any wildcard,
|
|
161
|
+
* minus — for a key a user owns — whatever that user could not do themselves.
|
|
162
|
+
*/
|
|
163
|
+
export function apiKeyActor(
|
|
164
|
+
verified: VerifiedApiKey,
|
|
165
|
+
options: ApiKeyActorOptions = {},
|
|
166
|
+
): PolicyActor {
|
|
167
|
+
const { record, owner } = verified;
|
|
168
|
+
return actorFromApiKey(record, owner === null ? null : (options.grantsOf ?? directGrants)(owner));
|
|
122
169
|
}
|
|
123
170
|
|
|
124
171
|
/** Safe to render in a dashboard or return from an MCP tool: no hash, no secret. */
|
package/src/auth.ts
CHANGED
|
@@ -7,8 +7,11 @@ import { type Clock, systemClock, uuid } from '@ultimat3/core';
|
|
|
7
7
|
import { t } from '@ultimat3/schema';
|
|
8
8
|
import type { AuthAdapter, AuthSession, AuthUser } from './adapter';
|
|
9
9
|
import { normaliseEmail } from './email';
|
|
10
|
-
import {
|
|
10
|
+
import { mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
11
11
|
import { installedAuthLimiter } from './limiter-install';
|
|
12
|
+
import { openLoginAttempt } from './login-attempt';
|
|
13
|
+
import { createTotpReplayGuard, type TotpReplayGuard } from './mfa';
|
|
14
|
+
import { mfaChallengeRequired } from './mfa-challenge';
|
|
12
15
|
import type { OAuthProviderId } from './oauth';
|
|
13
16
|
import {
|
|
14
17
|
checkPasswordStrength,
|
|
@@ -26,9 +29,7 @@ import {
|
|
|
26
29
|
assertAuthLimiterPolicy,
|
|
27
30
|
createAuthLimiter,
|
|
28
31
|
DEFAULT_AUTH_RATE_LIMIT,
|
|
29
|
-
ipKey,
|
|
30
32
|
loginFailed,
|
|
31
|
-
orgKey,
|
|
32
33
|
orgRateLimit,
|
|
33
34
|
} from './rate-limit';
|
|
34
35
|
import {
|
|
@@ -147,6 +148,12 @@ export interface AuthConfigInput {
|
|
|
147
148
|
*/
|
|
148
149
|
readonly orgLimiter?: AuthLimiter | undefined;
|
|
149
150
|
readonly mfa?: Partial<AuthMfaPolicy> | undefined;
|
|
151
|
+
/**
|
|
152
|
+
* Which TOTP steps have been spent, read and written by `completeMfa`. Omitted means
|
|
153
|
+
* `createTotpReplayGuard()` — one process' worth of memory, so a code is single-use per replica;
|
|
154
|
+
* a fleet that needs it single-use everywhere passes a shared guard with the same two methods.
|
|
155
|
+
*/
|
|
156
|
+
readonly totpReplay?: TotpReplayGuard | undefined;
|
|
150
157
|
/**
|
|
151
158
|
* The OAuth providers this app serves login routes for. Defaults to `[]` — an empty list is
|
|
152
159
|
* "no OAuth", and every `/auth/oauth/<id>` answers `X_OAUTH_PROVIDER_UNKNOWN`. Never the live
|
|
@@ -168,6 +175,7 @@ export interface Auth {
|
|
|
168
175
|
readonly orgRateLimit: AuthRateLimitPolicy;
|
|
169
176
|
readonly orgLimiter: AuthLimiter;
|
|
170
177
|
readonly mfa: AuthMfaPolicy;
|
|
178
|
+
readonly totpReplay: TotpReplayGuard;
|
|
171
179
|
readonly providers: readonly OAuthProviderId[];
|
|
172
180
|
readonly link: OAuthLinkPolicy;
|
|
173
181
|
}
|
|
@@ -224,6 +232,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
224
232
|
orgRateLimit: orgLimiter.policy,
|
|
225
233
|
orgLimiter,
|
|
226
234
|
mfa,
|
|
235
|
+
totpReplay: config.totpReplay ?? createTotpReplayGuard(),
|
|
227
236
|
// BREAKING (majors only): the default is `[]`, never the live registry.
|
|
228
237
|
//
|
|
229
238
|
// It was `oauthProviderIds()`, so `defineAuth({ providers })`'s own documented purpose — the
|
|
@@ -278,50 +287,46 @@ export interface LoginResult {
|
|
|
278
287
|
* Every failure path here throws `loginFailed()` — unknown address, wrong password and
|
|
279
288
|
* disabled account are indistinguishable in both message and duration. The only paths that
|
|
280
289
|
* throw something else are lockout (before any work) and MFA (after the password is proven).
|
|
290
|
+
*
|
|
291
|
+
* The attempt is RESERVED against every bucket before the KDF and settled after it: a burst of
|
|
292
|
+
* concurrent guesses spends the allowance as it arrives, so at most `maxAttempts` of them ever
|
|
293
|
+
* reach the hash. A failure leaves its reservations standing — they are the recorded failure.
|
|
281
294
|
*/
|
|
282
295
|
export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
|
|
283
|
-
const account = accountKey(input.email);
|
|
284
296
|
const ip = input.ip ?? null;
|
|
285
|
-
await auth.
|
|
286
|
-
if (ip !== null) await auth.limiter.assertAllowed(ipKey(ip));
|
|
297
|
+
const attempt = await openLoginAttempt(auth, accountKey(input.email), ip);
|
|
287
298
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
await
|
|
304
|
-
|
|
305
|
-
if (org !== null) await auth.orgLimiter.recordFailure(orgKey(org));
|
|
306
|
-
throw loginFailed();
|
|
299
|
+
let user: AuthUser | null;
|
|
300
|
+
let verification: Awaited<ReturnType<typeof verifyPassword>>;
|
|
301
|
+
try {
|
|
302
|
+
user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
|
|
303
|
+
// The tenant bucket can only be joined once the address resolves to an org, which is still
|
|
304
|
+
// before the KDF runs — the expensive half of this function — and it caps a spray that
|
|
305
|
+
// per-IP and per-account buckets both let through.
|
|
306
|
+
await attempt.reserveOrg(user?.orgId ?? null);
|
|
307
|
+
verification = await verifyPassword({
|
|
308
|
+
hash: user !== null && user.disabledAt === null ? user.passwordHash : null,
|
|
309
|
+
password: input.password,
|
|
310
|
+
params: auth.password.params,
|
|
311
|
+
});
|
|
312
|
+
} catch (thrown) {
|
|
313
|
+
// No verdict — a shed KDF, a store that is down, a tenant cap — is not a failed guess.
|
|
314
|
+
await attempt.release();
|
|
315
|
+
throw thrown;
|
|
307
316
|
}
|
|
308
317
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// which made it inert against the attack it exists for. A credential-stuffing run never spends
|
|
317
|
-
// `maxAttempts` guesses on one account, so the per-account bucket never fires; the address
|
|
318
|
-
// bucket is the only one that sees the pattern, and `4 wrong guesses + 1 login to an account
|
|
319
|
-
// the attacker owns, repeat` wiped it every fifth request. Measured: 5 guesses to
|
|
320
|
-
// `X_ACCOUNT_LOCKED` without the reset, 160 and never locked with it.
|
|
318
|
+
if (!verification.ok || user === null) throw loginFailed();
|
|
319
|
+
|
|
320
|
+
// ONLY the account bucket is cleared, and only once nothing more is owed. The ACCOUNT window
|
|
321
|
+
// belongs to one person, so a success proves the typos before it were theirs. The address and
|
|
322
|
+
// the tenant count traffic from a SHARED source, so a success is not evidence the failures
|
|
323
|
+
// beside it were benign — clearing the address bucket made it inert against stuffing (`4 wrong
|
|
324
|
+
// guesses + 1 login to an account the attacker owns, repeat` never locked).
|
|
321
325
|
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
326
|
+
// With a second factor still owed NOTHING is cleared: a caller holding the password would
|
|
327
|
+
// otherwise wipe the count of wrong codes with every repeat of this first leg.
|
|
328
|
+
if (user.mfaSecret === null) await attempt.succeed();
|
|
329
|
+
else await attempt.release();
|
|
325
330
|
|
|
326
331
|
// Parameters were raised since this hash was written: upgrade it now, while we hold the
|
|
327
332
|
// plaintext. This is the only moment it is possible without asking the user for anything.
|
|
@@ -331,11 +336,9 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
|
|
|
331
336
|
});
|
|
332
337
|
}
|
|
333
338
|
|
|
334
|
-
// Password proven, second factor not. No half-authenticated session is written
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
// credential; the constraint is written down in `packages/auth/CLAUDE.md`.
|
|
338
|
-
if (user.mfaSecret !== null) throw mfaRequired(user.id);
|
|
339
|
+
// Password proven, second factor not. No half-authenticated session is written: the sealed
|
|
340
|
+
// challenge in the error is the only thing that carries the first leg to `completeMfa`.
|
|
341
|
+
if (user.mfaSecret !== null) throw await mfaChallengeRequired(auth, user.id);
|
|
339
342
|
|
|
340
343
|
const issued = await createSession(auth.sessions, {
|
|
341
344
|
userId: user.id,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Single responsibility: an `x_users` unique violation as the code `MemoryAdapter` answers. One
|
|
2
|
+
// duplicate signup must not be `X_AUTH_WRITE_FAILED` under `x dev` and `X_DB_UNIQUE_VIOLATION` in
|
|
3
|
+
// production: an app that branches on the code passes its tests and misses on Postgres.
|
|
4
|
+
|
|
5
|
+
import { stringField } from '@ultimat3/core';
|
|
6
|
+
import { SQLSTATE, sqlState } from '@ultimat3/db';
|
|
7
|
+
import { type AuthError, authUniqueViolation } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The constraints `tables.ts` declares on `x_users`, by the names Postgres gives them. Closed: a
|
|
11
|
+
* unique index an app added to the table is the app's, and its violation travels on as the
|
|
12
|
+
* database error it is rather than being reported as a column it does not cover.
|
|
13
|
+
*/
|
|
14
|
+
const X_USERS_UNIQUE_COLUMNS: ReadonlyMap<string, string> = new Map([
|
|
15
|
+
['x_users_email_key', 'email'],
|
|
16
|
+
['x_users_external_id_key', 'external_id'],
|
|
17
|
+
['x_users_pkey', 'id'],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** How deep `DbError.sourceError` may nest before the read stops — db's own bound. */
|
|
21
|
+
const MAX_WRAPS = 4;
|
|
22
|
+
|
|
23
|
+
/** The constraint the server named. db's wrap keeps the driver error on `sourceError`. */
|
|
24
|
+
function violatedConstraint(error: unknown): string | undefined {
|
|
25
|
+
let value = error;
|
|
26
|
+
for (let depth = 0; depth < MAX_WRAPS && value !== undefined && value !== null; depth += 1) {
|
|
27
|
+
const constraint = stringField(value, 'constraint');
|
|
28
|
+
if (constraint !== undefined) return constraint;
|
|
29
|
+
value = typeof value === 'object' ? (value as { sourceError?: unknown }).sourceError : null;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `undefined` for everything that is not one of `x_users`' own unique constraints, so the caller
|
|
36
|
+
* rethrows what it caught. The SQLSTATE is read through db's `sqlState`, which decides by where
|
|
37
|
+
* the error came from, never by the shape of a `code` field.
|
|
38
|
+
*/
|
|
39
|
+
export function usersUniqueViolation(operation: string, error: unknown): AuthError | undefined {
|
|
40
|
+
if (sqlState(error) !== SQLSTATE.uniqueViolation) return undefined;
|
|
41
|
+
const column = X_USERS_UNIQUE_COLUMNS.get(violatedConstraint(error) ?? '');
|
|
42
|
+
return column === undefined ? undefined : authUniqueViolation(operation, 'x_users', column);
|
|
43
|
+
}
|
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>>;
|
|
@@ -117,9 +120,24 @@ const toApiKey = (row: Row): AuthApiKeyRecord => ({
|
|
|
117
120
|
export class BuiltinAdapter 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 `MemoryAdapter` 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}`,
|
|
@@ -308,6 +360,9 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
308
360
|
}
|
|
309
361
|
|
|
310
362
|
/**
|
|
363
|
+
* `consumed_at` is the injected clock's instant, bound — never the server's `now()`, which made
|
|
364
|
+
* the stamp the one instant in this package no test could move and no `Clock` agreed with.
|
|
365
|
+
*
|
|
311
366
|
* One conditional UPDATE, and every part of the predicate is load-bearing. `consumed_at is null`
|
|
312
367
|
* on the UPDATE itself is what makes redemption single-use under concurrency — inside the
|
|
313
368
|
* subselect alone, two racing redemptions both pick the row and both consume it. `token_hash`
|
|
@@ -324,7 +379,7 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
324
379
|
tokenHash: string,
|
|
325
380
|
): Promise<AuthVerification | null> {
|
|
326
381
|
const row = await this.#db.one<Row>(sql`
|
|
327
|
-
update x_verifications set consumed_at = now()
|
|
382
|
+
update x_verifications set consumed_at = ${this.#clock.now()}
|
|
328
383
|
where consumed_at is null and id = (
|
|
329
384
|
select id from x_verifications
|
|
330
385
|
where purpose = ${purpose} and identifier = ${identifier}
|
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
|
}
|