@fuzdev/fuz_app 0.106.1 → 0.108.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/dist/actions/CLAUDE.md +42 -23
- package/dist/actions/action_bridge.d.ts +34 -3
- package/dist/actions/action_bridge.d.ts.map +1 -1
- package/dist/actions/action_bridge.js +32 -1
- package/dist/actions/action_rpc.d.ts +5 -4
- package/dist/actions/action_rpc.d.ts.map +1 -1
- package/dist/actions/action_rpc.js +4 -3
- package/dist/actions/action_spec.d.ts +8 -2
- package/dist/actions/action_spec.d.ts.map +1 -1
- package/dist/actions/action_spec.js +5 -2
- package/dist/actions/compile_action_registry.d.ts +26 -14
- package/dist/actions/compile_action_registry.d.ts.map +1 -1
- package/dist/actions/compile_action_registry.js +29 -14
- package/dist/actions/perform_action.d.ts +26 -16
- package/dist/actions/perform_action.d.ts.map +1 -1
- package/dist/actions/perform_action.js +69 -44
- package/dist/actions/register_action_ws.d.ts +4 -4
- package/dist/actions/register_action_ws.js +5 -5
- package/dist/actions/register_ws_endpoint.d.ts +13 -5
- package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
- package/dist/actions/register_ws_endpoint.js +15 -6
- package/dist/auth/CLAUDE.md +16 -9
- package/dist/auth/account_route_schema.d.ts +10 -1
- package/dist/auth/account_route_schema.d.ts.map +1 -1
- package/dist/auth/account_route_schema.js +10 -1
- package/dist/auth/account_routes.d.ts +18 -4
- package/dist/auth/account_routes.d.ts.map +1 -1
- package/dist/auth/account_routes.js +32 -29
- package/dist/auth/api_token_queries.d.ts +4 -0
- package/dist/auth/api_token_queries.d.ts.map +1 -1
- package/dist/auth/api_token_queries.js +5 -1
- package/dist/auth/audit_log_route_schema.d.ts.map +1 -1
- package/dist/auth/audit_log_route_schema.js +11 -1
- package/dist/auth/audit_log_routes.d.ts.map +1 -1
- package/dist/auth/audit_log_routes.js +4 -9
- package/dist/auth/auth_guard_resolver.d.ts +48 -21
- package/dist/auth/auth_guard_resolver.d.ts.map +1 -1
- package/dist/auth/auth_guard_resolver.js +60 -26
- package/dist/auth/bearer_auth.d.ts +10 -7
- package/dist/auth/bearer_auth.d.ts.map +1 -1
- package/dist/auth/bearer_auth.js +13 -22
- package/dist/auth/bootstrap_routes.d.ts +12 -2
- package/dist/auth/bootstrap_routes.d.ts.map +1 -1
- package/dist/auth/bootstrap_routes.js +15 -9
- package/dist/auth/middleware.d.ts +0 -3
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +11 -10
- package/dist/auth/migrations.d.ts.map +1 -1
- package/dist/auth/migrations.js +24 -0
- package/dist/auth/request_context.d.ts +54 -24
- package/dist/auth/request_context.d.ts.map +1 -1
- package/dist/auth/request_context.js +117 -39
- package/dist/auth/session_queries.d.ts +13 -2
- package/dist/auth/session_queries.d.ts.map +1 -1
- package/dist/auth/session_queries.js +14 -3
- package/dist/auth/signup_routes.d.ts +10 -0
- package/dist/auth/signup_routes.d.ts.map +1 -1
- package/dist/auth/signup_routes.js +14 -11
- package/dist/auth/token_scope.d.ts +109 -21
- package/dist/auth/token_scope.d.ts.map +1 -1
- package/dist/auth/token_scope.js +106 -21
- package/dist/http/CLAUDE.md +23 -11
- package/dist/http/auth_shape.d.ts +58 -3
- package/dist/http/auth_shape.d.ts.map +1 -1
- package/dist/http/auth_shape.js +63 -5
- package/dist/http/error_schemas.d.ts +25 -2
- package/dist/http/error_schemas.d.ts.map +1 -1
- package/dist/http/error_schemas.js +37 -15
- package/dist/http/route_spec.d.ts +29 -26
- package/dist/http/route_spec.d.ts.map +1 -1
- package/dist/http/route_spec.js +12 -16
- package/dist/http/surface_query.d.ts +2 -2
- package/dist/http/surface_query.js +2 -2
- package/dist/rate_limiter.d.ts +36 -3
- package/dist/rate_limiter.d.ts.map +1 -1
- package/dist/rate_limiter.js +36 -3
- package/dist/server/app_server.d.ts +27 -10
- package/dist/server/app_server.d.ts.map +1 -1
- package/dist/server/app_server.js +30 -20
- package/dist/server/app_server_context.d.ts +10 -2
- package/dist/server/app_server_context.d.ts.map +1 -1
- package/dist/server/serve_fact_route.d.ts.map +1 -1
- package/dist/server/serve_fact_route.js +12 -10
- package/dist/testing/CLAUDE.md +60 -11
- package/dist/testing/app_server.d.ts.map +1 -1
- package/dist/testing/app_server.js +6 -4
- package/dist/testing/cross_backend/conformance_case.js +1 -1
- package/dist/testing/cross_backend/default_spine_surface.d.ts.map +1 -1
- package/dist/testing/cross_backend/default_spine_surface.js +9 -8
- package/dist/testing/cross_backend/login_security.d.ts.map +1 -1
- package/dist/testing/cross_backend/login_security.js +59 -0
- package/dist/testing/cross_backend/session_cap.d.ts +19 -0
- package/dist/testing/cross_backend/session_cap.d.ts.map +1 -0
- package/dist/testing/cross_backend/session_cap.js +93 -0
- package/dist/testing/cross_backend/testing_backdoor.d.ts.map +1 -1
- package/dist/testing/cross_backend/testing_backdoor.js +6 -5
- package/dist/testing/cross_backend/token_scope_surface.d.ts +9 -0
- package/dist/testing/cross_backend/token_scope_surface.d.ts.map +1 -0
- package/dist/testing/cross_backend/token_scope_surface.js +170 -0
- package/dist/testing/integration.d.ts.map +1 -1
- package/dist/testing/integration.js +3 -2
- package/dist/testing/middleware.d.ts +2 -5
- package/dist/testing/middleware.d.ts.map +1 -1
- package/dist/testing/middleware.js +5 -5
- package/dist/testing/rate_limiting.d.ts +10 -16
- package/dist/testing/rate_limiting.d.ts.map +1 -1
- package/dist/testing/rate_limiting.js +11 -95
- package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
- package/dist/testing/rpc_attack_surface.js +4 -2
- package/dist/testing/stubs.d.ts.map +1 -1
- package/dist/testing/stubs.js +3 -1
- package/dist/testing/testing_rate_limiter.d.ts +7 -1
- package/dist/testing/testing_rate_limiter.d.ts.map +1 -1
- package/dist/testing/testing_rate_limiter.js +7 -1
- package/package.json +1 -1
|
@@ -5,22 +5,32 @@
|
|
|
5
5
|
* `perform_action` runs the post-parse pipeline that every action-spec
|
|
6
6
|
* handler must traverse:
|
|
7
7
|
*
|
|
8
|
-
* 1. **Pre-
|
|
9
|
-
* on `'required'` axes
|
|
10
|
-
*
|
|
11
|
-
* 2. **Validate params (400)** — `spec.input.safeParse(raw_params)` with
|
|
12
|
-
* `z.void()` / `?? {}` rules. The validated input lands inside the
|
|
13
|
-
* function so the authorization phase reads `acting` as a typed Zod
|
|
14
|
-
* field.
|
|
15
|
-
* 3. **Authorization phase** — when `auth.actor !== 'none'` (or
|
|
8
|
+
* 1. **Pre-authorization auth (401)** — short-circuits unauthenticated callers
|
|
9
|
+
* on `'required'` axes.
|
|
10
|
+
* 2. **Authorization phase** — when `auth.actor !== 'none'` (or
|
|
16
11
|
* `auth.account !== 'none' && actor === 'none'`), resolves the actor
|
|
17
12
|
* via `apply_authorization_phase` against the supplied `account_id`
|
|
18
|
-
* plus `
|
|
19
|
-
*
|
|
13
|
+
* plus the request's `acting` selector, read off the raw params by
|
|
14
|
+
* `read_acting`. Failures fold into a JSON-RPC error envelope. The
|
|
15
|
+
* test-harness escape hatch lives in the caller — pass
|
|
20
16
|
* `preset.request_context` to skip the live phase and use a pre-baked
|
|
21
17
|
* context instead.
|
|
22
|
-
*
|
|
23
|
-
* `auth.roles` against the resolved
|
|
18
|
+
* 3. **Post-authorization auth (403)** — gates `auth.credential_types`,
|
|
19
|
+
* the credential's `token_scope`, and `auth.roles` against the resolved
|
|
20
|
+
* context.
|
|
21
|
+
* 4. **Validate params (400)** — `spec.input.safeParse(raw_params)` with
|
|
22
|
+
* `z.void()` / `?? {}` rules. Runs **after** the authority gates: a
|
|
23
|
+
* 400 describing the action's input shape to a caller those gates
|
|
24
|
+
* refuse would confirm the method exists and describe how to call it,
|
|
25
|
+
* to a channel that should have learned nothing. Coarse authority
|
|
26
|
+
* facts before fine ones, and no shape disclosure before authority is
|
|
27
|
+
* settled — the same argument that puts the credential gate ahead of
|
|
28
|
+
* the scope gate. The cost: a malformed request from an *authenticated*
|
|
29
|
+
* caller now pays for the authorization phase's actor + role_grant reads
|
|
30
|
+
* before being rejected, where validation used to turn it away first.
|
|
31
|
+
* Not a new DoS lever — the same caller reaches that work anyway by
|
|
32
|
+
* sending well-formed params — but it does remove a cheap-rejection path
|
|
33
|
+
* that buggy clients used to hit.
|
|
24
34
|
* 5. **Rate limit (429)** — per-action IP / account throttling, throttle-
|
|
25
35
|
* requests semantics (every invocation records, regardless of outcome).
|
|
26
36
|
* 6. **Dispatch + DEV-only output validation + error normalization** —
|
|
@@ -45,50 +55,39 @@ import { dispatch_with_post_commit_rollback } from '../http/pending_effects.js';
|
|
|
45
55
|
import { JSONRPC_VERSION } from '../http/jsonrpc.js';
|
|
46
56
|
import { jsonrpc_error_messages, jsonrpc_error_code_to_http_status, http_status_to_jsonrpc_error_code, JSONRPC_ERROR_CODES, dev_only } from '../http/jsonrpc_errors.js';
|
|
47
57
|
import { ERROR_AUTHENTICATION_REQUIRED, ERROR_INSUFFICIENT_PERMISSIONS, ERROR_CREDENTIAL_TYPE_REQUIRED, ERROR_TOKEN_SCOPE_REQUIRED } from '../http/error_schemas.js';
|
|
48
|
-
import { is_public_auth } from '../http/auth_shape.js';
|
|
49
|
-
import { token_scope_admits_method } from '../auth/token_scope.js';
|
|
58
|
+
import { is_public_auth, needs_actor, parse_acting } from '../http/auth_shape.js';
|
|
59
|
+
import { token_scope_admits_method, token_scope_method_capability } from '../auth/token_scope.js';
|
|
50
60
|
/**
|
|
51
61
|
* The shared dispatch core. Pure data — no Hono context, no socket. Each
|
|
52
62
|
* transport calls into this with pre-parsed inputs and binds the result
|
|
53
63
|
* to its wire shape.
|
|
54
64
|
*
|
|
55
|
-
* Phase order: 401 →
|
|
56
|
-
* dispatcher skips the live authorization phase and uses the supplied
|
|
57
|
-
* pre-baked context for post-authorization checks;
|
|
58
|
-
*
|
|
65
|
+
* Phase order: 401 → authz → 403 → 400 → handler. On the test-preset path
|
|
66
|
+
* the dispatcher skips the live authorization phase and uses the supplied
|
|
67
|
+
* pre-baked context for post-authorization checks; the 401 still fires when
|
|
68
|
+
* the harness omits `account_id`.
|
|
59
69
|
*/
|
|
60
70
|
export const perform_action = async (input, deps) => {
|
|
61
71
|
const { action, raw_params, request_id: id, account_id, credential_type, token_scope, client_ip, signal, notify, connection_id, request_client, preset } = input;
|
|
62
72
|
const { db, pending_effects, post_commit_effects, log, action_ip_rate_limiter, action_account_rate_limiter } = deps;
|
|
63
73
|
const { spec, handler } = action;
|
|
64
74
|
const action_auth = spec.auth;
|
|
65
|
-
// step 1: pre-
|
|
66
|
-
const pre =
|
|
75
|
+
// step 1: pre-authorization auth — 401 short-circuit.
|
|
76
|
+
const pre = check_action_auth_pre_authorization(action_auth, account_id);
|
|
67
77
|
if (pre)
|
|
68
78
|
return error_result(pre);
|
|
69
|
-
// step 2:
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return error_result(jsonrpc_error_messages.invalid_params('invalid params', dev_only({ issues: parse_result.error.issues })));
|
|
76
|
-
}
|
|
77
|
-
const validated_input = parse_result.data;
|
|
78
|
-
// step 3: authorization phase. `acting` reads off the typed Zod field
|
|
79
|
-
// validated in step 2. Per registry-time invariant 2,
|
|
80
|
-
// `auth.actor !== 'none' ⟺ input declares acting?: ActingActor` — so
|
|
81
|
-
// the typed read is safe whenever the dispatcher needs it.
|
|
79
|
+
// step 2: authorization phase. `acting` reads off the raw params rather
|
|
80
|
+
// than a validated field, because validation now runs after the authority
|
|
81
|
+
// gates. Per registry-time invariant 2,
|
|
82
|
+
// `auth.actor !== 'none' ⟺ input declares acting?: ActingActor` — so the
|
|
83
|
+
// selector is present on exactly the specs that need it, and step 4 is
|
|
84
|
+
// what rejects a malformed one.
|
|
82
85
|
let request_context = null;
|
|
83
86
|
if (preset !== undefined) {
|
|
84
87
|
request_context = preset.request_context;
|
|
85
88
|
}
|
|
86
89
|
else if (!is_public_auth(action_auth)) {
|
|
87
|
-
const
|
|
88
|
-
const acting_value = validated_with_acting && typeof validated_with_acting.acting === 'string'
|
|
89
|
-
? validated_with_acting.acting
|
|
90
|
-
: undefined;
|
|
91
|
-
const result = await apply_authorization_phase({ db }, account_id, action_auth, acting_value);
|
|
90
|
+
const result = await apply_authorization_phase({ db }, account_id, action_auth, read_acting(action_auth, raw_params));
|
|
92
91
|
if (!result.ok) {
|
|
93
92
|
const { error: reason, ...rest } = result.body;
|
|
94
93
|
const code = http_status_to_jsonrpc_error_code(result.status);
|
|
@@ -102,10 +101,19 @@ export const perform_action = async (input, deps) => {
|
|
|
102
101
|
// unauthenticated-optional axis; the handler sees null in either case.
|
|
103
102
|
request_context = result.request_context;
|
|
104
103
|
}
|
|
105
|
-
// step
|
|
104
|
+
// step 3: post-authorization auth — credential gate, then scope, then role.
|
|
106
105
|
const post = check_action_auth_post_authorization(action_auth, request_context, credential_type, action.spec.method, token_scope);
|
|
107
106
|
if (post)
|
|
108
107
|
return error_result(post);
|
|
108
|
+
// step 4: validate params. JSON-RPC 2.0 §4.2 forbids `params: null`;
|
|
109
|
+
// registration sites reject `z.null()` inputs. Empty-body convention
|
|
110
|
+
// (`raw_params ?? {}`) lets all-optional-object methods omit `params`.
|
|
111
|
+
const params = is_void_schema(spec.input) ? raw_params : (raw_params ?? {});
|
|
112
|
+
const parse_result = spec.input.safeParse(params);
|
|
113
|
+
if (!parse_result.success) {
|
|
114
|
+
return error_result(jsonrpc_error_messages.invalid_params('invalid params', dev_only({ issues: parse_result.error.issues })));
|
|
115
|
+
}
|
|
116
|
+
const validated_input = parse_result.data;
|
|
109
117
|
// step 5: rate limit — throttle-requests semantics (record on every
|
|
110
118
|
// invocation, no success-reset). Same limiters shared with the WS
|
|
111
119
|
// dispatcher so an attacker can't switch transports to bypass the budget.
|
|
@@ -203,16 +211,33 @@ const rate_limited_result = (retry_after) => {
|
|
|
203
211
|
};
|
|
204
212
|
};
|
|
205
213
|
/**
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
214
|
+
* Read the `acting` actor selector off the raw params for the authorization
|
|
215
|
+
* phase.
|
|
216
|
+
*
|
|
217
|
+
* Consulted only when the spec declares an actor axis; `parse_acting` owns
|
|
218
|
+
* what counts as a selector and why a malformed one reads as omitted.
|
|
219
|
+
*
|
|
220
|
+
* @param auth - the spec's auth shape; no actor axis means no selector
|
|
221
|
+
* @param raw_params - the request's unvalidated params
|
|
222
|
+
* @returns the actor id, or `undefined` when absent, malformed, or unwanted
|
|
223
|
+
*/
|
|
224
|
+
const read_acting = (auth, raw_params) => {
|
|
225
|
+
if (!needs_actor(auth))
|
|
226
|
+
return undefined;
|
|
227
|
+
if (typeof raw_params !== 'object' || raw_params === null)
|
|
228
|
+
return undefined;
|
|
229
|
+
return parse_acting(raw_params.acting);
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Pre-authorization auth gate — fires before the authorization phase so
|
|
233
|
+
* missing credentials short-circuit with `unauthenticated`.
|
|
209
234
|
*
|
|
210
235
|
* 401 fires when `auth.account === 'required'` (or `auth.actor === 'required'`,
|
|
211
236
|
* since registry-time invariant 3 forbids accountless actors in v1) and
|
|
212
237
|
* no account is on the request. `'optional'` axes pass through — the
|
|
213
238
|
* authorization phase decides based on whatever the credential supports.
|
|
214
239
|
*/
|
|
215
|
-
const
|
|
240
|
+
const check_action_auth_pre_authorization = (auth, account_id) => {
|
|
216
241
|
if (auth.account === 'required' || auth.actor === 'required') {
|
|
217
242
|
if (account_id == null) {
|
|
218
243
|
// Carry the reason on `error.data.reason` (symmetric with the 403
|
|
@@ -264,7 +289,7 @@ const check_action_auth_post_authorization = (auth, request_context, credential_
|
|
|
264
289
|
if (token_scope && !token_scope_admits_method(token_scope, method)) {
|
|
265
290
|
return jsonrpc_error_messages.forbidden('forbidden', {
|
|
266
291
|
reason: ERROR_TOKEN_SCOPE_REQUIRED,
|
|
267
|
-
required_scope:
|
|
292
|
+
required_scope: token_scope_method_capability(method)
|
|
268
293
|
});
|
|
269
294
|
}
|
|
270
295
|
if (auth.roles?.length) {
|
|
@@ -174,10 +174,10 @@ export interface RegisterActionWsResult {
|
|
|
174
174
|
* - Notifications (method + no id) are silently dropped per JSON-RPC spec.
|
|
175
175
|
* Exception: `cancel` notifications abort the matching pending request's
|
|
176
176
|
* `ctx.signal` before bubbling out.
|
|
177
|
-
* - Per-message dispatch goes through `perform_action`: pre-
|
|
178
|
-
* auth (401) →
|
|
179
|
-
*
|
|
180
|
-
*
|
|
177
|
+
* - Per-message dispatch goes through `perform_action`: pre-authorization
|
|
178
|
+
* auth (401) → authorization phase → post-authorization auth (403) →
|
|
179
|
+
* input validation (400) → rate limit (429) → handler (with transaction
|
|
180
|
+
* wrap iff `spec.side_effects: true`) → DEV output validation.
|
|
181
181
|
* - Authorization phase runs **per message** — role_grant changes during a
|
|
182
182
|
* connection lifetime are picked up on the next message without any
|
|
183
183
|
* in-place refresh. Authentication invalidation closes the socket via
|
|
@@ -53,10 +53,10 @@ export const DEFAULT_SERVER_HEARTBEAT_TIMEOUT = 60_000;
|
|
|
53
53
|
* - Notifications (method + no id) are silently dropped per JSON-RPC spec.
|
|
54
54
|
* Exception: `cancel` notifications abort the matching pending request's
|
|
55
55
|
* `ctx.signal` before bubbling out.
|
|
56
|
-
* - Per-message dispatch goes through `perform_action`: pre-
|
|
57
|
-
* auth (401) →
|
|
58
|
-
*
|
|
59
|
-
*
|
|
56
|
+
* - Per-message dispatch goes through `perform_action`: pre-authorization
|
|
57
|
+
* auth (401) → authorization phase → post-authorization auth (403) →
|
|
58
|
+
* input validation (400) → rate limit (429) → handler (with transaction
|
|
59
|
+
* wrap iff `spec.side_effects: true`) → DEV output validation.
|
|
60
60
|
* - Authorization phase runs **per message** — role_grant changes during a
|
|
61
61
|
* connection lifetime are picked up on the next message without any
|
|
62
62
|
* in-place refresh. Authentication invalidation closes the socket via
|
|
@@ -98,7 +98,7 @@ export const register_action_ws = (options) => {
|
|
|
98
98
|
// scoped token that could upgrade would observe far more than it was
|
|
99
99
|
// granted.
|
|
100
100
|
async (c, next) => {
|
|
101
|
-
const denied = token_scope_surface_denial(c, 'ws_upgrade');
|
|
101
|
+
const denied = token_scope_surface_denial(c, 'surface:ws_upgrade');
|
|
102
102
|
if (denied)
|
|
103
103
|
return denied;
|
|
104
104
|
return next();
|
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
* 1. `verify_request_source(allowed_origins)` — reject disallowed origins
|
|
8
8
|
* before the upgrade handshake runs.
|
|
9
9
|
* 2. `require_auth` — reject unauthenticated upgrades.
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. `require_token_scope('surface:ws_upgrade')` — rule 3: a narrowed token
|
|
11
|
+
* cannot hold a socket. Mounted here, ahead of the role guard, so a
|
|
12
|
+
* narrowed token is told about its scope rather than about a role it
|
|
13
|
+
* also lacks. `register_action_ws` repeats the check on its own route
|
|
14
|
+
* chain, which is what covers consumers mounting it directly; the
|
|
15
|
+
* earlier answer wins when both are wired.
|
|
16
|
+
* 4. **Authorization phase** — resolve the acting actor against the
|
|
11
17
|
* authenticated account plus an optional `?acting=<uuid>` query string,
|
|
12
18
|
* and build the `RequestContext` that per-message dispatch reads.
|
|
13
19
|
* Multi-actor accounts must supply `?acting` to pick a persona;
|
|
14
20
|
* single-actor accounts work without it.
|
|
15
|
-
*
|
|
21
|
+
* 5. Optional `require_role(required_roles)` — for endpoints gated to a
|
|
16
22
|
* non-empty any-of set of roles.
|
|
17
23
|
*
|
|
18
24
|
* Then delegates to `register_action_ws` for per-message JSON-RPC
|
|
@@ -43,14 +49,16 @@ export interface RegisterWsEndpointOptions extends RegisterActionWsOptions {
|
|
|
43
49
|
}
|
|
44
50
|
/**
|
|
45
51
|
* Mount a WebSocket endpoint with the standard upgrade stack (origin check
|
|
46
|
-
* + auth + actor resolution + optional role) and JSON-RPC
|
|
52
|
+
* + auth + token scope + actor resolution + optional role) and JSON-RPC
|
|
53
|
+
* dispatch.
|
|
47
54
|
*
|
|
48
55
|
* Returns the `BackendWebsocketTransport` (supplied or freshly
|
|
49
56
|
* created), same as `register_action_ws` — retain it to wire
|
|
50
57
|
* `create_ws_auth_guard` on `on_audit_event` or to broadcast.
|
|
51
58
|
*
|
|
52
|
-
* @mutates options.app - applies origin/auth/authorization/role middleware
|
|
53
|
-
* then registers the `GET path` route via the inner
|
|
59
|
+
* @mutates options.app - applies origin/auth/scope/authorization/role middleware
|
|
60
|
+
* via `app.use`, then registers the `GET path` route via the inner
|
|
61
|
+
* `register_action_ws`
|
|
54
62
|
*/
|
|
55
63
|
export declare const register_ws_endpoint: (options: RegisterWsEndpointOptions) => RegisterActionWsResult;
|
|
56
64
|
//# sourceMappingURL=register_ws_endpoint.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"register_ws_endpoint.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/actions/register_ws_endpoint.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAaH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGvD,OAAO,EAEN,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,MAAM,yBAAyB,CAAC;AAEjC,0CAA0C;AAC1C,MAAM,WAAW,yBAA0B,SAAQ,uBAAuB;IACzE;;;;OAIG;IACH,eAAe,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC;IACvC;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,aAAa,CAAC,QAAQ,CAAC,CAAC;CACzC;AAgDD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,oBAAoB,GAChC,SAAS,yBAAyB,KAChC,sBAoBF,CAAC"}
|
|
@@ -7,12 +7,18 @@
|
|
|
7
7
|
* 1. `verify_request_source(allowed_origins)` — reject disallowed origins
|
|
8
8
|
* before the upgrade handshake runs.
|
|
9
9
|
* 2. `require_auth` — reject unauthenticated upgrades.
|
|
10
|
-
* 3.
|
|
10
|
+
* 3. `require_token_scope('surface:ws_upgrade')` — rule 3: a narrowed token
|
|
11
|
+
* cannot hold a socket. Mounted here, ahead of the role guard, so a
|
|
12
|
+
* narrowed token is told about its scope rather than about a role it
|
|
13
|
+
* also lacks. `register_action_ws` repeats the check on its own route
|
|
14
|
+
* chain, which is what covers consumers mounting it directly; the
|
|
15
|
+
* earlier answer wins when both are wired.
|
|
16
|
+
* 4. **Authorization phase** — resolve the acting actor against the
|
|
11
17
|
* authenticated account plus an optional `?acting=<uuid>` query string,
|
|
12
18
|
* and build the `RequestContext` that per-message dispatch reads.
|
|
13
19
|
* Multi-actor accounts must supply `?acting` to pick a persona;
|
|
14
20
|
* single-actor accounts work without it.
|
|
15
|
-
*
|
|
21
|
+
* 5. Optional `require_role(required_roles)` — for endpoints gated to a
|
|
16
22
|
* non-empty any-of set of roles.
|
|
17
23
|
*
|
|
18
24
|
* Then delegates to `register_action_ws` for per-message JSON-RPC
|
|
@@ -21,7 +27,7 @@
|
|
|
21
27
|
* @module
|
|
22
28
|
*/
|
|
23
29
|
import { Logger } from '@fuzdev/fuz_util/log.ts';
|
|
24
|
-
import { apply_authorization_phase, REQUEST_CONTEXT_KEY, require_auth, require_role } from '../auth/request_context.js';
|
|
30
|
+
import { apply_authorization_phase, REQUEST_CONTEXT_KEY, require_auth, require_role, require_token_scope } from '../auth/request_context.js';
|
|
25
31
|
import { verify_request_source } from '../http/origin.js';
|
|
26
32
|
import { ACCOUNT_ID_KEY, TEST_CONTEXT_PRESET_KEY } from '../hono_context.js';
|
|
27
33
|
import { register_action_ws } from './register_action_ws.js';
|
|
@@ -67,19 +73,22 @@ const create_ws_authorization_middleware = (db) => {
|
|
|
67
73
|
};
|
|
68
74
|
/**
|
|
69
75
|
* Mount a WebSocket endpoint with the standard upgrade stack (origin check
|
|
70
|
-
* + auth + actor resolution + optional role) and JSON-RPC
|
|
76
|
+
* + auth + token scope + actor resolution + optional role) and JSON-RPC
|
|
77
|
+
* dispatch.
|
|
71
78
|
*
|
|
72
79
|
* Returns the `BackendWebsocketTransport` (supplied or freshly
|
|
73
80
|
* created), same as `register_action_ws` — retain it to wire
|
|
74
81
|
* `create_ws_auth_guard` on `on_audit_event` or to broadcast.
|
|
75
82
|
*
|
|
76
|
-
* @mutates options.app - applies origin/auth/authorization/role middleware
|
|
77
|
-
* then registers the `GET path` route via the inner
|
|
83
|
+
* @mutates options.app - applies origin/auth/scope/authorization/role middleware
|
|
84
|
+
* via `app.use`, then registers the `GET path` route via the inner
|
|
85
|
+
* `register_action_ws`
|
|
78
86
|
*/
|
|
79
87
|
export const register_ws_endpoint = (options) => {
|
|
80
88
|
const { app, path, allowed_origins, db, required_roles, log = new Logger('[ws]'), ...rest } = options;
|
|
81
89
|
app.use(path, verify_request_source(allowed_origins));
|
|
82
90
|
app.use(path, require_auth);
|
|
91
|
+
app.use(path, require_token_scope('surface:ws_upgrade'));
|
|
83
92
|
app.use(path, create_ws_authorization_middleware(db));
|
|
84
93
|
if (required_roles?.length) {
|
|
85
94
|
app.use(path, require_role(required_roles));
|
package/dist/auth/CLAUDE.md
CHANGED
|
@@ -61,6 +61,7 @@ Convention — `*_schema.ts` is Zod-only; `*_ddl.ts` holds DDL strings.
|
|
|
61
61
|
- `auth/scope_kind_schema.ts` — `ScopeKindName`, `create_scope_kind_schema` (open registry, no builtins).
|
|
62
62
|
- `auth/credential_type_schema.ts` — `CredentialTypeName`, `CREDENTIAL_TYPE_SESSION` / `_API_TOKEN` / `_DAEMON_TOKEN`, `create_credential_type_schema`.
|
|
63
63
|
- `auth/grant_path_schema.ts` — `GrantPathName`, `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP`, `create_grant_path_schema`.
|
|
64
|
+
- `auth/token_scope.ts` — per-credential authority narrowing stored on `api_token.scope`: `TokenScope` / `TokenScopeInput` + constructors, `parse_stored_token_scope` (fail-closed), `token_scope_admits_method` / `_non_rpc`, and the `<section>:<id>` capability vocabulary `RouteAuth.required_scope` is written in (`parse_token_scope_capability`, `token_scope_admits_capability`, `token_scope_denied_body`, `TOKEN_SURFACES`). Not an open registry — see ../../../docs/security.md §Token scoping for why the identifier half is deliberately unregistered.
|
|
64
65
|
- `auth/auth_ddl.ts` — `CREATE TABLE` / index / seed strings for the core identity tables.
|
|
65
66
|
- `auth/audit_log_schema.ts` — `AUDIT_EVENT_TYPES` (27 builtins), `AuditEventType` / `AuditEventTypeName`, `audit_metadata_schemas`, `AuditLogEvent`, `AuditLogInput`, `AuditLogConfig`, `create_audit_log_config`.
|
|
66
67
|
- `auth/audit_log_ddl.ts` — `audit_log` table DDL with `seq BIGSERIAL` for cursor-based gap fill (BIGSERIAL converges with the Rust spine; `create_db` registers a `pg.types` int8 parser so `seq` still reads as a JS number).
|
|
@@ -123,7 +124,7 @@ they track the same config. Sample via `get_*`; `reset_*` are test-only.
|
|
|
123
124
|
- `auth/bootstrap_routes.ts` — `POST /bootstrap` + `check_bootstrap_status`; `BootstrapStatus` runtime ref.
|
|
124
125
|
- `auth/signup_routes.ts` — `POST /signup` (open or invite-gated).
|
|
125
126
|
- `auth/audit_log_routes.ts` — optional `GET /audit/stream` (SSE); list/history are on the RPC surface.
|
|
126
|
-
- `auth/auth_guard_resolver.ts` — `fuz_auth_guard_resolver` injected into `apply_route_specs` so the framework stays auth-agnostic.
|
|
127
|
+
- `auth/auth_guard_resolver.ts` — `fuz_auth_guard_resolver` injected into `apply_route_specs` so the framework stays auth-agnostic. Maps each `RouteAuth` axis to a guard and is the single site every route spec traverses, so it also carries the two invariants the schema can't (a `required_scope` that isn't a well-formed capability, and one on an unrestricted route).
|
|
127
128
|
|
|
128
129
|
**Hono-free route shapes.** Each cookie/SSE-coupled route module has a sibling
|
|
129
130
|
`*_route_schema.ts` (`account_route_schema.ts`, `signup_route_schema.ts`,
|
|
@@ -158,7 +159,7 @@ Everything else listed under §RPC action surfaces.
|
|
|
158
159
|
### Middleware
|
|
159
160
|
|
|
160
161
|
- `auth/middleware.ts` — `create_auth_middleware_specs(deps, options)` assembles `[origin, session, request_context, bearer_auth]` + optional `daemon_token`.
|
|
161
|
-
- `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role`), guards (`require_auth`, `require_role`, `require_credential_types`), `refresh_role_grants`.
|
|
162
|
+
- `auth/request_context.ts` — `RequestContext`, `resolve_acting_actor`, `build_request_context`, predicates (`has_role`, `has_scoped_role`, `has_any_scoped_role`), guards (`require_auth`, `require_role`, `require_credential_types`, `require_token_scope`), `token_scope_surface_denial` (the direct-call form, for surfaces that aren't route specs), `refresh_role_grants`.
|
|
162
163
|
- `auth/session_middleware.ts` — `process_session_cookie` integration, `create_session_and_set_cookie` (shared by login / signup / bootstrap).
|
|
163
164
|
- `auth/bearer_auth.ts` — soft-fail bearer middleware; rejects when `Origin` or `Referer` present (browser context).
|
|
164
165
|
- `auth/daemon_token_middleware.ts` — `start_daemon_token_rotation` + `create_daemon_token_middleware(state, deps, log)` (atomic file write, soft-fail validation, keeper account resolution). Soft-fails — discards the credential (pass-through, no own 401/503) on **every** non-success path: browser context (`Origin`/`Referer` present), malformed/invalid token, and valid-token-but-no-keeper all `next()` through to the dispatcher's `credential_type_required` (403) gate, mirroring the bearer guard and the Rust spine's `resolve.rs`. Daemon tokens are loopback-only, so browser context never arises in practice — the discard is defense-in-depth.
|
|
@@ -274,12 +275,15 @@ Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid credential.
|
|
|
274
275
|
Account-only — never loads actor or role_grants, never populates
|
|
275
276
|
`REQUEST_CONTEXT_KEY`.
|
|
276
277
|
|
|
277
|
-
**Authorization runs
|
|
278
|
-
401 →
|
|
279
|
-
When the route's input declares `acting?: ActingActor` or its auth
|
|
280
|
-
role_grants, the authorization phase calls `resolve_acting_actor` over
|
|
281
|
-
|
|
282
|
-
|
|
278
|
+
**Authorization runs before input validation**, matching the dispatcher's
|
|
279
|
+
401 → authz → 403 → 400 phase order (see `http/CLAUDE.md` §Validation
|
|
280
|
+
pipeline). When the route's input declares `acting?: ActingActor` or its auth
|
|
281
|
+
requires role_grants, the authorization phase calls `resolve_acting_actor` over
|
|
282
|
+
the `acting` selector — the validated query value on GETs, read off the raw
|
|
283
|
+
body on mutations — and builds an actor-bound `RequestContext`. A malformed
|
|
284
|
+
selector reads as omitted (it only ever picks among actors the account already
|
|
285
|
+
owns, and input validation rejects it a step later). Account-grain routes run
|
|
286
|
+
with `RequestContext.actor: null`.
|
|
283
287
|
|
|
284
288
|
`apply_authorization_phase` is pure data — returns `AuthorizationResult`
|
|
285
289
|
(`{ok: true, request_context: RequestContext | null} | {ok: false, status, body}`)
|
|
@@ -399,7 +403,10 @@ Keeper is not a dedicated guard — it's a composable `RouteAuth` shape:
|
|
|
399
403
|
`{account: 'required', actor: 'required', roles: ['keeper'], credential_types: ['daemon_token']}`.
|
|
400
404
|
The two-part check is `require_credential_types(['daemon_token'])` (403
|
|
401
405
|
`ERROR_CREDENTIAL_TYPE_REQUIRED`) followed by `require_role(['keeper'])`
|
|
402
|
-
(403 `ERROR_INSUFFICIENT_PERMISSIONS`)
|
|
406
|
+
(403 `ERROR_INSUFFICIENT_PERMISSIONS`) — the channel gate lands
|
|
407
|
+
pre-authorization and the role gate post-authorization, so a non-daemon
|
|
408
|
+
credential is refused before the route resolves an actor for it. Same
|
|
409
|
+
scope-aware semantics mirrored
|
|
403
410
|
in the HTTP RPC dispatcher (`actions/action_rpc.ts`), the WS dispatcher
|
|
404
411
|
(`actions/register_action_ws.ts`), and the admin bypasses inside
|
|
405
412
|
`auth/role_grant_offer_actions.ts`.
|
|
@@ -79,7 +79,16 @@ export declare const PasswordChangeOutput: z.ZodObject<{
|
|
|
79
79
|
tokens_revoked: z.ZodNumber;
|
|
80
80
|
}, z.core.$strict>;
|
|
81
81
|
export type PasswordChangeOutput = z.infer<typeof PasswordChangeOutput>;
|
|
82
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* Default maximum sessions per account.
|
|
84
|
+
*
|
|
85
|
+
* The Rust twin pins the same 5 as a `const` (`fuz_auth`'s
|
|
86
|
+
* `DEFAULT_MAX_SESSIONS`) rather than a route-state field, because its
|
|
87
|
+
* `AccountRouteState` has no `Default` and a new field would break every
|
|
88
|
+
* exhaustive literal in every consumer workspace. So this stays the
|
|
89
|
+
* configurable side of the pair (`null` disables), and the two ship the same
|
|
90
|
+
* number — retuning means editing both.
|
|
91
|
+
*/
|
|
83
92
|
export declare const DEFAULT_MAX_SESSIONS = 5;
|
|
84
93
|
/** Default maximum API tokens per account. */
|
|
85
94
|
export declare const DEFAULT_MAX_TOKENS = 10;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE
|
|
1
|
+
{"version":3,"file":"account_route_schema.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_route_schema.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAKxB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAQvD,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,WAAW,CAAC;AAC3C,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAEpE,4CAA4C;AAC5C,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;kBAI9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,4EAA4E;AAC5E,eAAO,MAAM,iCAAiC;;;iBAG5C,CAAC;AACH,MAAM,MAAM,iCAAiC,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iCAAiC,CAAC,CAAC;AAElG,oFAAoF;AACpF,eAAO,MAAM,UAAU;;;kBAGrB,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAEpD,6EAA6E;AAC7E,eAAO,MAAM,WAAW;;kBAEtB,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,2EAA2E;AAC3E,eAAO,MAAM,WAAW,WAAW,CAAC;AACpC,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEtD,wFAAwF;AACxF,eAAO,MAAM,YAAY;;;kBAGvB,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAExD,sHAAsH;AACtH,eAAO,MAAM,mBAAmB;;;kBAG9B,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEtE,uGAAuG;AACvG,eAAO,MAAM,oBAAoB;;;;kBAI/B,CAAC;AACH,MAAM,MAAM,oBAAoB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAExE;;;;;;;;;GASG;AACH,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAEtC,8CAA8C;AAC9C,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAUD,CAAC;AAEvC,8EAA8E;AAC9E,MAAM,WAAW,wBAAwB;IACxC,8FAA8F;IAC9F,0BAA0B,EAAE,OAAO,CAAC;CACpC;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,GACvC,SAAS,wBAAwB,KAC/B,CACF,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,EAC1B,IAAI,CAAC,SAAS,EAAE,SAAS,CAAC,CAoD1B,CAAC"}
|
|
@@ -56,7 +56,16 @@ export const PasswordChangeOutput = z.strictObject({
|
|
|
56
56
|
sessions_revoked: z.number(),
|
|
57
57
|
tokens_revoked: z.number()
|
|
58
58
|
});
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Default maximum sessions per account.
|
|
61
|
+
*
|
|
62
|
+
* The Rust twin pins the same 5 as a `const` (`fuz_auth`'s
|
|
63
|
+
* `DEFAULT_MAX_SESSIONS`) rather than a route-state field, because its
|
|
64
|
+
* `AccountRouteState` has no `Default` and a new field would break every
|
|
65
|
+
* exhaustive literal in every consumer workspace. So this stays the
|
|
66
|
+
* configurable side of the pair (`null` disables), and the two ship the same
|
|
67
|
+
* number — retuning means editing both.
|
|
68
|
+
*/
|
|
60
69
|
export const DEFAULT_MAX_SESSIONS = 5;
|
|
61
70
|
/** Default maximum API tokens per account. */
|
|
62
71
|
export const DEFAULT_MAX_TOKENS = 10;
|
|
@@ -69,20 +69,34 @@ export declare const DEFAULT_LOGIN_FAIL_FLOOR_MS = 250;
|
|
|
69
69
|
*/
|
|
70
70
|
export declare const DEFAULT_LOGIN_FAIL_JITTER_MS = 25;
|
|
71
71
|
/**
|
|
72
|
-
* Shared options for route factories that create sessions
|
|
72
|
+
* Shared options for route factories that create sessions.
|
|
73
73
|
*
|
|
74
74
|
* Extended by `AccountRouteOptions` and `SignupRouteOptions`.
|
|
75
75
|
* Consumers can destructure these from `AppServerContext` once and spread into multiple factories.
|
|
76
|
+
*
|
|
77
|
+
* The per-IP limiter is deliberately *not* here. Each auth surface names its
|
|
78
|
+
* own (`login_ip_rate_limiter`, `signup_ip_rate_limiter`,
|
|
79
|
+
* `bootstrap_ip_rate_limiter`) because the buckets are monotone within their
|
|
80
|
+
* window — see `RateLimiter.reset`. A shared base field made one instance the
|
|
81
|
+
* path of least resistance, which let a failure on any surface spend the
|
|
82
|
+
* budget that bounds guessing on every other one.
|
|
76
83
|
*/
|
|
77
84
|
export interface AuthSessionRouteOptions {
|
|
78
85
|
session_options: SessionOptions<string>;
|
|
79
|
-
/** Rate limiter for auth attempts, keyed by client IP. Pass `null` to disable. */
|
|
80
|
-
ip_rate_limiter: RateLimiter | null;
|
|
81
86
|
}
|
|
82
87
|
/**
|
|
83
88
|
* Per-factory configuration for account route specs.
|
|
84
89
|
*/
|
|
85
90
|
export interface AccountRouteOptions extends AuthSessionRouteOptions {
|
|
91
|
+
/**
|
|
92
|
+
* Rate limiter for login + password-change attempts, keyed by client IP.
|
|
93
|
+
* Pass `null` to disable. The distributed-spray backstop: login is the one
|
|
94
|
+
* surface that guesses *other* accounts' credentials, so this bucket is
|
|
95
|
+
* never refunded on success (see `RateLimiter.reset`). Password change
|
|
96
|
+
* shares it — it is password-bearing on the same account grain, and the
|
|
97
|
+
* Rust spine shares the same instance across both.
|
|
98
|
+
*/
|
|
99
|
+
login_ip_rate_limiter: RateLimiter | null;
|
|
86
100
|
/** Rate limiter for login attempts, keyed by submitted username. Pass `null` to disable. */
|
|
87
101
|
login_account_rate_limiter: RateLimiter | null;
|
|
88
102
|
/** Max active sessions per account. Evicts oldest on login. Default 5, `null` disables. */
|
|
@@ -130,7 +144,7 @@ export interface AccountRouteOptions extends AuthSessionRouteOptions {
|
|
|
130
144
|
* Self-service session/token management is on `auth/account_actions.ts`.
|
|
131
145
|
*
|
|
132
146
|
* @param deps - stateless capabilities (keyring, password, log)
|
|
133
|
-
* @param options - per-factory configuration (session_options,
|
|
147
|
+
* @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
|
|
134
148
|
* @returns route specs (not yet applied to Hono)
|
|
135
149
|
*/
|
|
136
150
|
export declare const create_account_route_specs: (deps: RouteFactoryDeps, options: AccountRouteOptions) => Array<RouteSpec>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AA4B1D,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAGxE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C
|
|
1
|
+
{"version":3,"file":"account_routes.d.ts","sourceRoot":"../src/lib/","sources":["../../src/lib/auth/account_routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AA4B1D,OAAO,EAAmB,KAAK,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE,OAAO,EAAgC,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACpF,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAGxE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gCAAgC,GAAI,UAAU,oBAAoB,KAAG,SA4EhF,CAAC;AAEH,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACpC,yDAAyD;IACzD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8FAA8F;IAC9F,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,MAAM,CAAC;AAE/C;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,KAAK,CAAC;AAQ/C;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,uBAAuB;IACvC,eAAe,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACxC;AAED;;GAEG;AACH,MAAM,WAAW,mBAAoB,SAAQ,uBAAuB;IACnE;;;;;;;OAOG;IACH,qBAAqB,EAAE,WAAW,GAAG,IAAI,CAAC;IAC1C,4FAA4F;IAC5F,0BAA0B,EAAE,WAAW,GAAG,IAAI,CAAC;IAC/C,2FAA2F;IAC3F,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC5C;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE;QAAE,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;CAC1C;AAID;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,0BAA0B,GACtC,MAAM,gBAAgB,EACtB,SAAS,mBAAmB,KAC1B,KAAK,CAAC,SAAS,CA6RjB,CAAC"}
|
|
@@ -155,12 +155,12 @@ const login_fail_delay = (floor_ms, jitter_ms) => {
|
|
|
155
155
|
* Self-service session/token management is on `auth/account_actions.ts`.
|
|
156
156
|
*
|
|
157
157
|
* @param deps - stateless capabilities (keyring, password, log)
|
|
158
|
-
* @param options - per-factory configuration (session_options,
|
|
158
|
+
* @param options - per-factory configuration (session_options, login_ip_rate_limiter, login_account_rate_limiter, bootstrap_status)
|
|
159
159
|
* @returns route specs (not yet applied to Hono)
|
|
160
160
|
*/
|
|
161
161
|
export const create_account_route_specs = (deps, options) => {
|
|
162
162
|
const { keyring, password } = deps;
|
|
163
|
-
const { session_options,
|
|
163
|
+
const { session_options, login_ip_rate_limiter, login_account_rate_limiter, max_sessions = DEFAULT_MAX_SESSIONS, login_fail_floor_ms = DEFAULT_LOGIN_FAIL_FLOOR_MS, login_fail_jitter_ms = DEFAULT_LOGIN_FAIL_JITTER_MS, connection_closer = null, bootstrap_status } = options;
|
|
164
164
|
const [verify_shape, login_shape, logout_shape, password_shape] = create_account_route_shapes({
|
|
165
165
|
login_account_rate_limited: login_account_rate_limiter !== null
|
|
166
166
|
});
|
|
@@ -181,9 +181,9 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
181
181
|
...login_shape,
|
|
182
182
|
handler: async (c, route) => {
|
|
183
183
|
// Per-IP rate limit check (before any DB/password work)
|
|
184
|
-
const ip =
|
|
185
|
-
if (
|
|
186
|
-
const check =
|
|
184
|
+
const ip = login_ip_rate_limiter ? get_client_ip(c) : null;
|
|
185
|
+
if (login_ip_rate_limiter && ip) {
|
|
186
|
+
const check = login_ip_rate_limiter.check(ip);
|
|
187
187
|
if (!check.allowed) {
|
|
188
188
|
return rate_limit_exceeded_response(c, check.retry_after);
|
|
189
189
|
}
|
|
@@ -214,8 +214,8 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
214
214
|
// enumeration prevention: verify_dummy equalizes Argon2id timing;
|
|
215
215
|
// login_fail_delay equalizes every other path difference.
|
|
216
216
|
await password.verify_dummy(pw);
|
|
217
|
-
if (
|
|
218
|
-
|
|
217
|
+
if (login_ip_rate_limiter && ip)
|
|
218
|
+
login_ip_rate_limiter.record(ip);
|
|
219
219
|
if (login_account_rate_limiter)
|
|
220
220
|
login_account_rate_limiter.record(account_rate_key);
|
|
221
221
|
deps.audit.emit(route, {
|
|
@@ -229,8 +229,8 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
229
229
|
}
|
|
230
230
|
const valid = await password.verify_password(pw, account.password_hash);
|
|
231
231
|
if (!valid) {
|
|
232
|
-
if (
|
|
233
|
-
|
|
232
|
+
if (login_ip_rate_limiter && ip)
|
|
233
|
+
login_ip_rate_limiter.record(ip);
|
|
234
234
|
if (login_account_rate_limiter)
|
|
235
235
|
login_account_rate_limiter.record(account_rate_key);
|
|
236
236
|
deps.audit.emit(route, {
|
|
@@ -243,9 +243,11 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
243
243
|
await delay;
|
|
244
244
|
return c.json({ error: ERROR_INVALID_CREDENTIALS }, 401);
|
|
245
245
|
}
|
|
246
|
-
// Successful login —
|
|
247
|
-
|
|
248
|
-
|
|
246
|
+
// Successful login — clear the account-grain failure history only.
|
|
247
|
+
// The IP aggregate is deliberately left standing: it is the
|
|
248
|
+
// distributed-spray backstop, so a success on one account must not
|
|
249
|
+
// refund the budget shared by every other account reachable from
|
|
250
|
+
// that address. See `RateLimiter.reset`.
|
|
249
251
|
if (login_account_rate_limiter)
|
|
250
252
|
login_account_rate_limiter.reset(account_rate_key);
|
|
251
253
|
await create_session_and_set_cookie({
|
|
@@ -305,9 +307,9 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
305
307
|
...password_shape,
|
|
306
308
|
handler: async (c, route) => {
|
|
307
309
|
// per-IP rate limit check (before argon2 work)
|
|
308
|
-
const ip =
|
|
309
|
-
if (
|
|
310
|
-
const check =
|
|
310
|
+
const ip = login_ip_rate_limiter ? get_client_ip(c) : null;
|
|
311
|
+
if (login_ip_rate_limiter && ip) {
|
|
312
|
+
const check = login_ip_rate_limiter.check(ip);
|
|
311
313
|
if (!check.allowed) {
|
|
312
314
|
return rate_limit_exceeded_response(c, check.retry_after);
|
|
313
315
|
}
|
|
@@ -325,8 +327,8 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
325
327
|
}
|
|
326
328
|
const valid = await password.verify_password(current_password, ctx.account.password_hash);
|
|
327
329
|
if (!valid) {
|
|
328
|
-
if (
|
|
329
|
-
|
|
330
|
+
if (login_ip_rate_limiter && ip)
|
|
331
|
+
login_ip_rate_limiter.record(ip);
|
|
330
332
|
if (login_account_rate_limiter)
|
|
331
333
|
login_account_rate_limiter.record(ctx.account.id);
|
|
332
334
|
deps.audit.emit(route, {
|
|
@@ -350,16 +352,17 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
350
352
|
// incidental; password is account-level state).
|
|
351
353
|
const updated = await query_update_account_password(route, ctx.account.id, new_hash, null, ctx.account.password_hash);
|
|
352
354
|
// Verify-success contract — the caller proved knowledge, so wipe
|
|
353
|
-
// their failure history. The race-loser branch below
|
|
354
|
-
// one on top of the wiped slate so net cost stays 1
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
355
|
+
// their account-grain failure history. The race-loser branch below
|
|
356
|
+
// re-records one on top of the wiped slate so net cost stays 1 on
|
|
357
|
+
// that bucket. Deferring from "after verify" to "after UPDATE
|
|
358
|
+
// settled" is what closes the S1 bypass — a throw between reset
|
|
359
|
+
// and the UPDATE could have wiped an attacker's budget.
|
|
360
|
+
//
|
|
361
|
+
// The IP aggregate is deliberately not cleared: it is the same
|
|
362
|
+
// instance login uses, so refunding it here reopens the spray path
|
|
363
|
+
// through a route that only needs the caller's *own* password. Its
|
|
364
|
+
// record on the race-loser branch below therefore accumulates
|
|
365
|
+
// rather than netting out, which is the intent.
|
|
363
366
|
if (login_account_rate_limiter)
|
|
364
367
|
login_account_rate_limiter.reset(ctx.account.id);
|
|
365
368
|
if (!updated) {
|
|
@@ -370,8 +373,8 @@ export const create_account_route_specs = (deps, options) => {
|
|
|
370
373
|
// audit log can distinguish "user typoed" from "two clients
|
|
371
374
|
// raced." Sessions/tokens were already revoked by the winner;
|
|
372
375
|
// no cookie clear here either.
|
|
373
|
-
if (
|
|
374
|
-
|
|
376
|
+
if (login_ip_rate_limiter && ip)
|
|
377
|
+
login_ip_rate_limiter.record(ip);
|
|
375
378
|
if (login_account_rate_limiter)
|
|
376
379
|
login_account_rate_limiter.record(ctx.account.id);
|
|
377
380
|
deps.audit.emit(route, {
|