@ultimat3/auth 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +79 -36
- package/README.md +178 -52
- package/package.json +5 -4
- package/src/adapter.ts +43 -16
- package/src/api-key-resolver.ts +55 -0
- package/src/api-keys.ts +59 -12
- package/src/auth.ts +48 -45
- package/src/builtin-adapter-violation.ts +43 -0
- package/src/builtin-adapter.ts +61 -6
- package/src/client-scope.ts +8 -2
- package/src/directory.ts +2 -13
- package/src/errors.ts +77 -45
- package/src/index.ts +48 -20
- package/src/jwks.ts +11 -0
- package/src/login-attempt.ts +70 -0
- package/src/memory-adapter.ts +41 -0
- package/src/mfa-challenge.ts +151 -0
- package/src/mfa-secret.ts +91 -0
- package/src/mfa.ts +9 -23
- package/src/oauth-builtins.ts +6 -0
- package/src/oauth-cookie.ts +8 -4
- package/src/oauth-discovery.ts +24 -5
- package/src/oauth-errors.ts +4 -84
- package/src/oauth-exchange.ts +13 -7
- package/src/oauth-login.ts +50 -9
- package/src/oauth-registry.ts +16 -1
- package/src/oauth-route.ts +75 -22
- package/src/policy-bridge.ts +51 -8
- package/src/privileges.ts +28 -16
- package/src/rate-limit-postgres.ts +113 -101
- package/src/rate-limit.ts +47 -13
- package/src/revocation.ts +25 -25
- package/src/session.ts +4 -30
- package/src/tables.ts +7 -9
- package/src/verify.ts +2 -2
- package/src/auth-fixture.ts +0 -26
- package/src/id-token-fixture.ts +0 -20
- package/src/oauth-login-fixture.ts +0 -53
package/src/oauth-registry.ts
CHANGED
|
@@ -5,9 +5,24 @@
|
|
|
5
5
|
// account linking with it. The three built-ins register through the same call an app uses, so the
|
|
6
6
|
// opening does not create a second path.
|
|
7
7
|
|
|
8
|
+
import { renderCauseValue, renderFixLiteral } from '@ultimat3/core';
|
|
9
|
+
import { AuthError } from './errors';
|
|
8
10
|
import type { OAuthProvider } from './oauth';
|
|
9
11
|
import { BUILTIN_OAUTH_PROVIDERS } from './oauth-builtins';
|
|
10
|
-
import {
|
|
12
|
+
import { oauthProviderUnknown } from './oauth-errors';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Two `registerOAuthProvider` calls claiming one id. A silent replacement would let whichever
|
|
16
|
+
* module imported second decide where every login for that id goes — including which `issuers`
|
|
17
|
+
* an id token may claim — so the second registration refuses at boot instead.
|
|
18
|
+
*/
|
|
19
|
+
export const oauthProviderDuplicate = (provider: string): AuthError =>
|
|
20
|
+
new AuthError({
|
|
21
|
+
code: 'X_OAUTH_PROVIDER_DUPLICATE',
|
|
22
|
+
cause: `an oauth provider is already registered as ${renderCauseValue(provider)}, so the second registration would silently replace the first`,
|
|
23
|
+
fix: `give one of them a different id, or delete the duplicate registerOAuthProvider({ id: ${renderFixLiteral(provider, '<id>')} }) call`,
|
|
24
|
+
meta: { provider },
|
|
25
|
+
});
|
|
11
26
|
|
|
12
27
|
const registry = new Map<string, OAuthProvider>();
|
|
13
28
|
|
package/src/oauth-route.ts
CHANGED
|
@@ -10,16 +10,20 @@
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
type Clock,
|
|
13
|
+
EnvMissingError,
|
|
13
14
|
isUltimateError,
|
|
14
15
|
logger,
|
|
16
|
+
renderCauseValue,
|
|
17
|
+
renderFixShellArg,
|
|
15
18
|
renderThrowable,
|
|
16
19
|
type UltimateError,
|
|
17
20
|
} from '@ultimat3/core';
|
|
18
21
|
import type { Auth, LoginResult } from './auth';
|
|
22
|
+
import { AuthError } from './errors';
|
|
19
23
|
import { beginOAuth, type OAuthProviderId } from './oauth';
|
|
20
24
|
import { BUILTIN_OAUTH_PROVIDER_IDS } from './oauth-builtins';
|
|
21
25
|
import { clearHandshakeCookie, handshakeCookie, readHandshakeCookie } from './oauth-cookie';
|
|
22
|
-
import {
|
|
26
|
+
import { oauthExchangeFailed, oauthProviderUnknown, restartAt } from './oauth-errors';
|
|
23
27
|
import type { OAuthClientCredentials, OAuthFetch } from './oauth-exchange';
|
|
24
28
|
import { oauthCredentials } from './oauth-exchange';
|
|
25
29
|
import { completeOAuthLogin, type ResolveOAuthGrants } from './oauth-login';
|
|
@@ -30,6 +34,30 @@ import {
|
|
|
30
34
|
} from './oauth-paths';
|
|
31
35
|
import { hasOAuthProvider } from './oauth-registry';
|
|
32
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The provider came back with `error=` and no code — almost always the user pressing Cancel.
|
|
39
|
+
* A separate code from `X_OAUTH_EXCHANGE_FAILED` on purpose: nothing was exchanged, nothing is
|
|
40
|
+
* misconfigured, and folding the single commonest non-success outcome of a login into the code
|
|
41
|
+
* that means "the client secret is wrong" makes both unreadable in a log and pages the wrong person.
|
|
42
|
+
*/
|
|
43
|
+
export const oauthDenied = (
|
|
44
|
+
provider: string,
|
|
45
|
+
reason: string,
|
|
46
|
+
description: string | null,
|
|
47
|
+
): AuthError =>
|
|
48
|
+
new AuthError({
|
|
49
|
+
code: 'X_OAUTH_DENIED',
|
|
50
|
+
// `reason` and `description` are query parameters off the callback URL — whatever the browser
|
|
51
|
+
// was redirected with, newlines and quotes included. `renderCauseValue` renders them as JSON
|
|
52
|
+
// string literals, so a forged `error_description` cannot forge a second log line or break the
|
|
53
|
+
// sentence around it. Both are already `string` by type: this is escaping, not throw-safety.
|
|
54
|
+
cause: `${provider} declined the authorization: ${renderCauseValue(reason)}${
|
|
55
|
+
description === null ? '' : ` (${renderCauseValue(description)})`
|
|
56
|
+
}`,
|
|
57
|
+
fix: `${restartAt(provider)} and approve the ${provider} consent screen`,
|
|
58
|
+
meta: { provider, reason },
|
|
59
|
+
});
|
|
60
|
+
|
|
33
61
|
/**
|
|
34
62
|
* What a router needs to mount one of these. Structural, like `RequestLike` in `session.ts`:
|
|
35
63
|
* `@ultimat3/http` binds to this shape, this package never binds to `@ultimat3/http`.
|
|
@@ -52,9 +80,9 @@ export interface OAuthLoginRoutes {
|
|
|
52
80
|
|
|
53
81
|
export interface OAuthLoginOptions {
|
|
54
82
|
/**
|
|
55
|
-
* The origin the provider redirects back to. Defaults to `APP_URL
|
|
56
|
-
*
|
|
57
|
-
*
|
|
83
|
+
* The origin the provider redirects back to. Defaults to `APP_URL`; with neither, the start leg
|
|
84
|
+
* refuses (`X_ENV_MISSING`). Never the request's own origin: that is the `Host` header, which
|
|
85
|
+
* the caller writes.
|
|
58
86
|
*/
|
|
59
87
|
readonly baseUrl?: string | undefined;
|
|
60
88
|
/** Defaults to the two env vars in the provider table, read per request, never at import. */
|
|
@@ -125,34 +153,44 @@ export function oauthRouteStatus(code: string): number {
|
|
|
125
153
|
return Object.hasOwn(OAUTH_ROUTE_STATUS, code) ? (OAUTH_ROUTE_STATUS[code] ?? 502) : 502;
|
|
126
154
|
}
|
|
127
155
|
|
|
156
|
+
/** The one sentence every refusal carries outward. The event name is where the rest went. */
|
|
157
|
+
const PUBLIC_CAUSE =
|
|
158
|
+
'the sign-in was refused; its cause and its fix are in this server\u2019s log under auth.oauth.refused';
|
|
159
|
+
|
|
160
|
+
/** A code this framework would have minted — the only kind spliced into a command. */
|
|
161
|
+
const FRAMEWORK_CODE = /^X_[A-Z0-9_]+$/;
|
|
162
|
+
|
|
128
163
|
/**
|
|
129
|
-
* What an anonymous caller is allowed to read
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
164
|
+
* What an anonymous caller is allowed to read: the code, its registered title and docs link, and
|
|
165
|
+
* one fixed sentence. Both legs are public by definition, and an authored `cause`/`fix` is written
|
|
166
|
+
* for the developer — it names env vars, endpoints and what a provider answered — so neither is
|
|
167
|
+
* served, on any code: no per-code judgement about which sentence is safe today.
|
|
133
168
|
*/
|
|
134
169
|
const publicBody = (coded: UltimateError): Record<string, string> => ({
|
|
135
170
|
code: coded.code,
|
|
136
171
|
title: coded.title,
|
|
137
|
-
cause:
|
|
138
|
-
fix: coded.
|
|
172
|
+
cause: PUBLIC_CAUSE,
|
|
173
|
+
fix: FRAMEWORK_CODE.test(coded.code)
|
|
174
|
+
? `x errors explain ${renderFixShellArg(coded.code, '<CODE>')} --json`
|
|
175
|
+
: 'x errors list --json',
|
|
139
176
|
docs: coded.docs,
|
|
140
177
|
});
|
|
141
178
|
|
|
142
179
|
/**
|
|
143
|
-
* Failure is JSON, never a redirect to a login page carrying `?error
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
180
|
+
* Failure is JSON, never a redirect to a login page carrying `?error=`: the status and the code
|
|
181
|
+
* are the answer a client branches on. The developer's half — the cause and the fix line — is
|
|
182
|
+
* logged as `auth.oauth.refused`, where only an operator reads it. An app that wants a rendered
|
|
183
|
+
* page wraps these two descriptors.
|
|
147
184
|
*/
|
|
148
185
|
function problem(error: unknown, extraCookies: readonly string[]): Response {
|
|
149
186
|
const coded = isUltimateError(error) ? error : uncoded(error);
|
|
187
|
+
const status = oauthRouteStatus(coded.code);
|
|
188
|
+
const fields = { code: coded.code, status, cause: coded.cause, fix: coded.fix };
|
|
189
|
+
if (status >= 500) logger.error('auth.oauth.refused', fields);
|
|
190
|
+
else logger.info('auth.oauth.refused', fields);
|
|
150
191
|
const headers = new Headers({ 'content-type': 'application/json; charset=utf-8' });
|
|
151
192
|
for (const cookie of extraCookies) headers.append('set-cookie', cookie);
|
|
152
|
-
return new Response(JSON.stringify(publicBody(coded)), {
|
|
153
|
-
status: oauthRouteStatus(coded.code),
|
|
154
|
-
headers,
|
|
155
|
-
});
|
|
193
|
+
return new Response(JSON.stringify(publicBody(coded)), { status, headers });
|
|
156
194
|
}
|
|
157
195
|
|
|
158
196
|
/**
|
|
@@ -244,10 +282,22 @@ function credentialsFor(
|
|
|
244
282
|
}
|
|
245
283
|
}
|
|
246
284
|
|
|
247
|
-
|
|
285
|
+
/**
|
|
286
|
+
* The declared origin, or a refusal. The request's own origin is the `Host` header, so a
|
|
287
|
+
* `redirect_uri` built from it is one the caller chose.
|
|
288
|
+
*/
|
|
289
|
+
function declaredOrigin(options: OAuthLoginOptions): string {
|
|
248
290
|
const env = options.env ?? Bun.env;
|
|
249
|
-
const declared = options.baseUrl ?? env['APP_URL']
|
|
250
|
-
|
|
291
|
+
const declared = (options.baseUrl ?? env['APP_URL'] ?? '').trim();
|
|
292
|
+
if (declared === '') {
|
|
293
|
+
throw new EnvMissingError({
|
|
294
|
+
cause:
|
|
295
|
+
'APP_URL is not set and oauthLogin() was given no baseUrl, so there is no declared origin to build the provider redirect_uri from',
|
|
296
|
+
fix: 'export APP_URL=http://localhost:3000 # the origin this app is served from',
|
|
297
|
+
meta: { key: 'APP_URL' },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return declared.replace(/\/+$/, '');
|
|
251
301
|
}
|
|
252
302
|
|
|
253
303
|
/** The handshake's own options, assembled once so both legs seal and open it identically. */
|
|
@@ -265,12 +315,15 @@ async function startHandler(
|
|
|
265
315
|
request: Request,
|
|
266
316
|
): Promise<Response> {
|
|
267
317
|
try {
|
|
318
|
+
// Before the provider is looked at: asked after, a missing origin answered 500 for a provider
|
|
319
|
+
// this app enabled and 404 for one it did not.
|
|
320
|
+
const origin = declaredOrigin(options);
|
|
268
321
|
const provider = assertEnabled(auth, providerSegment(request, 'start'));
|
|
269
322
|
const credentials = credentialsFor(provider, options);
|
|
270
323
|
const handshake = beginOAuth({
|
|
271
324
|
provider,
|
|
272
325
|
clientId: credentials.clientId,
|
|
273
|
-
redirectUri: `${
|
|
326
|
+
redirectUri: `${origin}${oauthCallbackPath(provider)}`,
|
|
274
327
|
scopes: options.scopes,
|
|
275
328
|
});
|
|
276
329
|
return new Response(null, {
|
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,9 +1,6 @@
|
|
|
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';
|
|
@@ -28,6 +25,8 @@ export interface UpdatePrivilegesResult {
|
|
|
28
25
|
readonly cookie?: string | undefined;
|
|
29
26
|
/** Which of `PRIVILEGE_FIELDS` the patch actually named. Empty means nothing rotated. */
|
|
30
27
|
readonly changed: readonly string[];
|
|
28
|
+
/** Sessions a `passwordHash` change ended. Zero for every other patch. */
|
|
29
|
+
readonly sessionsRevoked: number;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
const changedFields = (patch: UserPatch): readonly string[] =>
|
|
@@ -44,6 +43,12 @@ const changedFields = (patch: UserPatch): readonly string[] =>
|
|
|
44
43
|
* there is no cookie of theirs to rotate. That case wants `revokeUserSessions()` instead, and the
|
|
45
44
|
* two are deliberately separate calls — silently killing an operator's own session mid-request is
|
|
46
45
|
* not something a role edit should decide on its own.
|
|
46
|
+
*
|
|
47
|
+
* A `passwordHash` change is the exception, and it decides for itself: the password is what every
|
|
48
|
+
* OTHER session of this user was issued under, so they all end here. The caller's own session —
|
|
49
|
+
* passed in, and this user's — is the one that survives (rotated, or kept when rotation is off);
|
|
50
|
+
* with no session of the user's own, as in a reset or an admin's change, none survives. A changed
|
|
51
|
+
* password that left a lifted cookie working would not have changed anything for whoever holds it.
|
|
47
52
|
*/
|
|
48
53
|
export async function updatePrivileges(
|
|
49
54
|
auth: Auth,
|
|
@@ -51,23 +56,30 @@ export async function updatePrivileges(
|
|
|
51
56
|
patch: UserPatch,
|
|
52
57
|
session?: IssuedSession['session'] | undefined,
|
|
53
58
|
): Promise<UpdatePrivilegesResult> {
|
|
59
|
+
const changed = changedFields(patch);
|
|
60
|
+
const own = session !== undefined && session.userId === userId ? session : undefined;
|
|
61
|
+
const credentialChanged = changed.includes('passwordHash');
|
|
54
62
|
const user = await auth.adapter.updateUser(userId, patch);
|
|
55
63
|
if (user === null) throw authWriteFailed('updateUser', 'x_users');
|
|
64
|
+
if (changed.length === 0) return { user, changed, sessionsRevoked: 0 };
|
|
56
65
|
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
const issued =
|
|
67
|
+
own !== undefined && auth.sessions.policy.rotateOnPrivilegeChange
|
|
68
|
+
? await rotateSession(auth.sessions, own)
|
|
69
|
+
: undefined;
|
|
70
|
+
let sessionsRevoked = 0;
|
|
71
|
+
if (credentialChanged) {
|
|
72
|
+
const keep = issued?.session.id ?? own?.id;
|
|
73
|
+
sessionsRevoked =
|
|
74
|
+
keep === undefined
|
|
75
|
+
? await auth.adapter.deleteSessionsForUser(userId)
|
|
76
|
+
: await auth.adapter.deleteOtherSessions(userId, keep);
|
|
65
77
|
}
|
|
66
|
-
|
|
67
|
-
const issued = await rotateSession(auth.sessions, session);
|
|
78
|
+
if (issued === undefined) return { user, changed, sessionsRevoked };
|
|
68
79
|
return {
|
|
69
80
|
user,
|
|
70
81
|
changed,
|
|
82
|
+
sessionsRevoked,
|
|
71
83
|
session: issued,
|
|
72
84
|
cookie: sessionCookie(issued.token, auth.sessions.policy),
|
|
73
85
|
};
|
|
@@ -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> {
|