@ultimat3/auth 1.2.0 → 2.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 +231 -0
- package/README.md +416 -34
- package/package.json +5 -4
- package/src/adapter.ts +69 -4
- package/src/auth.ts +96 -14
- package/src/builtin-adapter.ts +83 -6
- package/src/directory.ts +77 -0
- package/src/email.ts +17 -0
- package/src/errors.ts +219 -14
- package/src/guards.ts +6 -26
- package/src/id-token.ts +48 -26
- package/src/index.ts +105 -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/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 +20 -8
- 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/oauth.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Single responsibility: the OAuth2/OIDC handshake. PKCE is mandatory rather than
|
|
2
2
|
// provider-dependent — an authorization code with no proof-of-possession is stealable from a
|
|
3
3
|
// redirect, and "this provider does not need it" is how that becomes a real incident. Provider
|
|
4
|
-
// configs are pure data
|
|
4
|
+
// configs are pure data and live in `oauth-registry.ts`: importing this file performs no network
|
|
5
|
+
// I/O and reads no env.
|
|
5
6
|
|
|
6
7
|
import { oauthStateInvalid } from './errors';
|
|
8
|
+
import { providerFor } from './oauth-registry';
|
|
7
9
|
import { base64Url, randomToken, sha256Bytes, timingSafeEqual } from './tokens';
|
|
8
10
|
|
|
9
11
|
export interface OAuthProvider {
|
|
@@ -18,67 +20,37 @@ export interface OAuthProvider {
|
|
|
18
20
|
* token. A list rather than one string because Google has issued both forms for years.
|
|
19
21
|
*/
|
|
20
22
|
readonly issuers: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* Where this provider publishes the keys its id tokens are signed with. `null` for a provider
|
|
25
|
+
* that issues no id token (GitHub). A provider with a key set can have a token from it verified
|
|
26
|
+
* on a channel that is not the token endpoint — IdP-initiated login, `form_post`, back-channel
|
|
27
|
+
* logout — which is what `jwks.ts` and `verifyIdToken({ keys })` exist for.
|
|
28
|
+
*/
|
|
29
|
+
readonly jwksUri: string | null;
|
|
21
30
|
readonly scopes: readonly string[];
|
|
22
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The literal `true`, not `boolean`. A provider config saying `usesPkce: false` was always
|
|
33
|
+
* invalid — an authorization code with no proof-of-possession is stealable from a redirect —
|
|
34
|
+
* and a comment saying so is not a build error. This is, and it deletes every downstream
|
|
35
|
+
* `if (provider.usesPkce)` branch along with the state it could ever have been false in.
|
|
36
|
+
*
|
|
37
|
+
* It stays the literal now that `registerOAuthProvider` is open to any app: the mechanism this
|
|
38
|
+
* package exists to own has to survive the opening, so an app cannot register a PKCE-less IdP.
|
|
39
|
+
*/
|
|
40
|
+
readonly usesPkce: true;
|
|
23
41
|
/** OIDC providers echo `nonce` in the id token; it binds the token to this browser. */
|
|
24
42
|
readonly usesNonce: boolean;
|
|
25
43
|
readonly clientIdEnv: string;
|
|
26
44
|
readonly clientSecretEnv: string;
|
|
27
45
|
}
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
// without it, so the flow asks for the verified list rather than guessing.
|
|
37
|
-
userEmailsUrl: 'https://api.github.com/user/emails',
|
|
38
|
-
issuers: [],
|
|
39
|
-
scopes: ['read:user', 'user:email'],
|
|
40
|
-
usesPkce: true,
|
|
41
|
-
usesNonce: false,
|
|
42
|
-
clientIdEnv: 'GITHUB_CLIENT_ID',
|
|
43
|
-
clientSecretEnv: 'GITHUB_CLIENT_SECRET',
|
|
44
|
-
},
|
|
45
|
-
google: {
|
|
46
|
-
id: 'google',
|
|
47
|
-
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
48
|
-
tokenUrl: 'https://oauth2.googleapis.com/token',
|
|
49
|
-
// Reached only when a narrowed `scopes` leaves the id token without an email claim.
|
|
50
|
-
userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
|
|
51
|
-
userEmailsUrl: null,
|
|
52
|
-
issuers: ['https://accounts.google.com', 'accounts.google.com'],
|
|
53
|
-
scopes: ['openid', 'email', 'profile'],
|
|
54
|
-
usesPkce: true,
|
|
55
|
-
usesNonce: true,
|
|
56
|
-
clientIdEnv: 'GOOGLE_CLIENT_ID',
|
|
57
|
-
clientSecretEnv: 'GOOGLE_CLIENT_SECRET',
|
|
58
|
-
},
|
|
59
|
-
apple: {
|
|
60
|
-
id: 'apple',
|
|
61
|
-
authorizeUrl: 'https://appleid.apple.com/auth/authorize',
|
|
62
|
-
tokenUrl: 'https://appleid.apple.com/auth/token',
|
|
63
|
-
// Apple returns claims in the id token only; there is no userinfo endpoint to call.
|
|
64
|
-
userInfoUrl: null,
|
|
65
|
-
userEmailsUrl: null,
|
|
66
|
-
issuers: ['https://appleid.apple.com'],
|
|
67
|
-
scopes: ['name', 'email'],
|
|
68
|
-
usesPkce: true,
|
|
69
|
-
usesNonce: true,
|
|
70
|
-
clientIdEnv: 'APPLE_CLIENT_ID',
|
|
71
|
-
// Apple alone does not accept a static secret: `APPLE_CLIENT_SECRET` must hold the ES256
|
|
72
|
-
// client-secret JWT signed with the .p8 key, which Apple expires every six months.
|
|
73
|
-
clientSecretEnv: 'APPLE_CLIENT_SECRET',
|
|
74
|
-
},
|
|
75
|
-
} as const satisfies Readonly<Record<string, OAuthProvider>>;
|
|
76
|
-
|
|
77
|
-
export type OAuthProviderId = keyof typeof OAUTH_PROVIDERS;
|
|
78
|
-
|
|
79
|
-
export const OAUTH_PROVIDER_IDS: readonly OAuthProviderId[] = Object.freeze(
|
|
80
|
-
Object.keys(OAUTH_PROVIDERS) as OAuthProviderId[],
|
|
81
|
-
);
|
|
47
|
+
/**
|
|
48
|
+
* Any registered provider's id — `string`, not a closed union, since 1.3.0. The union of three
|
|
49
|
+
* consumer IdPs made an enterprise OP unrepresentable at the type level, which is a constraint no
|
|
50
|
+
* configuration can escape. `providerFor(id)` is the runtime check that replaces it, and it
|
|
51
|
+
* throws the same `X_OAUTH_PROVIDER_UNKNOWN` the route already answered with.
|
|
52
|
+
*/
|
|
53
|
+
export type OAuthProviderId = string;
|
|
82
54
|
|
|
83
55
|
export interface PkcePair {
|
|
84
56
|
readonly verifier: string;
|
|
@@ -115,7 +87,7 @@ export interface BeginOAuthInput {
|
|
|
115
87
|
}
|
|
116
88
|
|
|
117
89
|
export function beginOAuth(input: BeginOAuthInput): OAuthHandshake {
|
|
118
|
-
const provider =
|
|
90
|
+
const provider = providerFor(input.provider);
|
|
119
91
|
const pkce = createPkce();
|
|
120
92
|
const state = randomToken(16);
|
|
121
93
|
const nonce = randomToken(16);
|
|
@@ -155,11 +127,12 @@ export interface OAuthCallback {
|
|
|
155
127
|
* tells an attacker which half to keep guessing at.
|
|
156
128
|
*/
|
|
157
129
|
export function assertOAuthCallback(handshake: OAuthHandshake, callback: OAuthCallback): void {
|
|
158
|
-
const provider =
|
|
130
|
+
const provider = providerFor(handshake.provider);
|
|
159
131
|
if (!timingSafeEqual(handshake.state, callback.state)) {
|
|
160
132
|
throw oauthStateInvalid(provider.id, 'state did not match the stored handshake');
|
|
161
133
|
}
|
|
162
|
-
|
|
134
|
+
// Unconditional: `usesPkce` is the literal `true`, so there is no provider to exempt.
|
|
135
|
+
if (handshake.verifier.length < 43) {
|
|
163
136
|
throw oauthStateInvalid(provider.id, 'no PKCE verifier was stored for this handshake');
|
|
164
137
|
}
|
|
165
138
|
if (callback.nonce !== undefined && !timingSafeEqual(handshake.nonce, callback.nonce)) {
|
package/src/password.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// otherwise response time answers "is this email registered?" for free.
|
|
5
5
|
|
|
6
6
|
import { passwordWeak } from './errors';
|
|
7
|
+
import { kdfGate } from './kdf-gate';
|
|
7
8
|
|
|
8
9
|
export interface PasswordParams {
|
|
9
10
|
readonly algorithm: 'argon2id';
|
|
@@ -57,15 +58,24 @@ const COMMON_PASSWORDS: ReadonlySet<string> = new Set([
|
|
|
57
58
|
'correcthorse',
|
|
58
59
|
]);
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Through `kdfGate()`, like every other KDF call here: 19 MiB of arena per hash and no per-source
|
|
63
|
+
* limiter that an IPv6 /64 cannot walk around means the ONLY thing bounding argon2 memory on this
|
|
64
|
+
* box is this gate. Past its queue it refuses with `X_OVERLOADED`, the same shed http's `admit`
|
|
65
|
+
* stage performs — a refusal that costs one comparison, not one arena.
|
|
66
|
+
*/
|
|
60
67
|
export async function hashPassword(
|
|
61
68
|
password: string,
|
|
62
69
|
params: PasswordParams = DEFAULT_PASSWORD_PARAMS,
|
|
63
70
|
): Promise<string> {
|
|
64
|
-
return await
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
return await kdfGate().run(
|
|
72
|
+
async () =>
|
|
73
|
+
await Bun.password.hash(password, {
|
|
74
|
+
algorithm: params.algorithm,
|
|
75
|
+
memoryCost: params.memoryCost,
|
|
76
|
+
timeCost: params.timeCost,
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/** Reads the parameters back out of a PHC string. `null` means "not a hash we recognise". */
|
|
@@ -103,13 +113,15 @@ export interface VerifyPasswordInput {
|
|
|
103
113
|
*/
|
|
104
114
|
export async function verifyPassword(input: VerifyPasswordInput): Promise<PasswordVerification> {
|
|
105
115
|
const params = input.params ?? DEFAULT_PASSWORD_PARAMS;
|
|
106
|
-
|
|
116
|
+
// Read into a local so the closure below narrows without a cast.
|
|
117
|
+
const hash = input.hash;
|
|
118
|
+
if (hash === null) {
|
|
107
119
|
await hashPassword(input.password, params);
|
|
108
120
|
return FAILED;
|
|
109
121
|
}
|
|
110
|
-
const ok = await Bun.password.verify(input.password,
|
|
122
|
+
const ok = await kdfGate().run(async () => await Bun.password.verify(input.password, hash));
|
|
111
123
|
if (!ok) return FAILED;
|
|
112
|
-
return { ok: true, needsRehash: needsRehash(
|
|
124
|
+
return { ok: true, needsRehash: needsRehash(hash, params) };
|
|
113
125
|
}
|
|
114
126
|
|
|
115
127
|
export interface StrengthOptions {
|
package/src/policy-bridge.ts
CHANGED
|
@@ -38,12 +38,18 @@ const withPermissions = (actor: Actor, permissions: readonly string[]): PolicyAc
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* A human. Roles come from the row and are expanded to permissions by policy; scopes
|
|
42
|
-
*
|
|
41
|
+
* A human. Roles come from the row and are expanded to permissions by policy; scopes come from
|
|
42
|
+
* the row too, and they are almost always empty.
|
|
43
|
+
*
|
|
44
|
+
* `scopes: []` used to be hardcoded here, which made a scope a thing no human could ever hold —
|
|
45
|
+
* so `hasScope(actor, 'tenancy:cross')`, whose own reasons name "an admin surface listing every
|
|
46
|
+
* org" and "support tooling", could only ever be satisfied by minting a `serviceActor` inside the
|
|
47
|
+
* handler. That discards the operator's identity and makes the sweep unattributable, which is the
|
|
48
|
+
* exact property the scope's required reason string exists to preserve.
|
|
43
49
|
*
|
|
44
50
|
* A session that has not satisfied an enrolled second factor resolves to an actor with no
|
|
45
|
-
* roles and no
|
|
46
|
-
* reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
|
|
51
|
+
* roles, no permissions and no scopes rather than an error, so a half-authenticated request can
|
|
52
|
+
* still reach the "finish MFA" route and nothing else. Login throws `X_MFA_REQUIRED` separately.
|
|
47
53
|
*/
|
|
48
54
|
export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor {
|
|
49
55
|
const mfaPending = user.mfaSecret !== null && !session.mfaSatisfied;
|
|
@@ -52,7 +58,7 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
|
|
|
52
58
|
id: user.id,
|
|
53
59
|
orgId: user.orgId ?? undefined,
|
|
54
60
|
roles: mfaPending ? [] : user.roles,
|
|
55
|
-
scopes: [],
|
|
61
|
+
scopes: mfaPending ? [] : user.scopes,
|
|
56
62
|
}),
|
|
57
63
|
mfaPending ? [] : user.permissions,
|
|
58
64
|
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Single responsibility: changing what a user may do, and rotating the credential that was issued
|
|
2
|
+
// under the old answer. `packages/auth/CLAUDE.md` has listed "rotate the session id on any
|
|
3
|
+
// privilege change (`rotateSession`)" as a non-negotiable since 1.0, and
|
|
4
|
+
// `SessionPolicy.rotateOnPrivilegeChange` has defaulted `true` — while `rotateSession` had no
|
|
5
|
+
// caller anywhere outside its own test and the flag was read by nothing. Per axiom 3 the rule did
|
|
6
|
+
// not exist. This is the caller that makes it exist.
|
|
7
|
+
|
|
8
|
+
import type { AuthUser, UserPatch } from './adapter';
|
|
9
|
+
import type { Auth } from './auth';
|
|
10
|
+
import { authWriteFailed } from './errors';
|
|
11
|
+
import { type IssuedSession, rotateSession, sessionCookie } from './session';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The fields whose change invalidates whatever the current cookie was issued under. `roles`,
|
|
15
|
+
* `permissions` and `scopes` are what an actor is built from; `passwordHash` is the credential
|
|
16
|
+
* itself; `orgId` moves every tenant-scoped read the session can perform.
|
|
17
|
+
*/
|
|
18
|
+
const PRIVILEGE_FIELDS = ['roles', 'permissions', 'scopes', 'orgId', 'passwordHash'] as const;
|
|
19
|
+
|
|
20
|
+
export interface UpdatePrivilegesResult {
|
|
21
|
+
readonly user: AuthUser;
|
|
22
|
+
/**
|
|
23
|
+
* The replacement session, present only when a current session was passed AND a privilege field
|
|
24
|
+
* actually changed. Set `cookie` on the response — the old id is already deleted, so a caller
|
|
25
|
+
* that drops this signs the user out rather than leaving a stale-privilege cookie live.
|
|
26
|
+
*/
|
|
27
|
+
readonly session?: IssuedSession | undefined;
|
|
28
|
+
readonly cookie?: string | undefined;
|
|
29
|
+
/** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
|
|
30
|
+
readonly changed: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const changedFields = (patch: UserPatch): readonly string[] =>
|
|
34
|
+
PRIVILEGE_FIELDS.filter((field) => patch[field] !== undefined);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apply the patch, then mint a new session id for the caller's own session when the patch touched
|
|
38
|
+
* privilege. Rotation is not about propagation — `authenticate` re-reads the user row on every
|
|
39
|
+
* request, so a revoked role takes effect on the very next one with no token-expiry lag, which is
|
|
40
|
+
* a better property than any claims-in-a-JWT design. It is about fixation: whoever planted or
|
|
41
|
+
* lifted the old cookie before the grant must not inherit the grant with it.
|
|
42
|
+
*
|
|
43
|
+
* `session` is optional because the common caller is an admin changing somebody ELSE's roles, and
|
|
44
|
+
* there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
|
|
45
|
+
* two are deliberately separate calls — silently killing an operator's own session mid-request is
|
|
46
|
+
* not something a role edit should decide on its own.
|
|
47
|
+
*/
|
|
48
|
+
export async function updatePrivileges(
|
|
49
|
+
auth: Auth,
|
|
50
|
+
userId: string,
|
|
51
|
+
patch: UserPatch,
|
|
52
|
+
session?: IssuedSession['session'] | undefined,
|
|
53
|
+
): Promise<UpdatePrivilegesResult> {
|
|
54
|
+
const user = await auth.adapter.updateUser(userId, patch);
|
|
55
|
+
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
56
|
+
|
|
57
|
+
const changed = changedFields(patch);
|
|
58
|
+
if (
|
|
59
|
+
changed.length === 0 ||
|
|
60
|
+
session === undefined ||
|
|
61
|
+
session.userId !== userId ||
|
|
62
|
+
!auth.sessions.policy.rotateOnPrivilegeChange
|
|
63
|
+
) {
|
|
64
|
+
return { user, changed };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const issued = await rotateSession(auth.sessions, session);
|
|
68
|
+
return {
|
|
69
|
+
user,
|
|
70
|
+
changed,
|
|
71
|
+
session: issued,
|
|
72
|
+
cookie: sessionCookie(issued.token, auth.sessions.policy),
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/rate-limit.ts
CHANGED
|
@@ -4,68 +4,198 @@
|
|
|
4
4
|
// bypassable. `loginFailed()` lives here so the throttle and the message can never drift apart.
|
|
5
5
|
|
|
6
6
|
import type { Clock } from '@ultimat3/core';
|
|
7
|
-
import {
|
|
7
|
+
import { normaliseEmail } from './email';
|
|
8
|
+
import {
|
|
9
|
+
AuthError,
|
|
10
|
+
accountLocked,
|
|
11
|
+
authLimiterNotShared,
|
|
12
|
+
authLimiterPolicyMismatch,
|
|
13
|
+
} from './errors';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Where a limiter's counters live. A limiter says which it provides; `AuthRateLimitPolicy` says
|
|
17
|
+
* which the deployment requires, and the two are checked against each other once, at `defineAuth`.
|
|
18
|
+
*/
|
|
19
|
+
export type AuthLimiterScope = 'process' | 'shared';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Hard bound on tracked keys. A key is one identity — `account:<email>` or `ip:<addr>` — so the
|
|
23
|
+
* natural cardinality is lower than `@ultimat3/http`'s per-route buckets, and so is the cap.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_MAX_AUTH_LIMIT_KEYS = 10_000;
|
|
8
26
|
|
|
9
27
|
export interface AuthRateLimitPolicy {
|
|
10
28
|
/** Failures inside `windowMs` before the key is locked. */
|
|
11
29
|
readonly maxAttempts: number;
|
|
12
30
|
readonly windowMs: number;
|
|
13
31
|
readonly lockoutMs: number;
|
|
32
|
+
/**
|
|
33
|
+
* The third bucket: failures inside `windowMs` against one TENANT, across every member and
|
|
34
|
+
* every source address. Per-IP buckets each grant their own quota and per-account buckets
|
|
35
|
+
* protect one person, so a misconfigured integration spraying one org's logins from 400
|
|
36
|
+
* addresses is capped by neither — it saturates the shared limiter and every other tenant's
|
|
37
|
+
* logins slow down behind it.
|
|
38
|
+
*
|
|
39
|
+
* A separate number and not `maxAttempts`, because a whole tenant sharing five attempts is a
|
|
40
|
+
* denial of service against that tenant. Defaults to `maxAttempts * ORG_ATTEMPT_FACTOR`.
|
|
41
|
+
*/
|
|
42
|
+
readonly orgMaxAttempts?: number | undefined;
|
|
43
|
+
/** Bound on the in-memory table. Defaults to `DEFAULT_MAX_AUTH_LIMIT_KEYS`. */
|
|
44
|
+
readonly maxKeys?: number | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* What this deployment requires of the limiter. `'shared'` says `maxAttempts` is the whole
|
|
47
|
+
* fleet's allowance, and a per-process limiter then refuses to build — because N replicas each
|
|
48
|
+
* counting their own failures let an account survive `maxAttempts × N` guesses, and a lockout
|
|
49
|
+
* one replica established is invisible to the rest.
|
|
50
|
+
*/
|
|
51
|
+
readonly scope: AuthLimiterScope;
|
|
14
52
|
}
|
|
15
53
|
|
|
54
|
+
/** One tenant is worth this many individuals' allowances before it is throttled as a tenant. */
|
|
55
|
+
export const ORG_ATTEMPT_FACTOR = 20;
|
|
56
|
+
|
|
16
57
|
export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
|
|
17
58
|
maxAttempts: 5,
|
|
18
59
|
windowMs: 15 * 60 * 1000,
|
|
19
60
|
lockoutMs: 15 * 60 * 1000,
|
|
61
|
+
orgMaxAttempts: 5 * ORG_ATTEMPT_FACTOR,
|
|
62
|
+
maxKeys: DEFAULT_MAX_AUTH_LIMIT_KEYS,
|
|
63
|
+
// One process is the only thing this package can promise without being told.
|
|
64
|
+
scope: 'process',
|
|
20
65
|
});
|
|
21
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Async on every member, so a shared implementation can exist at all: a lockout that holds across
|
|
69
|
+
* replicas is a network round trip, and a synchronous signature has no way to wait for one. The
|
|
70
|
+
* in-memory limiter below answers immediately — the cost is one already-settled promise per call,
|
|
71
|
+
* on a path that is about to run a KDF.
|
|
72
|
+
*/
|
|
22
73
|
export interface AuthLimiter {
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
74
|
+
/**
|
|
75
|
+
* The limits this limiter actually enforces, including where it keeps its counters. Stated
|
|
76
|
+
* rather than assumed, because `defineAuth` compares it against the app's declaration: a
|
|
77
|
+
* limiter counting to 50 under a policy that says 5 makes `Auth.rateLimit` a number the
|
|
78
|
+
* operator reads and nothing enforces.
|
|
79
|
+
*/
|
|
80
|
+
readonly policy: AuthRateLimitPolicy;
|
|
81
|
+
/** Rejects with `X_ACCOUNT_LOCKED` if the key is inside a lockout. Call before any KDF work. */
|
|
82
|
+
assertAllowed(key: string): Promise<void>;
|
|
83
|
+
recordFailure(key: string): Promise<void>;
|
|
26
84
|
/** A success clears the window: a legitimate user is not punished for a typo yesterday. */
|
|
27
|
-
recordSuccess(key: string): void
|
|
28
|
-
lockedUntil(key: string): Date | null
|
|
29
|
-
reset(): void
|
|
85
|
+
recordSuccess(key: string): Promise<void>;
|
|
86
|
+
lockedUntil(key: string): Promise<Date | null>;
|
|
87
|
+
reset(): Promise<void>;
|
|
30
88
|
}
|
|
31
89
|
|
|
32
|
-
|
|
90
|
+
/** What `createAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
|
|
91
|
+
export interface MemoryAuthLimiter extends AuthLimiter {
|
|
92
|
+
readonly size: number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The SAME normalisation the lookup uses, from the one declaration — a bucket keyed differently
|
|
97
|
+
* from the row it protects hands a sprayer a fresh attempt budget per spelling of one address.
|
|
98
|
+
*/
|
|
99
|
+
export const accountKey = (email: string): string => `account:${normaliseEmail(email)}`;
|
|
33
100
|
|
|
34
101
|
export const ipKey = (ip: string): string => `ip:${ip}`;
|
|
35
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The tenant bucket's key shape, declared here even though the general noisy-neighbour case lives
|
|
105
|
+
* in `@ultimat3/http` and `@ultimat3/jobs`: three limiters that spell one tenant three ways cannot
|
|
106
|
+
* be read together during an incident.
|
|
107
|
+
*/
|
|
108
|
+
export const orgKey = (orgId: string): string => `org:${orgId}`;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The policy the tenant limiter enforces — the same window and lockout, a wider allowance. One
|
|
112
|
+
* derivation, read by `defineAuth` when it builds the limiter and again when it checks an
|
|
113
|
+
* injected one, so the two cannot disagree about what was declared.
|
|
114
|
+
*/
|
|
115
|
+
export function orgRateLimit(policy: AuthRateLimitPolicy): AuthRateLimitPolicy {
|
|
116
|
+
return {
|
|
117
|
+
...policy,
|
|
118
|
+
maxAttempts: policy.orgMaxAttempts ?? policy.maxAttempts * ORG_ATTEMPT_FACTOR,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
36
122
|
interface Bucket {
|
|
37
123
|
failures: number[];
|
|
38
124
|
lockedUntilMs: number;
|
|
125
|
+
/**
|
|
126
|
+
* The instant this entry becomes indistinguishable from a missing one: the window has emptied
|
|
127
|
+
* and any lockout has expired, so it answers exactly as a first-ever attempt does.
|
|
128
|
+
*/
|
|
129
|
+
forgetAtMs: number;
|
|
39
130
|
}
|
|
40
131
|
|
|
132
|
+
/** An idle limiter still sweeps this often, so one spray's state does not sit until the next. */
|
|
133
|
+
const SWEEP_EVERY_MS = 60_000;
|
|
134
|
+
|
|
41
135
|
/**
|
|
42
136
|
* Sliding window over an injected `Clock` — never `Date.now()`, so a lockout test is
|
|
43
137
|
* deterministic instead of a sleep. In-memory per process; a multi-process deployment passes
|
|
44
138
|
* a shared implementation of the same interface.
|
|
139
|
+
*
|
|
140
|
+
* Bounded, because half the keys are attacker-chosen: `ipKey` mints one per source address, so
|
|
141
|
+
* a spray from an IPv6 /64 is a fresh key per attempt and an unbounded map is an OOM. Two rules
|
|
142
|
+
* keep it flat. An entry whose window has emptied and whose lockout has expired is *forgotten*,
|
|
143
|
+
* not evicted — it answers exactly as a missing one, so dropping it changes no decision. Only if
|
|
144
|
+
* that is not enough does the cap evict live state, and then the entries nearest to being
|
|
145
|
+
* forgotten anyway go first: a locked account is the last key to go, so filling the table is not
|
|
146
|
+
* a way to buy back attempts against one.
|
|
45
147
|
*/
|
|
46
148
|
export function createAuthLimiter(
|
|
47
149
|
clock: Clock,
|
|
48
150
|
policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
|
|
49
|
-
):
|
|
151
|
+
): MemoryAuthLimiter {
|
|
50
152
|
const buckets = new Map<string, Bucket>();
|
|
153
|
+
const maxKeys = Math.max(1, Math.floor(policy.maxKeys ?? DEFAULT_MAX_AUTH_LIMIT_KEYS));
|
|
154
|
+
const evictTo = Math.max(1, Math.floor(maxKeys * 0.9));
|
|
155
|
+
let lastSweepMs = Number.NEGATIVE_INFINITY;
|
|
51
156
|
|
|
52
157
|
const bucketFor = (key: string): Bucket => {
|
|
53
158
|
const existing = buckets.get(key);
|
|
54
159
|
if (existing !== undefined) return existing;
|
|
55
|
-
const fresh: Bucket = { failures: [], lockedUntilMs: 0 };
|
|
160
|
+
const fresh: Bucket = { failures: [], lockedUntilMs: 0, forgetAtMs: 0 };
|
|
56
161
|
buckets.set(key, fresh);
|
|
57
162
|
return fresh;
|
|
58
163
|
};
|
|
59
164
|
|
|
165
|
+
const sweep = (nowMs: number): void => {
|
|
166
|
+
lastSweepMs = nowMs;
|
|
167
|
+
for (const [key, bucket] of buckets) {
|
|
168
|
+
if (bucket.forgetAtMs <= nowMs) buckets.delete(key);
|
|
169
|
+
}
|
|
170
|
+
if (buckets.size <= maxKeys) return;
|
|
171
|
+
// Batched down to `evictTo` so this sort is paid once per 10% of the cap, not per failure.
|
|
172
|
+
// A live lockout outranks its deadline: two entries recorded a second apart are otherwise
|
|
173
|
+
// ordered by recency, which would let a spray evict the account it just locked.
|
|
174
|
+
const locked = (bucket: Bucket): number => (bucket.lockedUntilMs > nowMs ? 1 : 0);
|
|
175
|
+
const nearestForgotten = [...buckets.entries()].sort(
|
|
176
|
+
(a, b) => locked(a[1]) - locked(b[1]) || a[1].forgetAtMs - b[1].forgetAtMs,
|
|
177
|
+
);
|
|
178
|
+
for (const [key] of nearestForgotten) {
|
|
179
|
+
if (buckets.size <= evictTo) break;
|
|
180
|
+
buckets.delete(key);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
60
184
|
return {
|
|
61
|
-
|
|
185
|
+
// The resolved policy, not the argument: `maxKeys` is normalized above, and a limiter that
|
|
186
|
+
// reported an unresolved bound would be reporting something it does not enforce.
|
|
187
|
+
policy: { ...policy, maxKeys, scope: 'process' },
|
|
188
|
+
get size() {
|
|
189
|
+
return buckets.size;
|
|
190
|
+
},
|
|
191
|
+
async assertAllowed(key) {
|
|
62
192
|
const bucket = buckets.get(key);
|
|
63
193
|
if (bucket === undefined) return;
|
|
64
194
|
const nowMs = clock.now().getTime();
|
|
65
195
|
if (bucket.lockedUntilMs <= nowMs) return;
|
|
66
196
|
throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
|
|
67
197
|
},
|
|
68
|
-
recordFailure(key) {
|
|
198
|
+
async recordFailure(key) {
|
|
69
199
|
const nowMs = clock.now().getTime();
|
|
70
200
|
const bucket = bucketFor(key);
|
|
71
201
|
bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
|
|
@@ -73,21 +203,54 @@ export function createAuthLimiter(
|
|
|
73
203
|
if (bucket.failures.length >= policy.maxAttempts) {
|
|
74
204
|
bucket.lockedUntilMs = nowMs + policy.lockoutMs;
|
|
75
205
|
}
|
|
206
|
+
// The newest failure leaves the window last, so that is the earliest this entry is free —
|
|
207
|
+
// unless a lockout outlives it. Recorded here because this is the only growth path.
|
|
208
|
+
bucket.forgetAtMs = Math.max(bucket.lockedUntilMs, nowMs + policy.windowMs);
|
|
209
|
+
if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
|
|
76
210
|
},
|
|
77
|
-
recordSuccess(key) {
|
|
211
|
+
async recordSuccess(key) {
|
|
78
212
|
buckets.delete(key);
|
|
79
213
|
},
|
|
80
|
-
lockedUntil(key) {
|
|
214
|
+
async lockedUntil(key) {
|
|
81
215
|
const bucket = buckets.get(key);
|
|
82
216
|
if (bucket === undefined || bucket.lockedUntilMs <= clock.now().getTime()) return null;
|
|
83
217
|
return new Date(bucket.lockedUntilMs);
|
|
84
218
|
},
|
|
85
|
-
reset() {
|
|
219
|
+
async reset() {
|
|
86
220
|
buckets.clear();
|
|
87
221
|
},
|
|
88
222
|
};
|
|
89
223
|
}
|
|
90
224
|
|
|
225
|
+
/**
|
|
226
|
+
* The numbers a limiter is compared on. `maxKeys` is absent deliberately: it bounds one process'
|
|
227
|
+
* table, so a shared limiter has no opinion on it and comparing it would refuse a correct pairing.
|
|
228
|
+
*/
|
|
229
|
+
const ENFORCED_LIMITS = ['maxAttempts', 'windowMs', 'lockoutMs'] as const;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* At `defineAuth`, never at the first login. Two ways the declared policy and the limiter in use
|
|
233
|
+
* can disagree, and both end the same way — an operator reading `Auth.rateLimit` and believing a
|
|
234
|
+
* number nothing enforces:
|
|
235
|
+
*
|
|
236
|
+
* - a per-node limiter under a `'shared'` declaration is a lockout worth `maxAttempts × replicas`;
|
|
237
|
+
* - a limiter counting to its own numbers makes every other field of the policy decorative.
|
|
238
|
+
*
|
|
239
|
+
* The declaration stays the app's — this package cannot see how many processes the image runs,
|
|
240
|
+
* and inferring it would be wrong the first time the app scaled — so the framework's job is to
|
|
241
|
+
* refuse the pairing, not to guess either half of it.
|
|
242
|
+
*/
|
|
243
|
+
export function assertAuthLimiterPolicy(declared: AuthRateLimitPolicy, limiter: AuthLimiter): void {
|
|
244
|
+
if (declared.scope === 'shared' && limiter.policy.scope !== 'shared') {
|
|
245
|
+
throw authLimiterNotShared(limiter.policy.scope);
|
|
246
|
+
}
|
|
247
|
+
for (const field of ENFORCED_LIMITS) {
|
|
248
|
+
if (declared[field] !== limiter.policy[field]) {
|
|
249
|
+
throw authLimiterPolicyMismatch(field, declared[field], limiter.policy[field]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
91
254
|
/**
|
|
92
255
|
* The single generic failure. Every credential path — unknown email, wrong password, disabled
|
|
93
256
|
* account, unverified address — throws exactly this object shape, so the rendered error is
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Single responsibility: taking a credential away, at the three blast radii an incident actually
|
|
2
|
+
// has — one person, one tenant, everything issued before an instant. `deleteOtherSessions` was the
|
|
3
|
+
// only one of the three the seam could express, so "kill every session in this org, now" was a
|
|
4
|
+
// per-user loop over an enumeration the adapter could not do, or a `TRUNCATE` that killed every
|
|
5
|
+
// other tenant with it. Doing nothing meant thirty days, which is the absolute TTL.
|
|
6
|
+
|
|
7
|
+
import { logger } from '@ultimat3/core';
|
|
8
|
+
import type { AuthUser } from './adapter';
|
|
9
|
+
import type { Auth } from './auth';
|
|
10
|
+
import { authNotImplemented, authWriteFailed } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every revocation is logged before it runs, with the reason the caller gave. An incident review
|
|
14
|
+
* asks "who killed these sessions and why" and a `delete` with no line answers neither; the reason
|
|
15
|
+
* is a required argument for the same rule `crossTenant()` requires one.
|
|
16
|
+
*/
|
|
17
|
+
const record = (operation: string, scope: string, reason: string): void => {
|
|
18
|
+
logger.warn('auth.revocation', { operation, scope, reason });
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const unsupported = (method: string, adapterName: string) =>
|
|
22
|
+
authNotImplemented(
|
|
23
|
+
`${adapterName}.${method}()`,
|
|
24
|
+
`implement ${method}() on your AuthAdapter — BuiltinAdapter (Postgres) and MemoryAdapter are the two reference implementations, in packages/auth/src`,
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** Every session this user holds, their current one included. Returns how many died. */
|
|
28
|
+
export async function revokeUserSessions(
|
|
29
|
+
auth: Auth,
|
|
30
|
+
userId: string,
|
|
31
|
+
reason: string,
|
|
32
|
+
): Promise<number> {
|
|
33
|
+
const remove = auth.adapter.deleteSessionsForUser?.bind(auth.adapter);
|
|
34
|
+
if (remove === undefined) throw unsupported('deleteSessionsForUser', auth.adapter.name);
|
|
35
|
+
record('revokeUserSessions', userId, reason);
|
|
36
|
+
return await remove(userId);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every session held by every member of one org. The 03:00 answer to a confirmed credential
|
|
41
|
+
* compromise in one tenant, and the one thing the seam could not express at all.
|
|
42
|
+
*/
|
|
43
|
+
export async function revokeOrgSessions(
|
|
44
|
+
auth: Auth,
|
|
45
|
+
orgId: string,
|
|
46
|
+
reason: string,
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const remove = auth.adapter.deleteSessionsForOrg?.bind(auth.adapter);
|
|
49
|
+
if (remove === undefined) throw unsupported('deleteSessionsForOrg', auth.adapter.name);
|
|
50
|
+
record('revokeOrgSessions', orgId, reason);
|
|
51
|
+
return await remove(orgId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything minted before an instant. The sweep that follows a rotated `SESSION_SECRET` or a
|
|
56
|
+
* suspected dump: it needs no enumeration of users, so it is the one that works when the list of
|
|
57
|
+
* affected accounts is exactly what is not known yet.
|
|
58
|
+
*/
|
|
59
|
+
export async function revokeSessionsCreatedBefore(
|
|
60
|
+
auth: Auth,
|
|
61
|
+
before: Date,
|
|
62
|
+
reason: string,
|
|
63
|
+
): Promise<number> {
|
|
64
|
+
const remove = auth.adapter.deleteSessionsCreatedBefore?.bind(auth.adapter);
|
|
65
|
+
if (remove === undefined) throw unsupported('deleteSessionsCreatedBefore', auth.adapter.name);
|
|
66
|
+
record('revokeSessionsCreatedBefore', before.toISOString(), reason);
|
|
67
|
+
return await remove(before);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DisabledUser {
|
|
71
|
+
readonly user: AuthUser;
|
|
72
|
+
readonly sessionsRevoked: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `disabledAt` is read by `login`, by `authenticate` and by the OAuth path — and until this
|
|
77
|
+
* function existed, no code path anywhere ever SET it. Offboarding was an UPDATE somebody typed.
|
|
78
|
+
*
|
|
79
|
+
* Stamping the column is not enough on its own: `authenticate` re-reads the user row on every
|
|
80
|
+
* request, so a disabled account stops working on its next request, but the live session row is
|
|
81
|
+
* still there and still slides. The sessions go in the same call, in that order — stamp first, so
|
|
82
|
+
* a request racing the revocation finds the row already disabled.
|
|
83
|
+
*/
|
|
84
|
+
export async function disableUser(
|
|
85
|
+
auth: Auth,
|
|
86
|
+
userId: string,
|
|
87
|
+
reason: string,
|
|
88
|
+
): Promise<DisabledUser> {
|
|
89
|
+
const user = await auth.adapter.updateUser(userId, { disabledAt: auth.clock.now() });
|
|
90
|
+
// No row came back: either there is no such user, or the adapter did not return the update.
|
|
91
|
+
// Both mean the account is not known to be disabled, and reporting success would be a lie.
|
|
92
|
+
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
93
|
+
record('disableUser', userId, reason);
|
|
94
|
+
return { user, sessionsRevoked: await revokeUserSessions(auth, userId, reason) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The inverse. No sessions are restored — a re-enabled account signs in again, deliberately. */
|
|
98
|
+
export async function enableUser(auth: Auth, userId: string): Promise<AuthUser | null> {
|
|
99
|
+
return await auth.adapter.updateUser(userId, { disabledAt: null });
|
|
100
|
+
}
|