@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/adapter.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Single responsibility: the persistence seam. `AuthAdapter` is the one interface auth talks
|
|
2
2
|
// to, split into per-concern stores so a test (or a caller) can satisfy just the slice it uses.
|
|
3
3
|
// Better Auth binds here — it is an adapter implementation, not a dependency. The blessed
|
|
4
|
-
// default is `
|
|
4
|
+
// default is `PostgresAuthAdapter` in `builtin-adapter.ts`; the DDL it expects is in `tables.ts`.
|
|
5
5
|
|
|
6
6
|
export interface AuthUser {
|
|
7
7
|
readonly id: string;
|
|
@@ -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`.
|
|
85
|
+
*/
|
|
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.
|
|
78
98
|
*/
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
|
|
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 {
|
|
@@ -108,6 +137,10 @@ export interface SessionStore {
|
|
|
108
137
|
getSession(id: string): Promise<AuthSession | null>;
|
|
109
138
|
createSession(session: AuthSession): Promise<AuthSession>;
|
|
110
139
|
updateSession(id: string, patch: SessionPatch): Promise<AuthSession | null>;
|
|
140
|
+
/**
|
|
141
|
+
* Remove one session and answer whether THIS call removed it, in one atomic step: `rotateSession`
|
|
142
|
+
* treats `true` as its claim on the id, so two concurrent deletes must not both answer `true`.
|
|
143
|
+
*/
|
|
111
144
|
deleteSession(id: string): Promise<boolean>;
|
|
112
145
|
/** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
|
|
113
146
|
deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
|
|
@@ -115,17 +148,15 @@ export interface SessionStore {
|
|
|
115
148
|
/**
|
|
116
149
|
* Every session this user holds, including the caller's own. `deleteOtherSessions` cannot
|
|
117
150
|
* 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.
|
|
151
|
+
* Returns how many were killed.
|
|
122
152
|
*/
|
|
123
|
-
deleteSessionsForUser
|
|
153
|
+
deleteSessionsForUser(userId: string): Promise<number>;
|
|
124
154
|
/**
|
|
125
155
|
* Everything issued before an instant. The credential-compromise sweep: rotate the secret,
|
|
126
|
-
* then kill everything minted under the old one, without enumerating users.
|
|
156
|
+
* then kill everything minted under the old one, without enumerating users. Strictly before:
|
|
157
|
+
* a session created AT the instant survives.
|
|
127
158
|
*/
|
|
128
|
-
deleteSessionsCreatedBefore
|
|
159
|
+
deleteSessionsCreatedBefore(before: Date): Promise<number>;
|
|
129
160
|
}
|
|
130
161
|
|
|
131
162
|
export interface AuthAccount {
|
|
@@ -200,7 +231,7 @@ export interface ApiKeyStore {
|
|
|
200
231
|
}
|
|
201
232
|
|
|
202
233
|
/**
|
|
203
|
-
* The full seam. One blessed implementation ships (`
|
|
234
|
+
* The full seam. One blessed implementation ships (`PostgresAuthAdapter`); Better Auth, or any
|
|
204
235
|
* other identity backend, binds by implementing this and nothing else changes upstream.
|
|
205
236
|
*/
|
|
206
237
|
export interface AuthAdapter
|
|
@@ -218,5 +249,5 @@ export interface AuthAdapter
|
|
|
218
249
|
* exactly the sessions it was run for. So it joins through `x_users`, which is why it sits on
|
|
219
250
|
* the full adapter rather than on `SessionStore`.
|
|
220
251
|
*/
|
|
221
|
-
deleteSessionsForOrg
|
|
252
|
+
deleteSessionsForOrg(orgId: string): Promise<number>;
|
|
222
253
|
}
|
package/src/api-key-resolver.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
// leaves everything else a throw, because "the database is down" is not "your key is wrong".
|
|
5
5
|
|
|
6
6
|
import { type Clock, isUltimateError, systemClock } from '@ultimat3/core';
|
|
7
|
-
import
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
type ApiKeyActorOptions,
|
|
9
|
+
type ApiKeyVerifyStore,
|
|
10
|
+
apiKeyActor,
|
|
11
|
+
verifyApiKey,
|
|
12
|
+
} from './api-keys';
|
|
9
13
|
import type { PolicyActor } from './policy-bridge';
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -15,11 +19,11 @@ import type { PolicyActor } from './policy-bridge';
|
|
|
15
19
|
export interface ApiKeyCaller {
|
|
16
20
|
/** The agent actor: the key's org, and its scopes as the permissions a policy reads. */
|
|
17
21
|
readonly actor: PolicyActor;
|
|
18
|
-
/** The key's
|
|
22
|
+
/** The scopes the ACTOR carries — the key's, cut by its owner's grants. The mount's scope map is cut by these. */
|
|
19
23
|
readonly scopes: ReadonlySet<string>;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
export interface ApiKeyResolverOptions {
|
|
26
|
+
export interface ApiKeyResolverOptions extends ApiKeyActorOptions {
|
|
23
27
|
readonly clock?: Clock | undefined;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -28,18 +32,19 @@ export interface ApiKeyResolverOptions {
|
|
|
28
32
|
* `defineAppMcp()` — one resolver for both.
|
|
29
33
|
*
|
|
30
34
|
* `store` is a THUNK, read when a token is presented and never captured: a mount is declared when
|
|
31
|
-
* its module is evaluated, and `
|
|
32
|
-
* boot installs later. `() =>
|
|
35
|
+
* its module is evaluated, and `postgresAuthAdapter()` takes the process's database client, which
|
|
36
|
+
* boot installs later. `() => memoryAuthAdapter()` would be a new, empty store per request — hold
|
|
33
37
|
* the instance and return it.
|
|
34
38
|
*/
|
|
35
39
|
export function apiKeyResolver(
|
|
36
|
-
store: () =>
|
|
40
|
+
store: () => ApiKeyVerifyStore,
|
|
37
41
|
options: ApiKeyResolverOptions = {},
|
|
38
42
|
): (token: string) => Promise<ApiKeyCaller | null> {
|
|
39
43
|
return async (token) => {
|
|
40
44
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
45
|
+
const verified = await verifyApiKey(store(), token, options.clock ?? systemClock);
|
|
46
|
+
const actor = apiKeyActor(verified, options);
|
|
47
|
+
return { actor, scopes: new Set(actor.scopes) };
|
|
43
48
|
} catch (thrown) {
|
|
44
49
|
// Every way a key can be wrong is this one code, by `verifyApiKey`'s own design: a caller
|
|
45
50
|
// that could tell "revoked" from "unknown" could enumerate ids.
|
package/src/api-keys.ts
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
// evaluation a human does. The plaintext is shown once; only its SHA-256 is ever stored, and
|
|
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
|
-
import { type Clock, randomHex, systemClock } from '@ultimat3/core';
|
|
8
|
-
import type { ApiKeyStore, AuthApiKeyRecord } from './adapter';
|
|
9
|
-
import { apiKeyEnvInvalid, apiKeyInvalid } from './errors';
|
|
7
|
+
import { type Clock, randomHex, systemClock, timingSafeEqual } from '@ultimat3/core';
|
|
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';
|
|
12
|
-
import { randomToken, sha256Hex
|
|
11
|
+
import { actorFromApiKey, isWildcardScope } from './policy-bridge';
|
|
12
|
+
import { randomToken, sha256Hex } from './tokens';
|
|
13
13
|
|
|
14
14
|
export const API_KEY_NAMESPACE = 'ult';
|
|
15
15
|
|
|
@@ -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
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
// (http, actions, jobs, MCP) authorizes on that and never on a session row, so there is one
|
|
4
4
|
// identity shape in the framework and `@ultimat3/policy` is the only thing that reads it.
|
|
5
5
|
|
|
6
|
-
import { type Clock, systemClock,
|
|
6
|
+
import { type Clock, systemClock, timingSafeEqual, uuidV7 } 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 { memoryTotpReplayGuard, type TotpReplayGuard } from './mfa';
|
|
14
|
+
import { mfaChallengeRequired } from './mfa-challenge';
|
|
12
15
|
import type { OAuthProviderId } from './oauth';
|
|
13
16
|
import {
|
|
14
17
|
checkPasswordStrength,
|
|
@@ -24,11 +27,9 @@ import {
|
|
|
24
27
|
type AuthRateLimitPolicy,
|
|
25
28
|
accountKey,
|
|
26
29
|
assertAuthLimiterPolicy,
|
|
27
|
-
createAuthLimiter,
|
|
28
30
|
DEFAULT_AUTH_RATE_LIMIT,
|
|
29
|
-
ipKey,
|
|
30
31
|
loginFailed,
|
|
31
|
-
|
|
32
|
+
memoryAuthLimiter,
|
|
32
33
|
orgRateLimit,
|
|
33
34
|
} from './rate-limit';
|
|
34
35
|
import {
|
|
@@ -40,7 +41,7 @@ import {
|
|
|
40
41
|
sessionCookie,
|
|
41
42
|
verifySession,
|
|
42
43
|
} from './session';
|
|
43
|
-
import { sha256Hex
|
|
44
|
+
import { sha256Hex } from './tokens';
|
|
44
45
|
|
|
45
46
|
// The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
|
|
46
47
|
// no token hash. The private columns live in `adapter.ts` and never leave the server.
|
|
@@ -135,7 +136,7 @@ export interface AuthConfigInput {
|
|
|
135
136
|
readonly password?: Partial<PasswordPolicy> | undefined;
|
|
136
137
|
readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
|
|
137
138
|
/**
|
|
138
|
-
* Where failed attempts are counted. Omitted means `
|
|
139
|
+
* Where failed attempts are counted. Omitted means `memoryAuthLimiter`, which is one process'
|
|
139
140
|
* worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
|
|
140
141
|
* runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
|
|
141
142
|
* same, or `defineAuth` refuses here rather than at 3am on the first spray.
|
|
@@ -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
|
+
* `memoryTotpReplayGuard()` — 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
|
}
|
|
@@ -189,13 +197,13 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
189
197
|
// scaffolded app had to remember to build a shared limiter itself, which is the opposite of
|
|
190
198
|
// what this framework promises — and `x new` scaffolds two replicas.
|
|
191
199
|
const limiter =
|
|
192
|
-
config.limiter ?? installedAuthLimiter(rateLimit) ??
|
|
200
|
+
config.limiter ?? installedAuthLimiter(rateLimit) ?? memoryAuthLimiter(clock, rateLimit);
|
|
193
201
|
assertAuthLimiterPolicy(rateLimit, limiter);
|
|
194
202
|
// The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
|
|
195
203
|
// that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
|
|
196
204
|
// for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
|
|
197
205
|
// throughput ceiling and discloses nothing. Only the LOCAL fallback is exempt, and exempting it
|
|
198
|
-
// is what buys that: `
|
|
206
|
+
// is what buys that: `memoryAuthLimiter` always reports `'process'`, which is the one arm the
|
|
199
207
|
// scope check would refuse. A limiter somebody else supplied — injected by the app or built by
|
|
200
208
|
// the host's factory — is compared exactly as the general bucket's is, because a factory that
|
|
201
209
|
// ignores the policy it was handed otherwise enforces numbers the app never declared while
|
|
@@ -203,7 +211,7 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
203
211
|
// is refused for one bucket and trusted for the other.
|
|
204
212
|
const orgLimits = orgRateLimit(rateLimit);
|
|
205
213
|
const suppliedOrgLimiter = config.orgLimiter ?? installedAuthLimiter(orgLimits);
|
|
206
|
-
const orgLimiter = suppliedOrgLimiter ??
|
|
214
|
+
const orgLimiter = suppliedOrgLimiter ?? memoryAuthLimiter(clock, orgLimits);
|
|
207
215
|
if (suppliedOrgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, suppliedOrgLimiter);
|
|
208
216
|
// Read through a widened local on purpose: the field's type is the literal `false`, so this
|
|
209
217
|
// branch is unreachable from TypeScript and reachable from every JS caller and every config
|
|
@@ -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 ?? memoryTotpReplayGuard(),
|
|
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
|
|
@@ -250,7 +259,7 @@ export interface RegisterInput {
|
|
|
250
259
|
export async function register(auth: Auth, input: RegisterInput): Promise<AuthUser> {
|
|
251
260
|
checkPasswordStrength(input.password, { policy: auth.password });
|
|
252
261
|
return await auth.adapter.createUser({
|
|
253
|
-
id:
|
|
262
|
+
id: uuidV7(auth.clock),
|
|
254
263
|
email: normaliseEmail(input.email),
|
|
255
264
|
passwordHash: await hashPassword(input.password, auth.password.params),
|
|
256
265
|
orgId: input.orgId ?? null,
|
|
@@ -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 `MemoryAuthAdapter` 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
|
+
}
|