@ultimat3/auth 1.2.0 → 3.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 +275 -0
- package/README.md +438 -35
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +122 -18
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +239 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +109 -13
- package/src/json.ts +33 -0
- package/src/jwks.ts +246 -0
- package/src/kdf-gate.ts +86 -0
- package/src/memory-adapter.ts +66 -4
- package/src/mfa.ts +104 -9
- package/src/oauth-builtins.ts +77 -0
- package/src/oauth-cookie.ts +4 -3
- package/src/oauth-discovery.ts +132 -0
- package/src/oauth-exchange.ts +40 -18
- package/src/oauth-login-fixture.ts +53 -0
- package/src/oauth-login.ts +111 -14
- package/src/oauth-paths.ts +20 -0
- package/src/oauth-profile.ts +9 -10
- package/src/oauth-registry.ts +65 -0
- package/src/oauth-route.ts +293 -0
- package/src/oauth.ts +31 -58
- package/src/password.ts +65 -13
- package/src/policy-bridge.ts +11 -5
- package/src/privileges.ts +74 -0
- package/src/rate-limit.ts +178 -15
- package/src/revocation.ts +100 -0
- package/src/session.ts +33 -4
- package/src/tables.ts +18 -2
- package/src/tokens.ts +26 -17
- package/src/verify.ts +12 -5
- package/src/workload.ts +131 -0
package/src/adapter.ts
CHANGED
|
@@ -14,9 +14,23 @@ export interface AuthUser {
|
|
|
14
14
|
readonly roles: readonly string[];
|
|
15
15
|
/** Direct grants that bypass roles. Rare; used by break-glass accounts. */
|
|
16
16
|
readonly permissions: readonly string[];
|
|
17
|
+
/**
|
|
18
|
+
* Capability strings this human may hold, landing on `Actor.scopes` — the field `hasScope()`
|
|
19
|
+
* reads and `permissions` is not. Without it a user actor's scopes were the hardcoded `[]`, so
|
|
20
|
+
* a scope-gated human surface (`tenancy:cross`, and every support tool built on one) was
|
|
21
|
+
* unreachable by any human and could only be reached by minting a `serviceActor`, which throws
|
|
22
|
+
* the operator's identity away. Empty for almost every account.
|
|
23
|
+
*/
|
|
24
|
+
readonly scopes: readonly string[];
|
|
17
25
|
/** Base32 TOTP secret, or `null` when MFA is not enrolled. */
|
|
18
26
|
readonly mfaSecret: string | null;
|
|
19
27
|
readonly recoveryCodeHashes: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* The identifier the IdP knows this person by — SCIM's `externalId`, OIDC's `sub`. Stable
|
|
30
|
+
* across a rename and across an address change, which is what a provisioning PUT/PATCH lands
|
|
31
|
+
* on and what an email address cannot be. `null` for an account that arrived by password.
|
|
32
|
+
*/
|
|
33
|
+
readonly externalId: string | null;
|
|
20
34
|
readonly disabledAt: Date | null;
|
|
21
35
|
readonly createdAt: Date;
|
|
22
36
|
}
|
|
@@ -27,6 +41,8 @@ export interface CreateUserInput {
|
|
|
27
41
|
readonly passwordHash: string | null;
|
|
28
42
|
readonly orgId: string | null;
|
|
29
43
|
readonly roles: readonly string[];
|
|
44
|
+
readonly scopes?: readonly string[] | undefined;
|
|
45
|
+
readonly externalId?: string | null | undefined;
|
|
30
46
|
readonly createdAt: Date;
|
|
31
47
|
}
|
|
32
48
|
|
|
@@ -37,6 +53,16 @@ export interface UserPatch {
|
|
|
37
53
|
readonly recoveryCodeHashes?: readonly string[] | undefined;
|
|
38
54
|
readonly disabledAt?: Date | null | undefined;
|
|
39
55
|
readonly roles?: readonly string[] | undefined;
|
|
56
|
+
readonly permissions?: readonly string[] | undefined;
|
|
57
|
+
readonly scopes?: readonly string[] | undefined;
|
|
58
|
+
readonly orgId?: string | null | undefined;
|
|
59
|
+
readonly externalId?: string | null | undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What `listUsersByOrg` filters on. Absent means every member of the org. */
|
|
63
|
+
export interface UserQuery {
|
|
64
|
+
readonly role?: string | undefined;
|
|
65
|
+
readonly includeDisabled?: boolean | undefined;
|
|
40
66
|
}
|
|
41
67
|
|
|
42
68
|
export interface UserStore {
|
|
@@ -44,6 +70,15 @@ export interface UserStore {
|
|
|
44
70
|
findUserById(id: string): Promise<AuthUser | null>;
|
|
45
71
|
createUser(input: CreateUserInput): Promise<AuthUser>;
|
|
46
72
|
updateUser(id: string, patch: UserPatch): Promise<AuthUser | null>;
|
|
73
|
+
/**
|
|
74
|
+
* Optional so an adapter written against 1.2's seam still compiles; both shipped adapters
|
|
75
|
+
* implement it, and `directory.ts` answers `X_NOT_IMPLEMENTED` with the method name when one
|
|
76
|
+
* does not. The alternative was a required member, which is a breaking change to every
|
|
77
|
+
* third-party `AuthAdapter` for a capability most of them will never be asked for.
|
|
78
|
+
*/
|
|
79
|
+
findUserByExternalId?(externalId: string): Promise<AuthUser | null>;
|
|
80
|
+
/** Enumeration, which nothing in the seam could do — a quarterly access review needs it. */
|
|
81
|
+
listUsersByOrg?(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]>;
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
export interface AuthSession {
|
|
@@ -77,6 +112,20 @@ export interface SessionStore {
|
|
|
77
112
|
/** Returns how many were killed — the "sign out everywhere else" number shown to the user. */
|
|
78
113
|
deleteOtherSessions(userId: string, keepSessionId: string): Promise<number>;
|
|
79
114
|
listSessions(userId: string): Promise<readonly AuthSession[]>;
|
|
115
|
+
/**
|
|
116
|
+
* Every session this user holds, including the caller's own. `deleteOtherSessions` cannot
|
|
117
|
+
* 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.
|
|
122
|
+
*/
|
|
123
|
+
deleteSessionsForUser?(userId: string): Promise<number>;
|
|
124
|
+
/**
|
|
125
|
+
* Everything issued before an instant. The credential-compromise sweep: rotate the secret,
|
|
126
|
+
* then kill everything minted under the old one, without enumerating users.
|
|
127
|
+
*/
|
|
128
|
+
deleteSessionsCreatedBefore?(before: Date): Promise<number>;
|
|
80
129
|
}
|
|
81
130
|
|
|
82
131
|
export interface AuthAccount {
|
|
@@ -112,10 +161,18 @@ export interface VerificationStore {
|
|
|
112
161
|
/** Upsert on `(purpose, identifier)` — issuing a new token invalidates the previous one. */
|
|
113
162
|
putVerification(record: AuthVerification): Promise<void>;
|
|
114
163
|
/**
|
|
115
|
-
* Read **and consume** in one atomic step
|
|
116
|
-
* caller convention: two concurrent redemptions must
|
|
164
|
+
* Read **and consume** in one atomic step, and only when `tokenHash` is the live row's.
|
|
165
|
+
* Single-use is a storage guarantee, not a caller convention: two concurrent redemptions must
|
|
166
|
+
* not both see an unconsumed row. The hash belongs to that same step for the same reason — a
|
|
167
|
+
* store that consumes first and lets the caller compare afterwards lets an unauthenticated
|
|
168
|
+
* wrong guess destroy the victim's live token, which is a password-reset denial of service
|
|
169
|
+
* against any address an attacker can name. A non-match consumes nothing and answers `null`.
|
|
117
170
|
*/
|
|
118
|
-
takeVerification(
|
|
171
|
+
takeVerification(
|
|
172
|
+
purpose: string,
|
|
173
|
+
identifier: string,
|
|
174
|
+
tokenHash: string,
|
|
175
|
+
): Promise<AuthVerification | null>;
|
|
119
176
|
}
|
|
120
177
|
|
|
121
178
|
export interface AuthApiKeyRecord {
|
|
@@ -152,6 +209,14 @@ export interface AuthAdapter
|
|
|
152
209
|
AccountStore,
|
|
153
210
|
VerificationStore,
|
|
154
211
|
ApiKeyStore {
|
|
155
|
-
/**
|
|
212
|
+
/** Reported by `Auth.adapter.name` so the driver in use is never a guess. */
|
|
156
213
|
readonly name: string;
|
|
214
|
+
/**
|
|
215
|
+
* Every session held by every member of one org. It spans two tables — `x_sessions` carries no
|
|
216
|
+
* `org_id` and deliberately does not gain one, because org membership lives on the user and a
|
|
217
|
+
* denormalised copy goes stale the moment somebody moves org, which makes the 03:00 sweep miss
|
|
218
|
+
* exactly the sessions it was run for. So it joins through `x_users`, which is why it sits on
|
|
219
|
+
* the full adapter rather than on `SessionStore`.
|
|
220
|
+
*/
|
|
221
|
+
deleteSessionsForOrg?(orgId: string): Promise<number>;
|
|
157
222
|
}
|
package/src/auth.ts
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
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
|
-
import {
|
|
9
|
+
import { normaliseEmail } from './email';
|
|
10
|
+
import { mfaRequired, mfaRequiredUnenforceable, sessionUnknown } from './errors';
|
|
10
11
|
import type { OAuthProviderId } from './oauth';
|
|
11
|
-
import {
|
|
12
|
+
import { oauthProviderIds } from './oauth-registry';
|
|
12
13
|
import {
|
|
13
14
|
checkPasswordStrength,
|
|
14
15
|
DEFAULT_PASSWORD_POLICY,
|
|
@@ -21,10 +22,13 @@ import {
|
|
|
21
22
|
type AuthLimiter,
|
|
22
23
|
type AuthRateLimitPolicy,
|
|
23
24
|
accountKey,
|
|
25
|
+
assertAuthLimiterPolicy,
|
|
24
26
|
createAuthLimiter,
|
|
25
27
|
DEFAULT_AUTH_RATE_LIMIT,
|
|
26
28
|
ipKey,
|
|
27
29
|
loginFailed,
|
|
30
|
+
orgKey,
|
|
31
|
+
orgRateLimit,
|
|
28
32
|
} from './rate-limit';
|
|
29
33
|
import {
|
|
30
34
|
createSession,
|
|
@@ -35,6 +39,7 @@ import {
|
|
|
35
39
|
sessionCookie,
|
|
36
40
|
verifySession,
|
|
37
41
|
} from './session';
|
|
42
|
+
import { sha256Hex, timingSafeEqual } from './tokens';
|
|
38
43
|
|
|
39
44
|
// The projections safe to hand to a client or an MCP tool: no password hash, no TOTP secret,
|
|
40
45
|
// no token hash. The private columns live in `adapter.ts` and never leave the server.
|
|
@@ -45,6 +50,8 @@ export const UserSchema = t.object({
|
|
|
45
50
|
orgId: t.optional(t.uuid),
|
|
46
51
|
roles: t.array(t.string),
|
|
47
52
|
permissions: t.array(t.string),
|
|
53
|
+
scopes: t.array(t.string),
|
|
54
|
+
externalId: t.optional(t.string),
|
|
48
55
|
mfaEnrolled: t.boolean,
|
|
49
56
|
createdAt: t.date,
|
|
50
57
|
});
|
|
@@ -63,7 +70,9 @@ export const SessionSchema = t.object({
|
|
|
63
70
|
export const AccountSchema = t.object({
|
|
64
71
|
id: t.uuid,
|
|
65
72
|
userId: t.uuid,
|
|
66
|
-
provider
|
|
73
|
+
// Any registered provider id, not the three built-ins: an app that registers its own OP has
|
|
74
|
+
// account rows carrying that id, and an enum of three would refuse to parse its own data.
|
|
75
|
+
provider: t.string,
|
|
67
76
|
providerAccountId: t.string,
|
|
68
77
|
expiresAt: t.optional(t.date),
|
|
69
78
|
createdAt: t.date,
|
|
@@ -78,10 +87,44 @@ export const VerificationSchema = t.object({
|
|
|
78
87
|
createdAt: t.date,
|
|
79
88
|
});
|
|
80
89
|
|
|
90
|
+
/**
|
|
91
|
+
* When a provider identity with no linked account is allowed to become an EXISTING local user.
|
|
92
|
+
*
|
|
93
|
+
* There are two values and there is deliberately no third. The dangerous one an app would reach
|
|
94
|
+
* for — "link on whatever address the provider sent" — is not spelled here at all, because a
|
|
95
|
+
* provider that does not verify addresses turns it into account takeover: register the victim's
|
|
96
|
+
* address at a sloppy provider, press the button, inherit the account. Unrepresentable beats
|
|
97
|
+
* explicit, the same way `PkcePair.method` is the literal `'S256'` and never `'plain'`.
|
|
98
|
+
*
|
|
99
|
+
* | value | a provider identity becomes an existing user when |
|
|
100
|
+
* |---|---|
|
|
101
|
+
* | `'verified-email'` (default) | the provider asserted the address verified AND that user had verified it too |
|
|
102
|
+
* | `'never'` | never — an address collision is refused and the caller signs in with their own credentials |
|
|
103
|
+
*
|
|
104
|
+
* An app that genuinely wants something else composes it: wrap `signInWithOAuth` and resolve the
|
|
105
|
+
* user yourself. That is the seam, and it keeps the framework from shipping the loose default.
|
|
106
|
+
*/
|
|
107
|
+
export type OAuthLinkPolicy = 'verified-email' | 'never';
|
|
108
|
+
|
|
109
|
+
/** The product name an authenticator app shows when the app declared none. */
|
|
110
|
+
export const DEFAULT_MFA_ISSUER = 'Ultimate';
|
|
111
|
+
|
|
81
112
|
export interface AuthMfaPolicy {
|
|
82
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Shown in the authenticator app. Usually the product name, and `enrolTotp(auth, …)` reads it
|
|
115
|
+
* from here so it is written once — a call may still name its own for the one enrolment.
|
|
116
|
+
*/
|
|
83
117
|
readonly issuer: string;
|
|
84
|
-
|
|
118
|
+
/**
|
|
119
|
+
* The literal `false`, so `required: true` is a type error rather than a comment — the shape
|
|
120
|
+
* `OAuthProvider.usesPkce` has, and for the same reason: the value nothing enforces has to be
|
|
121
|
+
* unrepresentable, not discouraged. This package cannot make a second factor mandatory. Both
|
|
122
|
+
* credential paths branch on `user.mfaSecret` alone and `actorFromUser` degrades only a user
|
|
123
|
+
* who HAS a secret, so a user who never enrolled has no half-authenticated actor to enrol
|
|
124
|
+
* through and no enrolment route to reach — refusing them at `login()` locks them out for good.
|
|
125
|
+
* `defineAuth` refuses the declaration outright; the app gates its own sign-in handler.
|
|
126
|
+
*/
|
|
127
|
+
readonly required: false;
|
|
85
128
|
}
|
|
86
129
|
|
|
87
130
|
export interface AuthConfigInput {
|
|
@@ -90,8 +133,22 @@ export interface AuthConfigInput {
|
|
|
90
133
|
readonly session?: Partial<SessionPolicy> | undefined;
|
|
91
134
|
readonly password?: Partial<PasswordPolicy> | undefined;
|
|
92
135
|
readonly rateLimit?: Partial<AuthRateLimitPolicy> | undefined;
|
|
136
|
+
/**
|
|
137
|
+
* Where failed attempts are counted. Omitted means `createAuthLimiter`, which is one process'
|
|
138
|
+
* worth of state — correct for dev and tests, and `maxAttempts × N` for N replicas. An app that
|
|
139
|
+
* runs more than one declares `rateLimit.scope: 'shared'` and passes a limiter that says the
|
|
140
|
+
* same, or `defineAuth` refuses here rather than at 3am on the first spray.
|
|
141
|
+
*/
|
|
142
|
+
readonly limiter?: AuthLimiter | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Where the per-TENANT counters live. Same rule as `limiter`, and a separate instance because
|
|
145
|
+
* it enforces a separate `maxAttempts` — `rateLimit.orgMaxAttempts`, which a whole org shares.
|
|
146
|
+
*/
|
|
147
|
+
readonly orgLimiter?: AuthLimiter | undefined;
|
|
93
148
|
readonly mfa?: Partial<AuthMfaPolicy> | undefined;
|
|
94
149
|
readonly providers?: readonly OAuthProviderId[] | undefined;
|
|
150
|
+
/** Defaults to `'verified-email'` — both halves proven. See `OAuthLinkPolicy`. */
|
|
151
|
+
readonly link?: OAuthLinkPolicy | undefined;
|
|
95
152
|
}
|
|
96
153
|
|
|
97
154
|
export interface Auth {
|
|
@@ -101,8 +158,12 @@ export interface Auth {
|
|
|
101
158
|
readonly password: PasswordPolicy;
|
|
102
159
|
readonly rateLimit: AuthRateLimitPolicy;
|
|
103
160
|
readonly limiter: AuthLimiter;
|
|
161
|
+
/** The tenant bucket's own policy — `rateLimit` with `orgMaxAttempts` as its `maxAttempts`. */
|
|
162
|
+
readonly orgRateLimit: AuthRateLimitPolicy;
|
|
163
|
+
readonly orgLimiter: AuthLimiter;
|
|
104
164
|
readonly mfa: AuthMfaPolicy;
|
|
105
165
|
readonly providers: readonly OAuthProviderId[];
|
|
166
|
+
readonly link: OAuthLinkPolicy;
|
|
106
167
|
}
|
|
107
168
|
|
|
108
169
|
export function defineAuth(config: AuthConfigInput): Auth {
|
|
@@ -110,15 +171,37 @@ export function defineAuth(config: AuthConfigInput): Auth {
|
|
|
110
171
|
const session: SessionPolicy = { ...DEFAULT_SESSION_POLICY, ...config.session };
|
|
111
172
|
const password: PasswordPolicy = { ...DEFAULT_PASSWORD_POLICY, ...config.password };
|
|
112
173
|
const rateLimit: AuthRateLimitPolicy = { ...DEFAULT_AUTH_RATE_LIMIT, ...config.rateLimit };
|
|
174
|
+
const limiter = config.limiter ?? createAuthLimiter(clock, rateLimit);
|
|
175
|
+
assertAuthLimiterPolicy(rateLimit, limiter);
|
|
176
|
+
// The tenant bucket is a noisy-neighbour cap, not a credential-guessing allowance, so an app
|
|
177
|
+
// that declares `scope: 'shared'` for its LOCKOUT is not also required to ship a shared limiter
|
|
178
|
+
// for this one — per replica it approximates to `orgMaxAttempts × replicas`, which is a
|
|
179
|
+
// throughput ceiling and discloses nothing. An INJECTED org limiter is still compared, because
|
|
180
|
+
// then the app has made a claim about what it enforces and `Auth.orgRateLimit` reports it.
|
|
181
|
+
const orgLimits = orgRateLimit(rateLimit);
|
|
182
|
+
const orgLimiter = config.orgLimiter ?? createAuthLimiter(clock, orgLimits);
|
|
183
|
+
if (config.orgLimiter !== undefined) assertAuthLimiterPolicy(orgLimits, config.orgLimiter);
|
|
184
|
+
// Read through a widened local on purpose: the field's type is the literal `false`, so this
|
|
185
|
+
// branch is unreachable from TypeScript and reachable from every JS caller and every config
|
|
186
|
+
// parsed out of JSON — the same split `invariantColumns()` keeps its Proxy behind a compile
|
|
187
|
+
// error for. A declaration this package cannot enforce is refused where it is written.
|
|
188
|
+
const declaredMfa: { readonly required?: unknown } = config.mfa ?? {};
|
|
189
|
+
if (declaredMfa.required === true) throw mfaRequiredUnenforceable();
|
|
190
|
+
const mfa: AuthMfaPolicy = { issuer: config.mfa?.issuer ?? DEFAULT_MFA_ISSUER, required: false };
|
|
113
191
|
return Object.freeze({
|
|
114
192
|
adapter: config.adapter,
|
|
115
193
|
clock,
|
|
116
194
|
sessions: { store: config.adapter, policy: session, clock },
|
|
117
195
|
password,
|
|
118
196
|
rateLimit,
|
|
119
|
-
limiter
|
|
120
|
-
|
|
121
|
-
|
|
197
|
+
limiter,
|
|
198
|
+
// What the limiter in use actually enforces, not the derivation — the two differ in `scope`
|
|
199
|
+
// exactly when the app declared 'shared' and left this limiter to the default.
|
|
200
|
+
orgRateLimit: orgLimiter.policy,
|
|
201
|
+
orgLimiter,
|
|
202
|
+
mfa,
|
|
203
|
+
providers: config.providers ?? oauthProviderIds(),
|
|
204
|
+
link: config.link ?? 'verified-email',
|
|
122
205
|
});
|
|
123
206
|
}
|
|
124
207
|
|
|
@@ -134,7 +217,7 @@ export async function register(auth: Auth, input: RegisterInput): Promise<AuthUs
|
|
|
134
217
|
checkPasswordStrength(input.password, { policy: auth.password });
|
|
135
218
|
return await auth.adapter.createUser({
|
|
136
219
|
id: uuid(auth.clock),
|
|
137
|
-
email: input.email
|
|
220
|
+
email: normaliseEmail(input.email),
|
|
138
221
|
passwordHash: await hashPassword(input.password, auth.password.params),
|
|
139
222
|
orgId: input.orgId ?? null,
|
|
140
223
|
roles: input.roles ?? [],
|
|
@@ -165,10 +248,16 @@ export interface LoginResult {
|
|
|
165
248
|
export async function login(auth: Auth, input: LoginInput): Promise<LoginResult> {
|
|
166
249
|
const account = accountKey(input.email);
|
|
167
250
|
const ip = input.ip ?? null;
|
|
168
|
-
auth.limiter.assertAllowed(account);
|
|
169
|
-
if (ip !== null) auth.limiter.assertAllowed(ipKey(ip));
|
|
251
|
+
await auth.limiter.assertAllowed(account);
|
|
252
|
+
if (ip !== null) await auth.limiter.assertAllowed(ipKey(ip));
|
|
253
|
+
|
|
254
|
+
const user = await auth.adapter.findUserByEmail(normaliseEmail(input.email));
|
|
255
|
+
// The tenant bucket can only be consulted once the address resolves to an org, which is still
|
|
256
|
+
// before the KDF runs — the expensive half of this function — so it costs one map lookup and
|
|
257
|
+
// caps a spray that per-IP and per-account buckets both let through.
|
|
258
|
+
const org = user?.orgId ?? null;
|
|
259
|
+
if (org !== null) await auth.orgLimiter.assertAllowed(orgKey(org));
|
|
170
260
|
|
|
171
|
-
const user = await auth.adapter.findUserByEmail(input.email.trim().toLowerCase());
|
|
172
261
|
const usable = user !== null && user.disabledAt === null;
|
|
173
262
|
const verification = await verifyPassword({
|
|
174
263
|
hash: usable ? user.passwordHash : null,
|
|
@@ -177,13 +266,17 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
|
|
|
177
266
|
});
|
|
178
267
|
|
|
179
268
|
if (!verification.ok || user === null) {
|
|
180
|
-
auth.limiter.recordFailure(account);
|
|
181
|
-
if (ip !== null) auth.limiter.recordFailure(ipKey(ip));
|
|
269
|
+
await auth.limiter.recordFailure(account);
|
|
270
|
+
if (ip !== null) await auth.limiter.recordFailure(ipKey(ip));
|
|
271
|
+
if (org !== null) await auth.orgLimiter.recordFailure(orgKey(org));
|
|
182
272
|
throw loginFailed();
|
|
183
273
|
}
|
|
184
274
|
|
|
185
|
-
auth.limiter.recordSuccess(account);
|
|
186
|
-
if (ip !== null) auth.limiter.recordSuccess(ipKey(ip));
|
|
275
|
+
await auth.limiter.recordSuccess(account);
|
|
276
|
+
if (ip !== null) await auth.limiter.recordSuccess(ipKey(ip));
|
|
277
|
+
// No `recordSuccess` on the tenant bucket, and that asymmetry is the point: one member signing
|
|
278
|
+
// in successfully must not clear the count a broken integration is running up beside them, or
|
|
279
|
+
// the tenant cap is cleared by exactly the traffic that proves the tenant is still in use.
|
|
187
280
|
|
|
188
281
|
// Parameters were raised since this hash was written: upgrade it now, while we hold the
|
|
189
282
|
// plaintext. This is the only moment it is possible without asking the user for anything.
|
|
@@ -193,8 +286,10 @@ export async function login(auth: Auth, input: LoginInput): Promise<LoginResult>
|
|
|
193
286
|
});
|
|
194
287
|
}
|
|
195
288
|
|
|
196
|
-
// Password proven, second factor not.
|
|
197
|
-
//
|
|
289
|
+
// Password proven, second factor not. No half-authenticated session is written here and nothing
|
|
290
|
+
// is persisted to correlate the two legs — finishing MFA is the app's, and `X_MFA_REQUIRED`'s
|
|
291
|
+
// `fix:` is the instruction. The framework's own second leg is blocked on a sealed pending-MFA
|
|
292
|
+
// credential; the constraint is written down in `packages/auth/CLAUDE.md`.
|
|
198
293
|
if (user.mfaSecret !== null) throw mfaRequired(user.id);
|
|
199
294
|
|
|
200
295
|
const issued = await createSession(auth.sessions, {
|
|
@@ -226,8 +321,17 @@ export async function authenticate(auth: Auth, token: string | null): Promise<Po
|
|
|
226
321
|
return resolveActor({ kind: 'user', user, session });
|
|
227
322
|
}
|
|
228
323
|
|
|
324
|
+
/**
|
|
325
|
+
* The SECRET half is checked before the row is deleted, exactly as `verifySession` checks it. The
|
|
326
|
+
* id half is not a credential — it is in a device list, in a log line and in an audit row — so
|
|
327
|
+
* deleting on it alone made "sign this person out" an unauthenticated write for anyone who had
|
|
328
|
+
* ever seen one. Constant-time, and `false` for every failure, so it stays a poor oracle too.
|
|
329
|
+
*/
|
|
229
330
|
export async function logout(auth: Auth, token: string): Promise<boolean> {
|
|
230
331
|
const parsed = parseSessionToken(token);
|
|
231
332
|
if (parsed === null) return false;
|
|
333
|
+
const session = await auth.adapter.getSession(parsed.id);
|
|
334
|
+
if (session === null) return false;
|
|
335
|
+
if (!timingSafeEqual(sha256Hex(parsed.secret), session.tokenHash)) return false;
|
|
232
336
|
return await auth.adapter.deleteSession(parsed.id);
|
|
233
337
|
}
|
package/src/builtin-adapter.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
CreateUserInput,
|
|
15
15
|
SessionPatch,
|
|
16
16
|
UserPatch,
|
|
17
|
+
UserQuery,
|
|
17
18
|
} from './adapter';
|
|
18
19
|
import { authWriteFailed } from './errors';
|
|
19
20
|
|
|
@@ -54,8 +55,10 @@ const toUser = (row: Row): AuthUser => ({
|
|
|
54
55
|
orgId: textOrNull(row, 'org_id'),
|
|
55
56
|
roles: list(row, 'roles'),
|
|
56
57
|
permissions: list(row, 'permissions'),
|
|
58
|
+
scopes: list(row, 'scopes'),
|
|
57
59
|
mfaSecret: textOrNull(row, 'mfa_secret'),
|
|
58
60
|
recoveryCodeHashes: list(row, 'recovery_code_hashes'),
|
|
61
|
+
externalId: textOrNull(row, 'external_id'),
|
|
59
62
|
disabledAt: dateOrNull(row, 'disabled_at'),
|
|
60
63
|
createdAt: date(row, 'created_at'),
|
|
61
64
|
});
|
|
@@ -131,9 +134,11 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
131
134
|
|
|
132
135
|
async createUser(input: CreateUserInput): Promise<AuthUser> {
|
|
133
136
|
const row = await this.#db.one<Row>(sql`
|
|
134
|
-
insert into x_users (id, email, password_hash, org_id, roles,
|
|
137
|
+
insert into x_users (id, email, password_hash, org_id, roles, scopes, external_id,
|
|
138
|
+
created_at)
|
|
135
139
|
values (${input.id}, ${input.email}, ${input.passwordHash}, ${input.orgId},
|
|
136
|
-
${[...input.roles]}, ${input.
|
|
140
|
+
${[...input.roles]}, ${[...(input.scopes ?? [])]}, ${input.externalId ?? null},
|
|
141
|
+
${input.createdAt})
|
|
137
142
|
returning *`);
|
|
138
143
|
// An empty `returning` means no row landed. A user fabricated from `{}` would travel back
|
|
139
144
|
// out of `register()` as a successful registration with no identity in it.
|
|
@@ -155,12 +160,44 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
155
160
|
disabled_at = case when ${patch.disabledAt !== undefined}
|
|
156
161
|
then ${patch.disabledAt ?? null} else disabled_at end,
|
|
157
162
|
roles = case when ${patch.roles !== undefined}
|
|
158
|
-
then ${[...(patch.roles ?? [])]} else roles end
|
|
163
|
+
then ${[...(patch.roles ?? [])]} else roles end,
|
|
164
|
+
permissions = case when ${patch.permissions !== undefined}
|
|
165
|
+
then ${[...(patch.permissions ?? [])]} else permissions end,
|
|
166
|
+
scopes = case when ${patch.scopes !== undefined}
|
|
167
|
+
then ${[...(patch.scopes ?? [])]} else scopes end,
|
|
168
|
+
org_id = case when ${patch.orgId !== undefined}
|
|
169
|
+
then ${patch.orgId ?? null} else org_id end,
|
|
170
|
+
external_id = case when ${patch.externalId !== undefined}
|
|
171
|
+
then ${patch.externalId ?? null} else external_id end
|
|
159
172
|
where id = ${id}
|
|
160
173
|
returning *`);
|
|
161
174
|
return row === null ? null : toUser(row);
|
|
162
175
|
}
|
|
163
176
|
|
|
177
|
+
async findUserByExternalId(externalId: string): Promise<AuthUser | null> {
|
|
178
|
+
const row = await this.#db.one<Row>(
|
|
179
|
+
sql`select * from x_users where external_id = ${externalId}`,
|
|
180
|
+
);
|
|
181
|
+
return row === null ? null : toUser(row);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One statement, and the two filters are conditional predicates rather than assembled SQL, for
|
|
186
|
+
* the reason `updateUser`'s columns are: the statement text stays constant and nothing is
|
|
187
|
+
* 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.
|
|
189
|
+
*/
|
|
190
|
+
async listUsersByOrg(orgId: string, query?: UserQuery): Promise<readonly AuthUser[]> {
|
|
191
|
+
const role = query?.role ?? null;
|
|
192
|
+
const rows = await this.#db.query<Row>(sql`
|
|
193
|
+
select * from x_users
|
|
194
|
+
where org_id = ${orgId}
|
|
195
|
+
and (${query?.includeDisabled === true} or disabled_at is null)
|
|
196
|
+
and (${role === null} or ${role} = any (roles))
|
|
197
|
+
order by email asc`);
|
|
198
|
+
return rows.map(toUser);
|
|
199
|
+
}
|
|
200
|
+
|
|
164
201
|
async getSession(id: string): Promise<AuthSession | null> {
|
|
165
202
|
const row = await this.#db.one<Row>(sql`select * from x_sessions where id = ${id}`);
|
|
166
203
|
return row === null ? null : toSession(row);
|
|
@@ -201,6 +238,26 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
201
238
|
);
|
|
202
239
|
}
|
|
203
240
|
|
|
241
|
+
async deleteSessionsForUser(userId: string): Promise<number> {
|
|
242
|
+
return await this.#db.execute(sql`delete from x_sessions where user_id = ${userId}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Joins through `x_users` rather than reading an `org_id` off the session. The column does not
|
|
247
|
+
* exist on `x_sessions` and is not being added: a copy of the membership goes stale the moment
|
|
248
|
+
* somebody changes org, and a stale row means the 03:00 sweep leaves live exactly the sessions
|
|
249
|
+
* it was run to kill.
|
|
250
|
+
*/
|
|
251
|
+
async deleteSessionsForOrg(orgId: string): Promise<number> {
|
|
252
|
+
return await this.#db.execute(sql`
|
|
253
|
+
delete from x_sessions
|
|
254
|
+
where user_id in (select id from x_users where org_id = ${orgId})`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async deleteSessionsCreatedBefore(before: Date): Promise<number> {
|
|
258
|
+
return await this.#db.execute(sql`delete from x_sessions where created_at < ${before}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
204
261
|
async listSessions(userId: string): Promise<readonly AuthSession[]> {
|
|
205
262
|
const rows = await this.#db.query<Row>(
|
|
206
263
|
sql`select * from x_sessions where user_id = ${userId} order by last_seen_at desc`,
|
|
@@ -244,11 +301,31 @@ export class BuiltinAdapter implements AuthAdapter {
|
|
|
244
301
|
consumed_at = null`);
|
|
245
302
|
}
|
|
246
303
|
|
|
247
|
-
/**
|
|
248
|
-
|
|
304
|
+
/**
|
|
305
|
+
* One conditional UPDATE, and every part of the predicate is load-bearing. `consumed_at is null`
|
|
306
|
+
* on the UPDATE itself is what makes redemption single-use under concurrency — inside the
|
|
307
|
+
* subselect alone, two racing redemptions both pick the row and both consume it. `token_hash`
|
|
308
|
+
* is what keeps a wrong guess from consuming anything: a consume that happens before the
|
|
309
|
+
* comparison turns any unauthenticated POST into a way to kill the victim's emailed link. The
|
|
310
|
+
* predicate compares the digest and never the token, so it leaks what a `=` on a hash leaks.
|
|
311
|
+
* `order by created_at desc limit 1` addresses exactly one row: `unique (purpose, identifier)`
|
|
312
|
+
* (`tables.ts`) already allows only one, but an unbounded `update … where` consumes every row
|
|
313
|
+
* that matches, so a table missing that constraint would redeem links nobody presented.
|
|
314
|
+
*/
|
|
315
|
+
async takeVerification(
|
|
316
|
+
purpose: string,
|
|
317
|
+
identifier: string,
|
|
318
|
+
tokenHash: string,
|
|
319
|
+
): Promise<AuthVerification | null> {
|
|
249
320
|
const row = await this.#db.one<Row>(sql`
|
|
250
321
|
update x_verifications set consumed_at = now()
|
|
251
|
-
where
|
|
322
|
+
where consumed_at is null and id = (
|
|
323
|
+
select id from x_verifications
|
|
324
|
+
where purpose = ${purpose} and identifier = ${identifier}
|
|
325
|
+
and consumed_at is null and token_hash = ${tokenHash}
|
|
326
|
+
order by created_at desc
|
|
327
|
+
limit 1
|
|
328
|
+
)
|
|
252
329
|
returning *`);
|
|
253
330
|
return row === null ? null : toVerification(row);
|
|
254
331
|
}
|
package/src/directory.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Single responsibility: reading accounts back out, safely. A quarterly access review — "who in
|
|
2
|
+
// this org holds `admin`, and is any of them gone?" — was unanswerable: `UserStore` could find one
|
|
3
|
+
// user by address or by id and nothing else, so there was no way to enumerate an org's members at
|
|
4
|
+
// all. The projection is `describeApiKey`'s shape and its rule: never the password hash, never the
|
|
5
|
+
// TOTP secret, never a recovery code hash.
|
|
6
|
+
|
|
7
|
+
import type { AuthUser, UserQuery } from './adapter';
|
|
8
|
+
import type { Auth } from './auth';
|
|
9
|
+
import { authNotImplemented } from './errors';
|
|
10
|
+
|
|
11
|
+
/** Safe to render in an admin page, return from an MCP tool, or paste into a review ticket. */
|
|
12
|
+
export interface AuthUserSummary {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly email: string;
|
|
15
|
+
readonly orgId: string | null;
|
|
16
|
+
readonly roles: readonly string[];
|
|
17
|
+
readonly permissions: readonly string[];
|
|
18
|
+
readonly scopes: readonly string[];
|
|
19
|
+
readonly externalId: string | null;
|
|
20
|
+
/** Whether a second factor is enrolled. The secret itself never leaves the server. */
|
|
21
|
+
readonly mfaEnrolled: boolean;
|
|
22
|
+
readonly emailVerifiedAt: Date | null;
|
|
23
|
+
readonly disabledAt: Date | null;
|
|
24
|
+
readonly createdAt: Date;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The one projection. An allow-list rather than a delete-list, because a column added to
|
|
29
|
+
* `AuthUser` later must not appear here by default — that is how a `mfaSecret` ends up in an
|
|
30
|
+
* admin JSON response one refactor after somebody was careful.
|
|
31
|
+
*/
|
|
32
|
+
export function describeUser(user: AuthUser): AuthUserSummary {
|
|
33
|
+
return {
|
|
34
|
+
id: user.id,
|
|
35
|
+
email: user.email,
|
|
36
|
+
orgId: user.orgId,
|
|
37
|
+
roles: user.roles,
|
|
38
|
+
permissions: user.permissions,
|
|
39
|
+
scopes: user.scopes,
|
|
40
|
+
externalId: user.externalId,
|
|
41
|
+
mfaEnrolled: user.mfaSecret !== null,
|
|
42
|
+
emailVerifiedAt: user.emailVerifiedAt,
|
|
43
|
+
disabledAt: user.disabledAt,
|
|
44
|
+
createdAt: user.createdAt,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
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
|
+
/** Every member of one org, as summaries. `query.role` narrows it to one role's holders. */
|
|
55
|
+
export async function listOrgUsers(
|
|
56
|
+
auth: Auth,
|
|
57
|
+
orgId: string,
|
|
58
|
+
query?: UserQuery,
|
|
59
|
+
): Promise<readonly AuthUserSummary[]> {
|
|
60
|
+
const list = auth.adapter.listUsersByOrg?.bind(auth.adapter);
|
|
61
|
+
if (list === undefined) throw unsupported('listUsersByOrg', auth.adapter.name);
|
|
62
|
+
return (await list(orgId, query)).map(describeUser);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The account a provisioning system already knows by its own id. This is what a SCIM `PUT` or
|
|
67
|
+
* `PATCH` resolves against — an email address cannot be, because a rename changes it and the
|
|
68
|
+
* subject stays the same person.
|
|
69
|
+
*/
|
|
70
|
+
export async function findUserByExternalId(
|
|
71
|
+
auth: Auth,
|
|
72
|
+
externalId: string,
|
|
73
|
+
): Promise<AuthUser | null> {
|
|
74
|
+
const find = auth.adapter.findUserByExternalId?.bind(auth.adapter);
|
|
75
|
+
if (find === undefined) throw unsupported('findUserByExternalId', auth.adapter.name);
|
|
76
|
+
return await find(externalId);
|
|
77
|
+
}
|
package/src/email.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Single responsibility: the ONE normalisation an email address gets before anything uses it as an
|
|
2
|
+
// identity key — a user lookup, a user insert, or the lockout bucket that has to key the same way
|
|
3
|
+
// those two do. It lives above the `AuthAdapter` seam because an adapter that normalises hides a
|
|
4
|
+
// caller that forgot to: `MemoryAdapter` did and `BuiltinAdapter` did not, so "does this account
|
|
5
|
+
// exist" had two answers, one under `x dev` and another in production.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Trim, then lowercase. Nothing else, deliberately: stripping a `+tag` or a gmail dot would MERGE
|
|
9
|
+
* two addresses a person kept apart on purpose, which is an account takeover between colleagues at
|
|
10
|
+
* one domain rather than a convenience. RFC 5321 makes the local part case-sensitive in theory and
|
|
11
|
+
* no mail provider in practice treats it that way, so folding case is what stops one human holding
|
|
12
|
+
* two accounts at one address — and `x_users.email` is a plain `text ... unique`, whose uniqueness
|
|
13
|
+
* is exactly this string.
|
|
14
|
+
*/
|
|
15
|
+
export function normaliseEmail(email: string): string {
|
|
16
|
+
return email.trim().toLowerCase();
|
|
17
|
+
}
|