@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/policy-bridge.ts
CHANGED
|
@@ -28,7 +28,12 @@ export interface ServiceIdentity {
|
|
|
28
28
|
/** The four `ActorKind`s, as the four things that can be holding a credential. */
|
|
29
29
|
export type AuthIdentity =
|
|
30
30
|
| { readonly kind: 'user'; readonly user: AuthUser; readonly session: AuthSession }
|
|
31
|
-
| {
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: 'agent';
|
|
33
|
+
readonly apiKey: AuthApiKeyRecord;
|
|
34
|
+
/** What the key's owner may do — `null` for a key no user owns. See `actorFromApiKey`. */
|
|
35
|
+
readonly ownerGrants: readonly string[] | null;
|
|
36
|
+
}
|
|
32
37
|
| { readonly kind: 'service'; readonly service: ServiceIdentity }
|
|
33
38
|
| { readonly kind: 'anonymous' };
|
|
34
39
|
|
|
@@ -64,20 +69,58 @@ export function actorFromUser(user: AuthUser, session: AuthSession): PolicyActor
|
|
|
64
69
|
);
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
/** `*` and `<resource>:*` — the two spellings a role uses to be granted everything. */
|
|
73
|
+
export const isWildcardScope = (scope: string): boolean => scope === '*' || scope.endsWith(':*');
|
|
74
|
+
|
|
75
|
+
// `@ultimat3/policy`'s own reading of a grant's resource, mirrored: same tier, so not imported.
|
|
76
|
+
const resourceOf = (grant: string): string => grant.split(':')[0] ?? grant;
|
|
77
|
+
|
|
78
|
+
/** Whether one of the owner's grants reaches a named permission. A grant MAY be a wildcard. */
|
|
79
|
+
const covers = (grant: string, wanted: string): boolean =>
|
|
80
|
+
grant === '*' ||
|
|
81
|
+
grant === wanted ||
|
|
82
|
+
(grant.endsWith(':*') && resourceOf(grant) === resourceOf(wanted));
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a key may carry of what it names. Two cuts, and both only ever remove:
|
|
86
|
+
*
|
|
87
|
+
* - a wildcard scope is dropped. `issueApiKey` refuses to mint one, and a row written before that
|
|
88
|
+
* refusal (or by hand) must not keep the reach the refusal exists to deny;
|
|
89
|
+
* - a key a user OWNS keeps only the scopes that owner could exercise themselves. A key was
|
|
90
|
+
* minted with whatever list its issuer typed, so without this a member could hold a key
|
|
91
|
+
* reaching further than their own account — and it kept that reach after a demotion.
|
|
92
|
+
*
|
|
93
|
+
* `ownerGrants` is `null` for a key no user owns: there is nobody to be narrower than.
|
|
94
|
+
*/
|
|
95
|
+
export function apiKeyScopes(
|
|
96
|
+
scopes: readonly string[],
|
|
97
|
+
ownerGrants: readonly string[] | null,
|
|
98
|
+
): readonly string[] {
|
|
99
|
+
return scopes.filter(
|
|
100
|
+
(scope) =>
|
|
101
|
+
!isWildcardScope(scope) &&
|
|
102
|
+
(ownerGrants === null || ownerGrants.some((grant) => covers(grant, scope))),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
/**
|
|
68
|
-
* An MCP/LLM caller. The actor's scopes are
|
|
69
|
-
* user's roles, never a default set. An agent that can do more than its key says
|
|
70
|
-
* failure mode this bridge exists to prevent.
|
|
107
|
+
* An MCP/LLM caller. The actor's scopes are the key's scopes cut by `apiKeyScopes` — never the
|
|
108
|
+
* owning user's roles, never a default set. An agent that can do more than its key says, or more
|
|
109
|
+
* than its owner can, is the whole failure mode this bridge exists to prevent.
|
|
71
110
|
*/
|
|
72
|
-
export function actorFromApiKey(
|
|
111
|
+
export function actorFromApiKey(
|
|
112
|
+
key: AuthApiKeyRecord,
|
|
113
|
+
ownerGrants: readonly string[] | null,
|
|
114
|
+
): PolicyActor {
|
|
115
|
+
const scopes = apiKeyScopes(key.scopes, ownerGrants);
|
|
73
116
|
return withPermissions(
|
|
74
117
|
agentActor({
|
|
75
118
|
id: key.id,
|
|
76
119
|
orgId: key.orgId ?? undefined,
|
|
77
120
|
roles: [],
|
|
78
|
-
scopes
|
|
121
|
+
scopes,
|
|
79
122
|
}),
|
|
80
|
-
|
|
123
|
+
scopes,
|
|
81
124
|
);
|
|
82
125
|
}
|
|
83
126
|
|
|
@@ -100,7 +143,7 @@ export function resolveActor(identity: AuthIdentity): PolicyActor {
|
|
|
100
143
|
case 'user':
|
|
101
144
|
return actorFromUser(identity.user, identity.session);
|
|
102
145
|
case 'agent':
|
|
103
|
-
return actorFromApiKey(identity.apiKey);
|
|
146
|
+
return actorFromApiKey(identity.apiKey, identity.ownerGrants);
|
|
104
147
|
case 'service':
|
|
105
148
|
return actorFromService(identity.service);
|
|
106
149
|
case 'anonymous':
|
package/src/privileges.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
// Single responsibility: changing what a user may do, and
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
1
|
+
// Single responsibility: changing what a user may do, and ending the credentials issued under the
|
|
2
|
+
// old answer — the caller's session is rotated on any privilege change, and a changed password
|
|
3
|
+
// ends every other session the user holds.
|
|
7
4
|
|
|
8
5
|
import type { AuthUser, UserPatch } from './adapter';
|
|
9
6
|
import type { Auth } from './auth';
|
|
10
7
|
import { authWriteFailed } from './errors';
|
|
11
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type IssuedSession,
|
|
10
|
+
remainingMaxAgeSeconds,
|
|
11
|
+
rotateSession,
|
|
12
|
+
sessionCookie,
|
|
13
|
+
} from './session';
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* The fields whose change invalidates whatever the current cookie was issued under. `roles`,
|
|
@@ -28,6 +30,8 @@ export interface UpdatePrivilegesResult {
|
|
|
28
30
|
readonly cookie?: string | undefined;
|
|
29
31
|
/** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
|
|
30
32
|
readonly changed: readonly string[];
|
|
33
|
+
/** Sessions a `passwordHash` change ended. Zero for every other patch. */
|
|
34
|
+
readonly sessionsRevoked: number;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
const changedFields = (patch: UserPatch): readonly string[] =>
|
|
@@ -44,6 +48,12 @@ const changedFields = (patch: UserPatch): readonly string[] =>
|
|
|
44
48
|
* there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
|
|
45
49
|
* two are deliberately separate calls — silently killing an operator's own session mid-request is
|
|
46
50
|
* not something a role edit should decide on its own.
|
|
51
|
+
*
|
|
52
|
+
* A `passwordHash` change is the exception, and it decides for itself: the password is what every
|
|
53
|
+
* OTHER session of this user was issued under, so they all end here. The caller's own session —
|
|
54
|
+
* passed in, and this user's — is the one that survives (rotated, or kept when rotation is off);
|
|
55
|
+
* with no session of the user's own, as in a reset or an admin's change, none survives. A changed
|
|
56
|
+
* password that left a lifted cookie working would not have changed anything for whoever holds it.
|
|
47
57
|
*/
|
|
48
58
|
export async function updatePrivileges(
|
|
49
59
|
auth: Auth,
|
|
@@ -51,24 +61,42 @@ export async function updatePrivileges(
|
|
|
51
61
|
patch: UserPatch,
|
|
52
62
|
session?: IssuedSession['session'] | undefined,
|
|
53
63
|
): Promise<UpdatePrivilegesResult> {
|
|
64
|
+
const changed = changedFields(patch);
|
|
65
|
+
const own = session !== undefined && session.userId === userId ? session : undefined;
|
|
66
|
+
const credentialChanged = changed.includes('passwordHash');
|
|
54
67
|
const user = await auth.adapter.updateUser(userId, patch);
|
|
55
68
|
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
69
|
+
if (changed.length === 0) return { user, changed, sessionsRevoked: 0 };
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
71
|
+
// Every OTHER session ends BEFORE the rotation, keyed on the caller's current id (which the
|
|
72
|
+
// rotation deletes anyway). Rotation refuses a session that is already gone, and a password
|
|
73
|
+
// change must not leave the lifted cookies it was made to kill alive because of that refusal.
|
|
74
|
+
let sessionsRevoked = 0;
|
|
75
|
+
if (credentialChanged) {
|
|
76
|
+
sessionsRevoked =
|
|
77
|
+
own === undefined
|
|
78
|
+
? await auth.adapter.deleteSessionsForUser(userId)
|
|
79
|
+
: await auth.adapter.deleteOtherSessions(userId, own.id);
|
|
65
80
|
}
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
const issued =
|
|
82
|
+
own !== undefined && auth.sessions.policy.rotateOnPrivilegeChange
|
|
83
|
+
? await rotateSession(auth.sessions, own)
|
|
84
|
+
: undefined;
|
|
85
|
+
// And once more AFTER it: a login that landed between the early sweep and the rotation was minted
|
|
86
|
+
// under the old password too. The early sweep stays — it is what still runs when rotation
|
|
87
|
+
// refuses a session that is already gone — and this one keeps only the replacement.
|
|
88
|
+
if (credentialChanged && own !== undefined) {
|
|
89
|
+
sessionsRevoked += await auth.adapter.deleteOtherSessions(userId, issued?.session.id ?? own.id);
|
|
90
|
+
}
|
|
91
|
+
if (issued === undefined) return { user, changed, sessionsRevoked };
|
|
68
92
|
return {
|
|
69
93
|
user,
|
|
70
94
|
changed,
|
|
95
|
+
sessionsRevoked,
|
|
71
96
|
session: issued,
|
|
72
|
-
|
|
97
|
+
// The rotated session keeps the old ceiling, so the cookie's `Max-Age` counts down to it too.
|
|
98
|
+
cookie: sessionCookie(issued.token, auth.sessions.policy, {
|
|
99
|
+
maxAgeSeconds: remainingMaxAgeSeconds(issued.session, auth.sessions.clock.now()),
|
|
100
|
+
}),
|
|
73
101
|
};
|
|
74
102
|
}
|
|
@@ -1,104 +1,104 @@
|
|
|
1
|
-
// The shared credential limiter:
|
|
1
|
+
// The shared credential limiter: one Postgres table, so N replicas count one spray once and a
|
|
2
2
|
// lockout one pod established is visible to the rest. Without it `rateLimit.scope: 'shared'` is a
|
|
3
3
|
// declaration nothing can satisfy, while `x new` scaffolds `replicas: 2` — which is
|
|
4
4
|
// `maxAttempts × 2` guesses per account.
|
|
5
|
-
import type { Clock } from '@ultimat3/core';
|
|
6
|
-
import { accountLocked } from './errors';
|
|
7
|
-
import type { AuthLimiter, AuthRateLimitPolicy } from './rate-limit';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The one thing this limiter needs from the DB layer, declared structurally rather than imported.
|
|
11
|
-
* `@ultimat3/action`'s `idempotency-postgres.ts` and `@ultimat3/http`'s rate-limit store declare
|
|
12
|
-
* the same shape for the same reason: the connection belongs to the HOST, not to any of them, so
|
|
13
|
-
* a limiter takes the pool the boot already opened instead of opening a second one against a URL
|
|
14
|
-
* that was resolved once.
|
|
15
|
-
*
|
|
16
|
-
* **`Bun.sql` does not satisfy it** — `Bun.sql.query` is `undefined`; it is a tagged template
|
|
17
|
-
* whose positional form is `unsafe`. `@ultimat3/db`'s `DbClient.query({ text, values })` does,
|
|
18
|
-
* wrapped in one line, and so does a transaction handle.
|
|
19
|
-
*/
|
|
20
|
-
export interface PgExecutor {
|
|
21
|
-
query<R>(sql: string, params: readonly unknown[]): Promise<readonly R[]>;
|
|
22
|
-
}
|
|
5
|
+
import type { Clock, PgExecutor } from '@ultimat3/core';
|
|
6
|
+
import { accountLocked, authWriteFailed } from './errors';
|
|
7
|
+
import type { AuthLimiter, AuthRateLimitPolicy, AuthReservation } from './rate-limit';
|
|
23
8
|
|
|
24
9
|
/**
|
|
25
10
|
* Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows, so
|
|
26
11
|
* `x dev`, the container's `web` role and `ROLE=migrate` all install it.
|
|
27
12
|
*
|
|
28
|
-
*
|
|
29
|
-
* SLIDING one
|
|
30
|
-
* end of one
|
|
31
|
-
*
|
|
13
|
+
* ONE row per key, holding every attempt still inside the window. The window this package enforces
|
|
14
|
+
* is a SLIDING one, so the instants are kept (a counter plus a window end is a fixed window, which
|
|
15
|
+
* admits `maxAttempts` at the end of one and `maxAttempts` again at the start of the next). They
|
|
16
|
+
* are kept IN the row because the row is what a concurrent take waits on: a count over a second
|
|
17
|
+
* table reads its statement's snapshot, and that snapshot predates the wait.
|
|
18
|
+
*
|
|
19
|
+
* The two `add column` lines are the upgrade of a table made before the reservation, on the rule
|
|
20
|
+
* `x_users` follows: additive, defaulted, idempotent, applied at every boot. The name and the
|
|
21
|
+
* first two columns are unchanged on purpose — a replica still running the previous release reads
|
|
22
|
+
* and writes this table as it always did, so a lockout holds on both sides of a rolling deploy.
|
|
32
23
|
*/
|
|
33
24
|
export const SQL_AUTH_LIMIT_TABLES = `
|
|
34
|
-
create table if not exists x_auth_failures (
|
|
35
|
-
key text not null,
|
|
36
|
-
at_ms bigint not null
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
create index if not exists x_auth_failures_key_idx on x_auth_failures (key, at_ms);
|
|
40
|
-
|
|
41
25
|
create table if not exists x_auth_lockouts (
|
|
42
|
-
key text
|
|
43
|
-
locked_until_ms bigint
|
|
26
|
+
key text primary key,
|
|
27
|
+
locked_until_ms bigint not null,
|
|
28
|
+
attempts_ms bigint[] not null default '{}',
|
|
29
|
+
admitted boolean not null default true
|
|
44
30
|
);
|
|
45
31
|
|
|
32
|
+
alter table x_auth_lockouts add column if not exists attempts_ms bigint[] not null default '{}';
|
|
33
|
+
alter table x_auth_lockouts add column if not exists admitted boolean not null default true;
|
|
34
|
+
|
|
46
35
|
create index if not exists x_auth_lockouts_until_idx on x_auth_lockouts (locked_until_ms);
|
|
47
36
|
`;
|
|
48
37
|
|
|
49
38
|
/**
|
|
50
|
-
* The
|
|
51
|
-
*
|
|
39
|
+
* The attempts still inside the window, read off the row as it is AFTER its lock is taken. It is
|
|
40
|
+
* repeated in the statement below rather than computed once in a CTE, and the repetition is
|
|
41
|
+
* required: only a direct `x_auth_lockouts.<column>` reference inside `on conflict do update` sees
|
|
42
|
+
* what a concurrent take just committed. `$2` nowMs, `$3` windowMs.
|
|
52
43
|
*/
|
|
53
|
-
|
|
44
|
+
const LIVE =
|
|
45
|
+
'array(select at_ms from unnest(x_auth_lockouts.attempts_ms) as at_ms ' +
|
|
46
|
+
'where at_ms > $2::bigint - $3::bigint)';
|
|
47
|
+
|
|
48
|
+
const LOCKED = 'x_auth_lockouts.locked_until_ms > $2::bigint';
|
|
54
49
|
|
|
55
50
|
/**
|
|
56
|
-
* `$1` key, `$2` nowMs
|
|
57
|
-
* statement,
|
|
58
|
-
*
|
|
59
|
-
* `PgExecutor` accepts a transaction handle (see its doc comment), and the insert and the count
|
|
60
|
-
* below are two statements. Without this lock two OUTER transactions recording a failure for one
|
|
61
|
-
* account each counted only what had COMMITTED plus their own row: with `maxAttempts: 3` and one
|
|
62
|
-
* failure already committed, both read two, neither locked, and both committed — three failures
|
|
63
|
-
* and an open account. The lock makes the second transaction wait at the insert until the first
|
|
64
|
-
* commits, so its count is taken against a snapshot that already holds the first's row.
|
|
51
|
+
* `$1` key, `$2` nowMs, `$3` windowMs, `$4` lockoutMs, `$5` maxAttempts — the reservation, in ONE
|
|
52
|
+
* statement: decide and count together, so N concurrent takes are admitted one at a time.
|
|
65
53
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* into `'repeatable read'` or `'serializable'` pins the count's snapshot at transaction start, and
|
|
70
|
-
* no lock can make a statement see a commit its own snapshot precedes.
|
|
54
|
+
* A locked key is left untouched and answers `admitted = false`. Otherwise the attempt joins the
|
|
55
|
+
* window and, if it fills it, starts the lockout. `admitted` is persisted because `returning`
|
|
56
|
+
* cannot see the row as it was: the verdict has to be a column the statement wrote.
|
|
71
57
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
58
|
+
* A live lockout is never rewritten, so nothing arriving during one can shorten it. Inside a
|
|
59
|
+
* caller's own transaction the row lock is held to that commit, which is what serialises two outer
|
|
60
|
+
* transactions; `auth.ts` takes account → ip → org in that fixed order, so two sign-ins cannot
|
|
61
|
+
* hold two of these rows in opposite orders.
|
|
74
62
|
*/
|
|
75
|
-
export const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
63
|
+
export const SQL_AUTH_TAKE = `
|
|
64
|
+
insert into x_auth_lockouts (key, attempts_ms, locked_until_ms, admitted)
|
|
65
|
+
values (
|
|
66
|
+
$1,
|
|
67
|
+
array[$2::bigint],
|
|
68
|
+
case when 1 >= $5::bigint then $2::bigint + $4::bigint else 0 end,
|
|
69
|
+
true
|
|
70
|
+
)
|
|
71
|
+
on conflict (key) do update
|
|
72
|
+
set attempts_ms = case when ${LOCKED} then x_auth_lockouts.attempts_ms
|
|
73
|
+
else ${LIVE} || $2::bigint end,
|
|
74
|
+
locked_until_ms = case
|
|
75
|
+
when ${LOCKED} then x_auth_lockouts.locked_until_ms
|
|
76
|
+
when cardinality(${LIVE}) + 1 >= $5::bigint then $2::bigint + $4::bigint
|
|
77
|
+
else 0 end,
|
|
78
|
+
admitted = not (${LOCKED})
|
|
79
|
+
returning admitted, locked_until_ms
|
|
79
80
|
`;
|
|
80
81
|
|
|
82
|
+
/** The window with the first entry equal to `$2` taken out — one reservation, never every equal one. */
|
|
83
|
+
const WITHOUT =
|
|
84
|
+
'(attempts_ms[:array_position(attempts_ms, $2::bigint) - 1] || ' +
|
|
85
|
+
'attempts_ms[array_position(attempts_ms, $2::bigint) + 1:])';
|
|
86
|
+
|
|
81
87
|
/**
|
|
82
|
-
* `$1` key, `$2`
|
|
88
|
+
* `$1` key, `$2` the reservation's `atMs`, `$3` nowMs, `$4` windowMs, `$5` maxAttempts.
|
|
83
89
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* sees this caller's own row and every other replica's that has committed — and, because the
|
|
88
|
-
* insert holds `SQL_AUTH_KEY_LOCK`, every row a concurrent transaction on this key committed too.
|
|
89
|
-
*
|
|
90
|
-
* `greatest` on conflict EXTENDS a live lockout and never shortens one — a spray arriving during
|
|
91
|
-
* a lockout must not be able to reset it to a nearer deadline.
|
|
90
|
+
* Gives ONE attempt back, and lifts the lockout when what is left no longer fills the window — a
|
|
91
|
+
* lockout the refunded attempt itself completed. A reservation that is no longer in the row (the
|
|
92
|
+
* key was cleared, or purged) matches nothing and changes nothing.
|
|
92
93
|
*/
|
|
93
|
-
export const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
returning locked_until_ms
|
|
94
|
+
export const SQL_AUTH_REFUND = `
|
|
95
|
+
update x_auth_lockouts
|
|
96
|
+
set attempts_ms = ${WITHOUT},
|
|
97
|
+
locked_until_ms = case
|
|
98
|
+
when (select count(*) from unnest(${WITHOUT}) as at_ms
|
|
99
|
+
where at_ms > $3::bigint - $4::bigint) < $5::bigint then 0
|
|
100
|
+
else locked_until_ms end
|
|
101
|
+
where key = $1 and array_position(attempts_ms, $2::bigint) is not null
|
|
102
102
|
`;
|
|
103
103
|
|
|
104
104
|
/** `$2` is the caller's clock: an expired lockout answers exactly as a missing one. */
|
|
@@ -106,30 +106,27 @@ export const SQL_AUTH_LOCKED_UNTIL = `
|
|
|
106
106
|
select locked_until_ms from x_auth_lockouts where key = $1 and locked_until_ms > $2::bigint
|
|
107
107
|
`;
|
|
108
108
|
|
|
109
|
-
/** A success clears the window AND the lockout: one
|
|
110
|
-
export const SQL_AUTH_FORGET_KEY =
|
|
111
|
-
with cleared as (delete from x_auth_failures where key = $1 returning key)
|
|
112
|
-
delete from x_auth_lockouts where key = $1
|
|
113
|
-
`;
|
|
109
|
+
/** A success clears the window AND the lockout: they are one row, so they go together. */
|
|
110
|
+
export const SQL_AUTH_FORGET_KEY = 'delete from x_auth_lockouts where key = $1';
|
|
114
111
|
|
|
115
|
-
export const SQL_AUTH_RESET =
|
|
116
|
-
with cleared as (delete from x_auth_failures returning key)
|
|
117
|
-
delete from x_auth_lockouts
|
|
118
|
-
`;
|
|
112
|
+
export const SQL_AUTH_RESET = 'delete from x_auth_lockouts';
|
|
119
113
|
|
|
120
114
|
/**
|
|
121
|
-
* `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in
|
|
115
|
+
* `$1` nowMs, `$2` windowMs — the CALLER's clock, never `now()`. Every instant in this table is
|
|
122
116
|
* written from the caller's clock, so a purge measuring against the SERVER's would delete rows by
|
|
123
|
-
* the offset between the two:
|
|
117
|
+
* the offset between the two: attempts that are still inside the window, and lockouts that are
|
|
124
118
|
* still live. The second one hands a sprayer its account back.
|
|
125
119
|
*/
|
|
126
120
|
export const SQL_AUTH_PURGE = `
|
|
127
|
-
with
|
|
128
|
-
delete from
|
|
129
|
-
|
|
130
|
-
|
|
121
|
+
with dropped as (
|
|
122
|
+
delete from x_auth_lockouts
|
|
123
|
+
where locked_until_ms <= $1::bigint
|
|
124
|
+
and not exists (
|
|
125
|
+
select 1 from unnest(attempts_ms) as at_ms where at_ms > $1::bigint - $2::bigint
|
|
126
|
+
)
|
|
127
|
+
returning key
|
|
131
128
|
)
|
|
132
|
-
select
|
|
129
|
+
select count(*) as removed from dropped
|
|
133
130
|
`;
|
|
134
131
|
|
|
135
132
|
export interface PostgresAuthLimiterOptions {
|
|
@@ -146,9 +143,9 @@ export interface PostgresAuthLimiterOptions {
|
|
|
146
143
|
|
|
147
144
|
export interface PostgresAuthLimiter extends AuthLimiter {
|
|
148
145
|
/**
|
|
149
|
-
* Drop every
|
|
150
|
-
*
|
|
151
|
-
* IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
|
|
146
|
+
* Drop every key whose window has emptied and whose lockout has expired, and answer how many
|
|
147
|
+
* rows went. The table does not bound itself — `ipKey` mints one key per source address, so a
|
|
148
|
+
* spray from an IPv6 /64 is a row per attempt — and Postgres forgets nothing on its own. An app runs this
|
|
152
149
|
* from a `task`; a row this deletes answers exactly as a missing one, so it changes no decision.
|
|
153
150
|
*/
|
|
154
151
|
purgeExpired(): Promise<number>;
|
|
@@ -159,6 +156,10 @@ interface LockRow {
|
|
|
159
156
|
readonly locked_until_ms: number | string;
|
|
160
157
|
}
|
|
161
158
|
|
|
159
|
+
interface TakeRow extends LockRow {
|
|
160
|
+
readonly admitted: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
162
163
|
/**
|
|
163
164
|
* **Install it at `defineAuth`, beside the declaration it satisfies.** Two limiters, one table:
|
|
164
165
|
* the keys are prefixed (`account:`, `ip:`, `org:`) and every limit travels as a parameter, so
|
|
@@ -192,22 +193,33 @@ export function postgresAuthLimiter(options: PostgresAuthLimiterOptions): Postgr
|
|
|
192
193
|
// bound this limiter does not enforce is the thing `assertAuthLimiterPolicy` exists to catch.
|
|
193
194
|
policy: { ...policy, maxKeys: undefined, scope: 'shared' },
|
|
194
195
|
|
|
195
|
-
async
|
|
196
|
-
const until = await lockedUntilMs(key);
|
|
197
|
-
if (until === null) return;
|
|
198
|
-
throw accountLocked(key, Math.ceil((until - nowMs()) / 1000));
|
|
199
|
-
},
|
|
200
|
-
|
|
201
|
-
async recordFailure(key): Promise<void> {
|
|
196
|
+
async reserve(key): Promise<AuthReservation> {
|
|
202
197
|
const at = nowMs();
|
|
203
|
-
await exec.query(
|
|
204
|
-
await exec.query(SQL_AUTH_LOCK, [
|
|
198
|
+
const rows = await exec.query<TakeRow>(SQL_AUTH_TAKE, [
|
|
205
199
|
key,
|
|
206
200
|
at,
|
|
207
201
|
policy.windowMs,
|
|
208
202
|
policy.lockoutMs,
|
|
209
203
|
policy.maxAttempts,
|
|
210
204
|
]);
|
|
205
|
+
const row = rows[0];
|
|
206
|
+
// No row is no verdict, and no verdict is not an admission: an executor that answered
|
|
207
|
+
// nothing must not be read as "allowed" on the credential path.
|
|
208
|
+
if (row === undefined) throw authWriteFailed('reserve', 'x_auth_lockouts');
|
|
209
|
+
if (row.admitted !== true) {
|
|
210
|
+
throw accountLocked(key, Math.ceil((Number(row.locked_until_ms) - at) / 1000));
|
|
211
|
+
}
|
|
212
|
+
return { key, atMs: at };
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
async refund(reservation): Promise<void> {
|
|
216
|
+
await exec.query(SQL_AUTH_REFUND, [
|
|
217
|
+
reservation.key,
|
|
218
|
+
reservation.atMs,
|
|
219
|
+
nowMs(),
|
|
220
|
+
policy.windowMs,
|
|
221
|
+
policy.maxAttempts,
|
|
222
|
+
]);
|
|
211
223
|
},
|
|
212
224
|
|
|
213
225
|
async recordSuccess(key): Promise<void> {
|
package/src/rate-limit.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
accountLocked,
|
|
11
11
|
authLimiterNotShared,
|
|
12
12
|
authLimiterPolicyMismatch,
|
|
13
|
+
ORG_KEY_PREFIX,
|
|
13
14
|
} from './errors';
|
|
14
15
|
import { assertFiniteAuthCount } from './policy-numbers';
|
|
15
16
|
|
|
@@ -65,11 +66,22 @@ export const DEFAULT_AUTH_RATE_LIMIT: AuthRateLimitPolicy = Object.freeze({
|
|
|
65
66
|
scope: 'process',
|
|
66
67
|
});
|
|
67
68
|
|
|
69
|
+
/** One counted attempt, as `reserve` took it — what `refund` gives back, and nothing else. */
|
|
70
|
+
export interface AuthReservation {
|
|
71
|
+
readonly key: string;
|
|
72
|
+
/** The limiter's own clock at the take. It identifies the entry inside the window. */
|
|
73
|
+
readonly atMs: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
/**
|
|
69
77
|
* Async on every member, so a shared implementation can exist at all: a lockout that holds across
|
|
70
78
|
* replicas is a network round trip, and a synchronous signature has no way to wait for one. The
|
|
71
79
|
* in-memory limiter below answers immediately — the cost is one already-settled promise per call,
|
|
72
80
|
* on a path that is about to run a KDF.
|
|
81
|
+
*
|
|
82
|
+
* The allowance is RESERVED, never checked: `reserve` counts the attempt in the same step that
|
|
83
|
+
* decides whether it may run, so N concurrent guesses spend N of the allowance before any of them
|
|
84
|
+
* reaches the KDF. A check followed later by a record let every one of them pass the check.
|
|
73
85
|
*/
|
|
74
86
|
export interface AuthLimiter {
|
|
75
87
|
/**
|
|
@@ -79,23 +91,32 @@ export interface AuthLimiter {
|
|
|
79
91
|
* operator reads and nothing enforces.
|
|
80
92
|
*/
|
|
81
93
|
readonly policy: AuthRateLimitPolicy;
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Count one attempt against the key, atomically, BEFORE any KDF work. Rejects with
|
|
96
|
+
* `X_ACCOUNT_LOCKED` — counting nothing — while the key is inside a lockout; the attempt that
|
|
97
|
+
* fills the window is admitted and starts the lockout. A failed attempt needs no further call:
|
|
98
|
+
* the reservation IS the recorded failure.
|
|
99
|
+
*/
|
|
100
|
+
reserve(key: string): Promise<AuthReservation>;
|
|
101
|
+
/**
|
|
102
|
+
* Give one reservation back — the attempt succeeded, or never reached a verdict. Removes that
|
|
103
|
+
* one entry and lifts a lockout the window no longer justifies. Never clears the key.
|
|
104
|
+
*/
|
|
105
|
+
refund(reservation: AuthReservation): Promise<void>;
|
|
85
106
|
/** A success clears the window: a legitimate user is not punished for a typo yesterday. */
|
|
86
107
|
recordSuccess(key: string): Promise<void>;
|
|
87
108
|
lockedUntil(key: string): Promise<Date | null>;
|
|
88
109
|
reset(): Promise<void>;
|
|
89
110
|
/**
|
|
90
111
|
* Drop every expired row this limiter is keeping, and answer how many went. Optional because a
|
|
91
|
-
* limiter that bounds itself has nothing to sweep — `
|
|
112
|
+
* limiter that bounds itself has nothing to sweep — `memoryAuthLimiter` evicts on write, so it
|
|
92
113
|
* omits this and `purgeAuthLimits()` skips it. A limiter backed by a table declares it, and
|
|
93
114
|
* that is what makes the framework's purge job able to reach one without knowing it is Postgres.
|
|
94
115
|
*/
|
|
95
116
|
purgeExpired?(): Promise<number>;
|
|
96
117
|
}
|
|
97
118
|
|
|
98
|
-
/** What `
|
|
119
|
+
/** What `memoryAuthLimiter` returns: the interface, plus the bound it keeps, observable. */
|
|
99
120
|
export interface MemoryAuthLimiter extends AuthLimiter {
|
|
100
121
|
readonly size: number;
|
|
101
122
|
}
|
|
@@ -113,7 +134,7 @@ export const ipKey = (ip: string): string => `ip:${ip}`;
|
|
|
113
134
|
* in `@ultimat3/http` and `@ultimat3/jobs`: three limiters that spell one tenant three ways cannot
|
|
114
135
|
* be read together during an incident.
|
|
115
136
|
*/
|
|
116
|
-
export const orgKey = (orgId: string): string =>
|
|
137
|
+
export const orgKey = (orgId: string): string => `${ORG_KEY_PREFIX}${orgId}`;
|
|
117
138
|
|
|
118
139
|
/**
|
|
119
140
|
* The policy the tenant limiter enforces — the same window and lockout, a wider allowance. One
|
|
@@ -153,7 +174,7 @@ const SWEEP_EVERY_MS = 60_000;
|
|
|
153
174
|
* forgotten anyway go first: a locked account is the last key to go, so filling the table is not
|
|
154
175
|
* a way to buy back attempts against one.
|
|
155
176
|
*/
|
|
156
|
-
export function
|
|
177
|
+
export function memoryAuthLimiter(
|
|
157
178
|
clock: Clock,
|
|
158
179
|
policy: AuthRateLimitPolicy = DEFAULT_AUTH_RATE_LIMIT,
|
|
159
180
|
): MemoryAuthLimiter {
|
|
@@ -204,25 +225,38 @@ export function createAuthLimiter(
|
|
|
204
225
|
get size() {
|
|
205
226
|
return buckets.size;
|
|
206
227
|
},
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const nowMs = clock.now().getTime();
|
|
211
|
-
if (bucket.lockedUntilMs <= nowMs) return;
|
|
212
|
-
throw accountLocked(key, Math.ceil((bucket.lockedUntilMs - nowMs) / 1000));
|
|
213
|
-
},
|
|
214
|
-
async recordFailure(key) {
|
|
228
|
+
// No `await` anywhere in the body: the check and the count are one synchronous step, which
|
|
229
|
+
// is the whole guarantee — concurrent callers are admitted one at a time, in order.
|
|
230
|
+
async reserve(key) {
|
|
215
231
|
const nowMs = clock.now().getTime();
|
|
232
|
+
const existing = buckets.get(key);
|
|
233
|
+
if (existing !== undefined && existing.lockedUntilMs > nowMs) {
|
|
234
|
+
throw accountLocked(key, Math.ceil((existing.lockedUntilMs - nowMs) / 1000));
|
|
235
|
+
}
|
|
216
236
|
const bucket = bucketFor(key);
|
|
217
237
|
bucket.failures = bucket.failures.filter((at) => at > nowMs - policy.windowMs);
|
|
218
238
|
bucket.failures.push(nowMs);
|
|
219
239
|
if (bucket.failures.length >= policy.maxAttempts) {
|
|
220
240
|
bucket.lockedUntilMs = nowMs + policy.lockoutMs;
|
|
221
241
|
}
|
|
222
|
-
// The newest
|
|
242
|
+
// The newest attempt leaves the window last, so that is the earliest this entry is free —
|
|
223
243
|
// unless a lockout outlives it. Recorded here because this is the only growth path.
|
|
224
244
|
bucket.forgetAtMs = Math.max(bucket.lockedUntilMs, nowMs + policy.windowMs);
|
|
225
245
|
if (buckets.size > maxKeys || nowMs - lastSweepMs >= SWEEP_EVERY_MS) sweep(nowMs);
|
|
246
|
+
return { key, atMs: nowMs };
|
|
247
|
+
},
|
|
248
|
+
async refund(reservation) {
|
|
249
|
+
const bucket = buckets.get(reservation.key);
|
|
250
|
+
if (bucket === undefined) return;
|
|
251
|
+
const index = bucket.failures.indexOf(reservation.atMs);
|
|
252
|
+
if (index < 0) return;
|
|
253
|
+
bucket.failures.splice(index, 1);
|
|
254
|
+
const nowMs = clock.now().getTime();
|
|
255
|
+
const live = bucket.failures.filter((at) => at > nowMs - policy.windowMs).length;
|
|
256
|
+
// A lockout this reservation completed is not one the remaining failures justify.
|
|
257
|
+
if (live < policy.maxAttempts) bucket.lockedUntilMs = 0;
|
|
258
|
+
if (bucket.failures.length === 0 && bucket.lockedUntilMs === 0)
|
|
259
|
+
buckets.delete(reservation.key);
|
|
226
260
|
},
|
|
227
261
|
async recordSuccess(key) {
|
|
228
262
|
buckets.delete(key);
|