@forgezero/access 0.1.1 → 0.1.3
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/README.md +301 -51
- package/dist/conditions.js +7 -4
- package/dist/effects.js +7 -4
- package/dist/elysia.js +7 -4
- package/dist/fetch.js +7 -4
- package/dist/header.d.ts +41 -0
- package/dist/header.js +432 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -4
- package/dist/pipeline.js +7 -4
- package/dist/principal-session.d.ts +47 -0
- package/dist/principal-session.js +548 -0
- package/dist/principal.d.ts +56 -0
- package/dist/principal.js +366 -0
- package/dist/testing.js +7 -4
- package/package.json +14 -2
package/dist/header.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var SCHEMA_VERSION = 1;
|
|
3
|
+
function assertReadable(document, what) {
|
|
4
|
+
const version = document.version ?? 1;
|
|
5
|
+
if (version > SCHEMA_VERSION) {
|
|
6
|
+
throw new AccessError("ACCESS_VERSION_TOO_NEW", `${what} is version ${version}; this build reads ${SCHEMA_VERSION}. Upgrade @forgezero/access.`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
class AccessError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "AccessError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
class Refusal extends Error {
|
|
20
|
+
status;
|
|
21
|
+
code;
|
|
22
|
+
details;
|
|
23
|
+
retryable;
|
|
24
|
+
refusal = true;
|
|
25
|
+
constructor(status, code, message, details = {}, retryable = false) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.status = status;
|
|
28
|
+
this.code = code;
|
|
29
|
+
this.details = details;
|
|
30
|
+
this.retryable = retryable;
|
|
31
|
+
this.name = "Refusal";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function isRefusal(error) {
|
|
35
|
+
return typeof error === "object" && error !== null && error.refusal === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class Settled extends Error {
|
|
39
|
+
value;
|
|
40
|
+
status;
|
|
41
|
+
headers;
|
|
42
|
+
settled = true;
|
|
43
|
+
constructor(value, status = 200, headers = {}) {
|
|
44
|
+
super("Already settled.");
|
|
45
|
+
this.value = value;
|
|
46
|
+
this.status = status;
|
|
47
|
+
this.headers = headers;
|
|
48
|
+
this.name = "Settled";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function isSettled(error) {
|
|
52
|
+
return typeof error === "object" && error !== null && error.settled === true;
|
|
53
|
+
}
|
|
54
|
+
function defineFactors(factors) {
|
|
55
|
+
for (const [id, factor] of Object.entries(factors)) {
|
|
56
|
+
if (factor.status === "shipped" && !(factor.tests?.length ?? 0)) {
|
|
57
|
+
throw new AccessError("FACTOR_UNTESTED", `Factor "${id}" is marked shipped with no tests. A method nobody exercises is a claim.`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return factors;
|
|
61
|
+
}
|
|
62
|
+
function page(label, options = {}) {
|
|
63
|
+
return { kind: "page", label, accessGroup: "default", ...options };
|
|
64
|
+
}
|
|
65
|
+
function action(label, method, options = {}) {
|
|
66
|
+
return { kind: "action", label, method, accessGroup: "default", ...options };
|
|
67
|
+
}
|
|
68
|
+
function defineRoutes(routes) {
|
|
69
|
+
for (const [key, route] of Object.entries(routes)) {
|
|
70
|
+
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
|
|
71
|
+
throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
|
|
72
|
+
}
|
|
73
|
+
const isApi = key.startsWith("api/");
|
|
74
|
+
if (isApi !== (route.kind === "action")) {
|
|
75
|
+
throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
|
|
76
|
+
}
|
|
77
|
+
if (route.kind === "action" && route.page && !(route.page in routes)) {
|
|
78
|
+
throw new AccessError("ROUTE_DANGLING_PAGE", `"${key}" names page "${route.page}", which does not exist.`);
|
|
79
|
+
}
|
|
80
|
+
if (route.kind === "action" && route.response) {
|
|
81
|
+
for (const status of Object.keys(route.response)) {
|
|
82
|
+
if (!route.response[Number(status)]) {
|
|
83
|
+
throw new AccessError("ROUTE_EMPTY_RESPONSE", `"${key}" declares status ${status} with no schema.`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return routes;
|
|
89
|
+
}
|
|
90
|
+
function codePolicy(config) {
|
|
91
|
+
return {
|
|
92
|
+
roles: async () => config.roles,
|
|
93
|
+
enabledFactors: async () => config.enabledFactors
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function externalPolicy(reader) {
|
|
97
|
+
return {
|
|
98
|
+
async roles() {
|
|
99
|
+
const roles = await reader.roles();
|
|
100
|
+
for (const role of roles)
|
|
101
|
+
assertReadable(role, `role "${role.roleKey}"`);
|
|
102
|
+
return roles;
|
|
103
|
+
},
|
|
104
|
+
enabledFactors: () => reader.enabledFactors()
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function stemOf(routeKey, strip) {
|
|
108
|
+
const withoutApi = routeKey.startsWith("api/") ? routeKey.slice(4) : routeKey;
|
|
109
|
+
return strip ? withoutApi.replace(strip, "") : withoutApi;
|
|
110
|
+
}
|
|
111
|
+
function grantsRoute(grants, routeKey, options = {}) {
|
|
112
|
+
const { mode = "inherit", sharesGrantWith, strip } = options;
|
|
113
|
+
const stem = stemOf(routeKey, strip);
|
|
114
|
+
const shared = sharesGrantWith ? stemOf(sharesGrantWith, strip) : undefined;
|
|
115
|
+
return grants.some((grant) => {
|
|
116
|
+
const granted = stemOf(grant, strip);
|
|
117
|
+
if (stem === granted || granted === routeKey)
|
|
118
|
+
return true;
|
|
119
|
+
if (shared !== undefined && (granted === shared || granted === sharesGrantWith))
|
|
120
|
+
return true;
|
|
121
|
+
return mode === "inherit" && stem.startsWith(granted + "/");
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function fulfilledActionFactors() {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
function defineAccessControl(config) {
|
|
128
|
+
assertReadable(config, "access control");
|
|
129
|
+
const routeKeys = new Set(Object.keys(config.routes));
|
|
130
|
+
const actionKeys = Object.entries(config.routes).filter(([, route]) => route.kind === "action").map(([key]) => key);
|
|
131
|
+
const checkKeys = (listName, name, keys) => {
|
|
132
|
+
if (keys.length === 0) {
|
|
133
|
+
throw new AccessError("POLICY_ORPHANED", `${listName} "${name}" binds no routes.`);
|
|
134
|
+
}
|
|
135
|
+
for (const key of keys) {
|
|
136
|
+
if (!routeKeys.has(key)) {
|
|
137
|
+
throw new AccessError("POLICY_UNKNOWN_ROUTE", `${listName} "${name}" names "${key}", which is not a route.`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const sessionOf = new Map;
|
|
142
|
+
for (const [name, policy] of Object.entries(config.sessionPolicies)) {
|
|
143
|
+
checkKeys("Session policy", name, policy.routes);
|
|
144
|
+
for (const key of policy.routes) {
|
|
145
|
+
if (sessionOf.has(key)) {
|
|
146
|
+
throw new AccessError("SESSION_DUPLICATE", `"${key}" is in more than one session policy.`);
|
|
147
|
+
}
|
|
148
|
+
sessionOf.set(key, policy);
|
|
149
|
+
}
|
|
150
|
+
for (const factor of policy.factors) {
|
|
151
|
+
const spec = config.factors[factor];
|
|
152
|
+
if (!spec)
|
|
153
|
+
throw new AccessError("FACTOR_UNKNOWN", `Session policy "${name}" names unknown factor "${factor}".`);
|
|
154
|
+
if (spec.kind === "action") {
|
|
155
|
+
throw new AccessError("FACTOR_WRONG_KIND", `"${factor}" is action-only and cannot establish a session.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const unclassified = [...routeKeys].filter((key) => !sessionOf.has(key));
|
|
160
|
+
if (unclassified.length > 0) {
|
|
161
|
+
throw new AccessError("SESSION_INCOMPLETE", `No session policy covers: ${unclassified.join(", ")}. Every route needs exactly one.`);
|
|
162
|
+
}
|
|
163
|
+
const actionOf = new Map;
|
|
164
|
+
for (const [name, policy] of Object.entries(config.actionPolicies ?? {})) {
|
|
165
|
+
checkKeys("Action policy", name, policy.routes);
|
|
166
|
+
if (policy.required < 1 || policy.required > policy.factors.length) {
|
|
167
|
+
throw new AccessError("ACTION_UNSATISFIABLE", `Action policy "${name}" requires ${policy.required} of ${policy.factors.length} factors.`);
|
|
168
|
+
}
|
|
169
|
+
for (const factor of policy.factors) {
|
|
170
|
+
const spec = config.factors[factor];
|
|
171
|
+
if (!spec)
|
|
172
|
+
throw new AccessError("FACTOR_UNKNOWN", `Action policy "${name}" names unknown factor "${factor}".`);
|
|
173
|
+
if (spec.kind === "session") {
|
|
174
|
+
throw new AccessError("FACTOR_WRONG_KIND", `"${factor}" is session-only and cannot authorise an action.`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
for (const key of policy.routes) {
|
|
178
|
+
if (actionOf.has(key)) {
|
|
179
|
+
throw new AccessError("ACTION_DUPLICATE", `"${key}" is in more than one action policy.`);
|
|
180
|
+
}
|
|
181
|
+
actionOf.set(key, policy);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const rateOf = new Map;
|
|
185
|
+
const rateLists = Object.entries(config.ratePolicies ?? {});
|
|
186
|
+
for (const [name, policy] of rateLists) {
|
|
187
|
+
checkKeys("Rate policy", name, policy.routes);
|
|
188
|
+
for (const key of policy.routes) {
|
|
189
|
+
if (rateOf.has(key)) {
|
|
190
|
+
throw new AccessError("RATE_DUPLICATE", `"${key}" is in more than one rate policy.`);
|
|
191
|
+
}
|
|
192
|
+
rateOf.set(key, policy);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (rateLists.length > 0) {
|
|
196
|
+
const unrated = actionKeys.filter((key) => !rateOf.has(key));
|
|
197
|
+
if (unrated.length > 0) {
|
|
198
|
+
throw new AccessError("RATE_INCOMPLETE", `No rate policy covers: ${unrated.join(", ")}. Declaring any means declaring all.`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const beforeOf = new Map;
|
|
202
|
+
for (const [name, handler] of Object.entries(config.beforeHandlers ?? {})) {
|
|
203
|
+
checkKeys("Before handler", name, handler.routes);
|
|
204
|
+
for (const key of handler.routes) {
|
|
205
|
+
beforeOf.set(key, [...beforeOf.get(key) ?? [], handler]);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const afterOf = new Map;
|
|
209
|
+
for (const [name, handler] of Object.entries(config.afterHandlers ?? {})) {
|
|
210
|
+
checkKeys("After handler", name, handler.routes);
|
|
211
|
+
for (const key of handler.routes) {
|
|
212
|
+
afterOf.set(key, [...afterOf.get(key) ?? [], handler]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const features = config.features ?? [];
|
|
216
|
+
return {
|
|
217
|
+
version: config.version ?? SCHEMA_VERSION,
|
|
218
|
+
routes: config.routes,
|
|
219
|
+
features,
|
|
220
|
+
source: config.source,
|
|
221
|
+
keys: () => Object.keys(config.routes),
|
|
222
|
+
get: (key) => config.routes[key],
|
|
223
|
+
exists(key, context) {
|
|
224
|
+
const route = config.routes[key];
|
|
225
|
+
if (!route)
|
|
226
|
+
return false;
|
|
227
|
+
if (route.feature && !features.includes(route.feature))
|
|
228
|
+
return false;
|
|
229
|
+
if (context?.stage && route.stages && !route.stages.includes(context.stage))
|
|
230
|
+
return false;
|
|
231
|
+
if (context?.realm && route.realms && !route.realms.includes(context.realm))
|
|
232
|
+
return false;
|
|
233
|
+
return true;
|
|
234
|
+
},
|
|
235
|
+
sessionPolicyFor: (key) => sessionOf.get(key),
|
|
236
|
+
actionPolicyFor: (key) => actionOf.get(key),
|
|
237
|
+
ratePolicyFor: (key) => rateOf.get(key),
|
|
238
|
+
beforeFor: (key) => beforeOf.get(key) ?? [],
|
|
239
|
+
afterFor: (key) => afterOf.get(key) ?? []
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function authorise(args) {
|
|
243
|
+
const { access, routeKey, stage, realm, session, roles } = args;
|
|
244
|
+
if (!access.exists(routeKey, { stage, realm })) {
|
|
245
|
+
return { allow: false, status: 404, code: "NOT_FOUND" };
|
|
246
|
+
}
|
|
247
|
+
const policy = access.sessionPolicyFor(routeKey);
|
|
248
|
+
if ((policy?.factors.length ?? 0) === 0)
|
|
249
|
+
return { allow: true };
|
|
250
|
+
if (!session)
|
|
251
|
+
return { allow: false, status: 401, code: "AUTH_REQUIRED" };
|
|
252
|
+
const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
|
|
253
|
+
if (missing.length > 0) {
|
|
254
|
+
return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
|
|
255
|
+
}
|
|
256
|
+
const grants = roles.filter((role) => session.roleKeys.includes(role.roleKey)).flatMap((role) => role.grants);
|
|
257
|
+
if (!grantsRoute(grants, routeKey)) {
|
|
258
|
+
return { allow: false, status: 403, code: "ACCESS_DENIED" };
|
|
259
|
+
}
|
|
260
|
+
return { allow: true };
|
|
261
|
+
}
|
|
262
|
+
function resolveActionFactors(policy, enabled) {
|
|
263
|
+
const available = policy.factors.filter((factor) => enabled.includes(factor));
|
|
264
|
+
return { available, required: policy.required, satisfiable: available.length >= policy.required };
|
|
265
|
+
}
|
|
266
|
+
function impactOfDisabling(access, enabledAfter) {
|
|
267
|
+
const broken = [];
|
|
268
|
+
for (const key of access.keys()) {
|
|
269
|
+
const policy = access.actionPolicyFor(key);
|
|
270
|
+
if (!policy)
|
|
271
|
+
continue;
|
|
272
|
+
const { available, required, satisfiable } = resolveActionFactors(policy, enabledAfter);
|
|
273
|
+
if (!satisfiable)
|
|
274
|
+
broken.push({ route: key, available: available.length, required });
|
|
275
|
+
}
|
|
276
|
+
return broken;
|
|
277
|
+
}
|
|
278
|
+
var VERSION = "0.1.0";
|
|
279
|
+
|
|
280
|
+
// src/principal.ts
|
|
281
|
+
var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
|
|
282
|
+
var GROUP = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
283
|
+
var METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
284
|
+
function uniqueAtoms(values, max, pattern = ATOM) {
|
|
285
|
+
return values.length <= max && new Set(values).size === values.length && values.every((value) => pattern.test(value));
|
|
286
|
+
}
|
|
287
|
+
function defineRbacPrincipal(principal) {
|
|
288
|
+
if (!ATOM.test(principal.kind) || !ATOM.test(principal.principalKey) || principal.assignments.length > 32) {
|
|
289
|
+
throw new AccessError("PRINCIPAL_INVALID", "The principal kind, key, or assignment count is invalid.");
|
|
290
|
+
}
|
|
291
|
+
const assignmentKeys = new Set;
|
|
292
|
+
for (const assignment of principal.assignments) {
|
|
293
|
+
if (!ATOM.test(assignment.assignmentKey) || assignmentKeys.has(assignment.assignmentKey) || !uniqueAtoms(assignment.roleKeys, 128) || !uniqueAtoms(assignment.accessGroups, 32, GROUP) || !uniqueAtoms(assignment.sessionFactors, 32) || assignment.methods.length === 0 || assignment.methods.length > METHODS.size || new Set(assignment.methods).size !== assignment.methods.length || assignment.methods.some((method) => !METHODS.has(method))) {
|
|
294
|
+
throw new AccessError("PRINCIPAL_ASSIGNMENT_INVALID", "A principal assignment is malformed, duplicate, or unbounded.");
|
|
295
|
+
}
|
|
296
|
+
if (assignment.idleTtlMs === undefined !== (assignment.lastActiveAtMs === undefined) || assignment.idleTtlMs !== undefined && (!Number.isSafeInteger(assignment.idleTtlMs) || assignment.idleTtlMs <= 0) || assignment.lastActiveAtMs !== undefined && (!Number.isSafeInteger(assignment.lastActiveAtMs) || assignment.lastActiveAtMs < 0) || assignment.absoluteExpiresAtMs !== undefined && (!Number.isSafeInteger(assignment.absoluteExpiresAtMs) || assignment.absoluteExpiresAtMs < 0)) {
|
|
297
|
+
throw new AccessError("PRINCIPAL_ASSIGNMENT_INVALID", "Assignment expiry coordinates are incomplete or invalid.");
|
|
298
|
+
}
|
|
299
|
+
if (assignment.delegatedBy && (!ATOM.test(assignment.delegatedBy.userKey) || !ATOM.test(assignment.delegatedBy.sessionKey) || !Number.isSafeInteger(assignment.delegatedBy.atMs))) {
|
|
300
|
+
throw new AccessError("PRINCIPAL_ASSIGNMENT_INVALID", "Assignment delegation binding is invalid.");
|
|
301
|
+
}
|
|
302
|
+
assignmentKeys.add(assignment.assignmentKey);
|
|
303
|
+
}
|
|
304
|
+
return principal;
|
|
305
|
+
}
|
|
306
|
+
function assignmentIsActive(assignment, now = Date.now()) {
|
|
307
|
+
if (assignment.absoluteExpiresAtMs !== undefined && now >= assignment.absoluteExpiresAtMs)
|
|
308
|
+
return false;
|
|
309
|
+
if (assignment.idleTtlMs !== undefined && assignment.lastActiveAtMs !== undefined && now >= assignment.lastActiveAtMs + assignment.idleTtlMs)
|
|
310
|
+
return false;
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
function touchPrincipalAssignment(assignment, now = Date.now()) {
|
|
314
|
+
if (!assignmentIsActive(assignment, now) || assignment.idleTtlMs === undefined)
|
|
315
|
+
return assignment;
|
|
316
|
+
return { ...assignment, lastActiveAtMs: now };
|
|
317
|
+
}
|
|
318
|
+
function decidePrincipalAccess(args) {
|
|
319
|
+
const route = args.access.get(args.routeKey);
|
|
320
|
+
if (!route || !args.access.exists(args.routeKey, { stage: args.stage, realm: args.realm }) || route.kind === "action" && args.method !== undefined && route.method !== args.method) {
|
|
321
|
+
return { allow: false, status: 404, code: "NOT_FOUND" };
|
|
322
|
+
}
|
|
323
|
+
if (route.accessGroup === "public")
|
|
324
|
+
return { allow: true, public: true };
|
|
325
|
+
if (!args.principal)
|
|
326
|
+
return { allow: false, status: 401, code: "AUTH_REQUIRED" };
|
|
327
|
+
const principal = defineRbacPrincipal(args.principal);
|
|
328
|
+
const policy = args.access.sessionPolicyFor(args.routeKey);
|
|
329
|
+
const requiredFactors = policy?.factors ?? [];
|
|
330
|
+
let missing;
|
|
331
|
+
for (const assignment of principal.assignments) {
|
|
332
|
+
if (!assignmentIsActive(assignment, args.now) || !assignment.accessGroups.includes(route.accessGroup) || route.kind === "action" && !assignment.methods.includes(route.method))
|
|
333
|
+
continue;
|
|
334
|
+
const absent = requiredFactors.filter((factor) => !assignment.sessionFactors.includes(factor));
|
|
335
|
+
if (absent.length > 0) {
|
|
336
|
+
missing = missing === undefined ? absent : [...new Set([...missing, ...absent])];
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const grants = args.roles.filter((role) => assignment.roleKeys.includes(role.roleKey)).flatMap((role) => role.grants);
|
|
340
|
+
if (!grantsRoute(grants, args.routeKey, {
|
|
341
|
+
mode: route.grantMode,
|
|
342
|
+
sharesGrantWith: route.sharesGrantWith
|
|
343
|
+
}))
|
|
344
|
+
continue;
|
|
345
|
+
const action2 = args.access.actionPolicyFor(args.routeKey);
|
|
346
|
+
if (action2 && !args.actionProofSatisfied) {
|
|
347
|
+
return {
|
|
348
|
+
allow: false,
|
|
349
|
+
status: 428,
|
|
350
|
+
code: "ACTION_REQUIRED",
|
|
351
|
+
missing: action2.factors,
|
|
352
|
+
required: action2.required
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
return { allow: true, public: false, assignmentKey: assignment.assignmentKey };
|
|
356
|
+
}
|
|
357
|
+
if (missing?.length)
|
|
358
|
+
return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
|
|
359
|
+
return { allow: false, status: 403, code: "ACCESS_DENIED" };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/header.ts
|
|
363
|
+
var HEADER = /^[a-z][a-z0-9-]{0,62}$/;
|
|
364
|
+
var KEY = /^[a-z][a-z0-9_.-]{0,63}$/;
|
|
365
|
+
var ATOM2 = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
|
|
366
|
+
var CLAIM_KEY = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
|
|
367
|
+
function defineHeaderIdentitySources(sources) {
|
|
368
|
+
if (sources.length > 16)
|
|
369
|
+
throw new AccessError("HEADER_SOURCE_LIMIT", "At most 16 header identity sources may be active.");
|
|
370
|
+
const keys = new Set;
|
|
371
|
+
const headers = new Set;
|
|
372
|
+
for (const source of sources) {
|
|
373
|
+
const header = source.header.trim().toLowerCase();
|
|
374
|
+
if (!KEY.test(source.key) || !HEADER.test(header)) {
|
|
375
|
+
throw new AccessError("HEADER_SOURCE_INVALID", "Header identity source keys and names must be bounded atoms.");
|
|
376
|
+
}
|
|
377
|
+
if (["cookie", "authorization", "x-fz-key", "x-fz-node", "x-fz-signature"].includes(header)) {
|
|
378
|
+
throw new AccessError("HEADER_SOURCE_RESERVED", `Header identity source "${source.key}" uses a reserved credential header.`);
|
|
379
|
+
}
|
|
380
|
+
if (keys.has(source.key) || headers.has(header)) {
|
|
381
|
+
throw new AccessError("HEADER_SOURCE_DUPLICATE", "Header identity source keys and header names must be unique.");
|
|
382
|
+
}
|
|
383
|
+
keys.add(source.key);
|
|
384
|
+
headers.add(header);
|
|
385
|
+
}
|
|
386
|
+
return sources;
|
|
387
|
+
}
|
|
388
|
+
function headerIdentityResolver(sourcesInput, resolveAssignments) {
|
|
389
|
+
const sources = defineHeaderIdentitySources(sourcesInput);
|
|
390
|
+
return async (request) => {
|
|
391
|
+
const presented = sources.flatMap((source2) => {
|
|
392
|
+
const value2 = request.headers.get(source2.header)?.trim();
|
|
393
|
+
return value2 ? [{ source: source2, value: value2 }] : [];
|
|
394
|
+
});
|
|
395
|
+
if (presented.length === 0)
|
|
396
|
+
return;
|
|
397
|
+
if (presented.length !== 1) {
|
|
398
|
+
throw new AccessError("HEADER_IDENTITY_AMBIGUOUS", "Exactly one header identity source may authenticate a request.");
|
|
399
|
+
}
|
|
400
|
+
const [{ source, value }] = presented;
|
|
401
|
+
if (value.length > 8192 || /[\0\r\n]/.test(value)) {
|
|
402
|
+
throw new AccessError("HEADER_IDENTITY_INVALID", "The header identity credential is malformed.");
|
|
403
|
+
}
|
|
404
|
+
const identity = await source.verify({ value, request, source: source.key });
|
|
405
|
+
if (!identity)
|
|
406
|
+
return;
|
|
407
|
+
const claims = identity.claims ?? {};
|
|
408
|
+
if (!ATOM2.test(identity.subject) || Object.keys(claims).length > 32 || Object.entries(claims).some(([key, claim]) => !CLAIM_KEY.test(key) || typeof claim !== "string" || claim.length > 512 || /[\0\r\n]/.test(claim))) {
|
|
409
|
+
throw new AccessError("HEADER_IDENTITY_INVALID", "The verified header identity has an invalid subject or bounded claim set.");
|
|
410
|
+
}
|
|
411
|
+
const principalKey = `header:${source.key}:${identity.subject}`;
|
|
412
|
+
const assignments = [...await resolveAssignments({
|
|
413
|
+
principalKey,
|
|
414
|
+
source: source.key,
|
|
415
|
+
subject: identity.subject,
|
|
416
|
+
claims,
|
|
417
|
+
request
|
|
418
|
+
})];
|
|
419
|
+
return defineRbacPrincipal({
|
|
420
|
+
kind: "header-identity",
|
|
421
|
+
principalKey,
|
|
422
|
+
source: source.key,
|
|
423
|
+
subject: identity.subject,
|
|
424
|
+
assignments,
|
|
425
|
+
claims
|
|
426
|
+
});
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
export {
|
|
430
|
+
headerIdentityResolver,
|
|
431
|
+
defineHeaderIdentitySources
|
|
432
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -129,6 +129,8 @@ export interface RouteContract {
|
|
|
129
129
|
}
|
|
130
130
|
export interface RouteBase extends RouteContract {
|
|
131
131
|
label: string;
|
|
132
|
+
/** Open host vocabulary used to scope delegated role assignments. */
|
|
133
|
+
accessGroup: string;
|
|
132
134
|
/** Disabled feature → 404, never 403. A 403 confirms the route exists. */
|
|
133
135
|
feature?: string;
|
|
134
136
|
/** Outside its stage → 404, for the same reason. */
|
package/dist/index.js
CHANGED
|
@@ -60,13 +60,16 @@ function defineFactors(factors) {
|
|
|
60
60
|
return factors;
|
|
61
61
|
}
|
|
62
62
|
function page(label, options = {}) {
|
|
63
|
-
return { kind: "page", label, ...options };
|
|
63
|
+
return { kind: "page", label, accessGroup: "default", ...options };
|
|
64
64
|
}
|
|
65
65
|
function action(label, method, options = {}) {
|
|
66
|
-
return { kind: "action", label, method, ...options };
|
|
66
|
+
return { kind: "action", label, method, accessGroup: "default", ...options };
|
|
67
67
|
}
|
|
68
68
|
function defineRoutes(routes) {
|
|
69
69
|
for (const [key, route] of Object.entries(routes)) {
|
|
70
|
+
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
|
|
71
|
+
throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
|
|
72
|
+
}
|
|
70
73
|
const isApi = key.startsWith("api/");
|
|
71
74
|
if (isApi !== (route.kind === "action")) {
|
|
72
75
|
throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
|
|
@@ -242,11 +245,11 @@ function authorise(args) {
|
|
|
242
245
|
return { allow: false, status: 404, code: "NOT_FOUND" };
|
|
243
246
|
}
|
|
244
247
|
const policy = access.sessionPolicyFor(routeKey);
|
|
245
|
-
if (
|
|
248
|
+
if ((policy?.factors.length ?? 0) === 0)
|
|
246
249
|
return { allow: true };
|
|
247
250
|
if (!session)
|
|
248
251
|
return { allow: false, status: 401, code: "AUTH_REQUIRED" };
|
|
249
|
-
const missing = policy
|
|
252
|
+
const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
|
|
250
253
|
if (missing.length > 0) {
|
|
251
254
|
return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
|
|
252
255
|
}
|
package/dist/pipeline.js
CHANGED
|
@@ -60,13 +60,16 @@ function defineFactors(factors) {
|
|
|
60
60
|
return factors;
|
|
61
61
|
}
|
|
62
62
|
function page(label, options = {}) {
|
|
63
|
-
return { kind: "page", label, ...options };
|
|
63
|
+
return { kind: "page", label, accessGroup: "default", ...options };
|
|
64
64
|
}
|
|
65
65
|
function action(label, method, options = {}) {
|
|
66
|
-
return { kind: "action", label, method, ...options };
|
|
66
|
+
return { kind: "action", label, method, accessGroup: "default", ...options };
|
|
67
67
|
}
|
|
68
68
|
function defineRoutes(routes) {
|
|
69
69
|
for (const [key, route] of Object.entries(routes)) {
|
|
70
|
+
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/.test(route.accessGroup)) {
|
|
71
|
+
throw new AccessError("ROUTE_ACCESS_INVALID", `"${key}": accessGroup must be a bounded name.`);
|
|
72
|
+
}
|
|
70
73
|
const isApi = key.startsWith("api/");
|
|
71
74
|
if (isApi !== (route.kind === "action")) {
|
|
72
75
|
throw new AccessError("ROUTE_KIND_MISMATCH", `"${key}": routes under api/ must be actions, and actions must be under api/.`);
|
|
@@ -242,11 +245,11 @@ function authorise(args) {
|
|
|
242
245
|
return { allow: false, status: 404, code: "NOT_FOUND" };
|
|
243
246
|
}
|
|
244
247
|
const policy = access.sessionPolicyFor(routeKey);
|
|
245
|
-
if (
|
|
248
|
+
if ((policy?.factors.length ?? 0) === 0)
|
|
246
249
|
return { allow: true };
|
|
247
250
|
if (!session)
|
|
248
251
|
return { allow: false, status: 401, code: "AUTH_REQUIRED" };
|
|
249
|
-
const missing = policy
|
|
252
|
+
const missing = (policy?.factors ?? []).filter((factor) => !session.factors.includes(factor));
|
|
250
253
|
if (missing.length > 0) {
|
|
251
254
|
return { allow: false, status: 428, code: "SECURITY_REQUIRED", missing };
|
|
252
255
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type PrincipalAssignment, type RbacPrincipal } from './principal';
|
|
2
|
+
export declare const DEFAULT_PRINCIPAL_IDLE_TTL_MS: number;
|
|
3
|
+
export declare const DEFAULT_PRINCIPAL_ABSOLUTE_TTL_MS: number;
|
|
4
|
+
export declare const MAX_PRINCIPAL_IDLE_TTL_MS: number;
|
|
5
|
+
export declare const MAX_PRINCIPAL_ABSOLUTE_TTL_MS: number;
|
|
6
|
+
export interface PrincipalSessionRecord {
|
|
7
|
+
sessionKey: string;
|
|
8
|
+
principalKey: string;
|
|
9
|
+
kind: string;
|
|
10
|
+
status: 'active' | 'revoked';
|
|
11
|
+
/** Only hashes are persisted. Browser-generated identity is a binding, not authentication. */
|
|
12
|
+
credentialHash: string;
|
|
13
|
+
clientIdentityHash: string;
|
|
14
|
+
assignments: readonly PrincipalAssignment[];
|
|
15
|
+
createdAtMs: number;
|
|
16
|
+
lastActiveAtMs: number;
|
|
17
|
+
idleTtlMs: number;
|
|
18
|
+
absoluteExpiresAtMs: number;
|
|
19
|
+
}
|
|
20
|
+
export interface IssuedPrincipalSession {
|
|
21
|
+
/** Returned once. Store in an HttpOnly Secure cookie, never localStorage. */
|
|
22
|
+
credential: string;
|
|
23
|
+
record: PrincipalSessionRecord;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a purpose-neutral delegated session. The browser may persist
|
|
27
|
+
* `clientIdentity`, but that value is only bound to the server credential and
|
|
28
|
+
* proves nothing by itself.
|
|
29
|
+
*/
|
|
30
|
+
export declare function issuePrincipalSession(args: {
|
|
31
|
+
kind: string;
|
|
32
|
+
principalKey: string;
|
|
33
|
+
clientIdentity: string;
|
|
34
|
+
assignments?: readonly PrincipalAssignment[];
|
|
35
|
+
idleTtlMs?: number;
|
|
36
|
+
absoluteTtlMs?: number;
|
|
37
|
+
now?: number;
|
|
38
|
+
}): Promise<IssuedPrincipalSession>;
|
|
39
|
+
export declare function principalSessionIsActive(record: PrincipalSessionRecord, now?: number): boolean;
|
|
40
|
+
/** Resolve only after looking up the record by its non-secret session key. */
|
|
41
|
+
export declare function authenticatePrincipalSession(record: PrincipalSessionRecord, args: {
|
|
42
|
+
credential: string;
|
|
43
|
+
clientIdentity: string;
|
|
44
|
+
now?: number;
|
|
45
|
+
}): Promise<RbacPrincipal | undefined>;
|
|
46
|
+
/** Host persists this with compare-and-swap after successful authentication. */
|
|
47
|
+
export declare function touchPrincipalSession(record: PrincipalSessionRecord, now?: number): PrincipalSessionRecord;
|