@ontrails/permits 1.0.0-beta.12
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/.turbo/turbo-build.log +1 -0
- package/.turbo/turbo-lint.log +3 -0
- package/.turbo/turbo-typecheck.log +1 -0
- package/CHANGELOG.md +19 -0
- package/dist/adapter.d.ts +26 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/adapters/jwt.d.ts +25 -0
- package/dist/adapters/jwt.d.ts.map +1 -0
- package/dist/adapters/jwt.js +148 -0
- package/dist/adapters/jwt.js.map +1 -0
- package/dist/auth-layer.d.ts +18 -0
- package/dist/auth-layer.d.ts.map +1 -0
- package/dist/auth-layer.js +56 -0
- package/dist/auth-layer.js.map +1 -0
- package/dist/auth-service.d.ts +10 -0
- package/dist/auth-service.d.ts.map +1 -0
- package/dist/auth-service.js +21 -0
- package/dist/auth-service.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/extraction.d.ts +20 -0
- package/dist/extraction.d.ts.map +1 -0
- package/dist/extraction.js +2 -0
- package/dist/extraction.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/permit.d.ts +26 -0
- package/dist/permit.d.ts.map +1 -0
- package/dist/permit.js +17 -0
- package/dist/permit.js.map +1 -0
- package/dist/rules.d.ts +47 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +127 -0
- package/dist/rules.js.map +1 -0
- package/dist/testing.d.ts +20 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +22 -0
- package/dist/testing.js.map +1 -0
- package/dist/trails/auth-verify.d.ts +22 -0
- package/dist/trails/auth-verify.d.ts.map +1 -0
- package/dist/trails/auth-verify.js +85 -0
- package/dist/trails/auth-verify.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/adapter.test.ts +338 -0
- package/src/__tests__/auth-layer.test.ts +130 -0
- package/src/__tests__/auth-service.test.ts +62 -0
- package/src/__tests__/auth-verify.test.ts +277 -0
- package/src/__tests__/permit.test.ts +122 -0
- package/src/__tests__/rules.test.ts +239 -0
- package/src/__tests__/testing.test.ts +57 -0
- package/src/adapter.ts +35 -0
- package/src/adapters/jwt.ts +230 -0
- package/src/auth-layer.ts +80 -0
- package/src/auth-service.ts +25 -0
- package/src/errors.ts +18 -0
- package/src/extraction.ts +19 -0
- package/src/index.ts +14 -0
- package/src/permit.ts +26 -0
- package/src/rules.ts +183 -0
- package/src/testing.ts +34 -0
- package/src/trails/auth-verify.ts +97 -0
- package/tsconfig.json +9 -0
- package/tsconfig.tsbuildinfo +1 -0
package/dist/rules.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Check whether a trail has any permit declaration (scopes object or 'public'). */
|
|
2
|
+
const hasPermit = (t) => t.permit !== undefined;
|
|
3
|
+
/** Extract scopes from a trail's permit declaration, or empty array. */
|
|
4
|
+
const getScopes = (t) => {
|
|
5
|
+
if (t.permit !== undefined && t.permit !== 'public') {
|
|
6
|
+
return t.permit.scopes;
|
|
7
|
+
}
|
|
8
|
+
return [];
|
|
9
|
+
};
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Rule: destroyWithoutPermit
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* Destructive trails without a real permit declaration are a governance failure.
|
|
15
|
+
*
|
|
16
|
+
* Reports an error for every trail with `intent: 'destroy'` that has no
|
|
17
|
+
* `permit` field or explicitly opts out with `permit: 'public'`.
|
|
18
|
+
*/
|
|
19
|
+
export const destroyWithoutPermit = (trails) => trails
|
|
20
|
+
.filter((t) => t.intent === 'destroy' && (!hasPermit(t) || t.permit === 'public'))
|
|
21
|
+
.map((t) => ({
|
|
22
|
+
message: `Trail "${t.id}" has intent 'destroy' but no permit declaration`,
|
|
23
|
+
rule: 'destroyWithoutPermit',
|
|
24
|
+
severity: 'error',
|
|
25
|
+
trailId: t.id,
|
|
26
|
+
}));
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Rule: writeWithoutPermit
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/**
|
|
31
|
+
* Write trails without a permit declaration get a warning.
|
|
32
|
+
*
|
|
33
|
+
* Trails with `intent: 'write'` (or no intent, which defaults to write) that
|
|
34
|
+
* lack a permit are flagged unless `permit: 'public'` is explicitly set.
|
|
35
|
+
*/
|
|
36
|
+
export const writeWithoutPermit = (trails) => trails
|
|
37
|
+
.filter((t) => (t.intent === 'write' || t.intent === undefined) && !hasPermit(t))
|
|
38
|
+
.map((t) => ({
|
|
39
|
+
message: `Trail "${t.id}" has write intent but no permit declaration`,
|
|
40
|
+
rule: 'writeWithoutPermit',
|
|
41
|
+
severity: 'warning',
|
|
42
|
+
trailId: t.id,
|
|
43
|
+
}));
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Rule: scopeNamingConsistency
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/** Returns true when a scope follows the `entity:action` convention. */
|
|
48
|
+
const isValidScopeFormat = (scope) => {
|
|
49
|
+
const parts = scope.split(':');
|
|
50
|
+
return (parts.length === 2 &&
|
|
51
|
+
(parts[0]?.length ?? 0) > 0 &&
|
|
52
|
+
(parts[1]?.length ?? 0) > 0);
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Warns for scopes that don't follow the `entity:action` convention.
|
|
56
|
+
*
|
|
57
|
+
* A valid scope contains exactly one colon separating a non-empty entity
|
|
58
|
+
* and a non-empty action.
|
|
59
|
+
*/
|
|
60
|
+
export const scopeNamingConsistency = (trails) => trails.flatMap((t) => getScopes(t)
|
|
61
|
+
.filter((scope) => !isValidScopeFormat(scope))
|
|
62
|
+
.map((scope) => ({
|
|
63
|
+
message: `Scope "${scope}" on trail "${t.id}" does not follow entity:action convention`,
|
|
64
|
+
rule: 'scopeNamingConsistency',
|
|
65
|
+
severity: 'warning',
|
|
66
|
+
trailId: t.id,
|
|
67
|
+
})));
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Rule: orphanScopeDetection
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
/** Build a map of scope -> set of trail IDs that declare it. */
|
|
72
|
+
const buildScopeMap = (trails) => {
|
|
73
|
+
const map = new Map();
|
|
74
|
+
for (const t of trails) {
|
|
75
|
+
for (const scope of getScopes(t)) {
|
|
76
|
+
const existing = map.get(scope);
|
|
77
|
+
if (existing) {
|
|
78
|
+
existing.add(t.id);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
map.set(scope, new Set([t.id]));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return map;
|
|
86
|
+
};
|
|
87
|
+
/** Filter trails that have a scoped (non-public) permit declaration. */
|
|
88
|
+
const trailsWithScopedPermit = (trails) => trails.filter((t) => t.permit !== undefined && t.permit !== 'public');
|
|
89
|
+
/** Convert orphan scope map entries into diagnostics. */
|
|
90
|
+
const orphanDiagnostics = (scopeMap) => [...scopeMap.entries()]
|
|
91
|
+
.filter(([, ids]) => ids.size === 1)
|
|
92
|
+
.map(([scope, ids]) => ({
|
|
93
|
+
message: `Scope "${scope}" appears only on trail "${[...ids][0]}" — possible typo`,
|
|
94
|
+
rule: 'orphanScopeDetection',
|
|
95
|
+
severity: 'warning',
|
|
96
|
+
trailId: [...ids][0] ?? '',
|
|
97
|
+
}));
|
|
98
|
+
/**
|
|
99
|
+
* Warns for scopes that appear in only one trail's permit.
|
|
100
|
+
*
|
|
101
|
+
* Catches typos like `user:wirte` by surfacing scopes not shared with any
|
|
102
|
+
* other trail. Only runs when at least 2 trails have permit declarations.
|
|
103
|
+
*/
|
|
104
|
+
export const orphanScopeDetection = (trails) => {
|
|
105
|
+
const scoped = trailsWithScopedPermit(trails);
|
|
106
|
+
if (scoped.length < 2) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
return orphanDiagnostics(buildScopeMap(scoped));
|
|
110
|
+
};
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Top-level validator
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
const allRules = [
|
|
115
|
+
destroyWithoutPermit,
|
|
116
|
+
writeWithoutPermit,
|
|
117
|
+
scopeNamingConsistency,
|
|
118
|
+
orphanScopeDetection,
|
|
119
|
+
];
|
|
120
|
+
/**
|
|
121
|
+
* Run all permit governance rules against a set of trails.
|
|
122
|
+
*
|
|
123
|
+
* Returns a flat array of diagnostics from every rule. An empty array
|
|
124
|
+
* means the topo passes all permit governance checks.
|
|
125
|
+
*/
|
|
126
|
+
export const validatePermits = (trails) => allRules.flatMap((rule) => rule(trails));
|
|
127
|
+
//# sourceMappingURL=rules.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rules.js","sourceRoot":"","sources":["../src/rules.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PermitRequirement } from '@ontrails/core';
|
|
2
|
+
import type { Permit } from './permit.js';
|
|
3
|
+
/**
|
|
4
|
+
* Mint a synthetic test permit with exactly the declared scopes.
|
|
5
|
+
* No admin permit, no wildcard — tests get only what the trail declares.
|
|
6
|
+
*/
|
|
7
|
+
export declare const mintTestPermit: (options?: {
|
|
8
|
+
readonly id?: string;
|
|
9
|
+
readonly scopes?: readonly string[];
|
|
10
|
+
readonly roles?: readonly string[];
|
|
11
|
+
readonly tenantId?: string;
|
|
12
|
+
}) => Permit;
|
|
13
|
+
/**
|
|
14
|
+
* Create a test permit matching a trail's permit requirement.
|
|
15
|
+
* Extracts scopes from the requirement and mints a permit with exactly those scopes.
|
|
16
|
+
*/
|
|
17
|
+
export declare const mintPermitForTrail: (trail: {
|
|
18
|
+
readonly permit?: PermitRequirement | undefined;
|
|
19
|
+
}) => Permit | undefined;
|
|
20
|
+
//# sourceMappingURL=testing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAExD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAE1C;;;GAGG;AACH,eAAO,MAAM,cAAc,GAAI,UAAU;IACvC,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B,KAAG,MAOF,CAAC;AAEH;;;GAGG;AACH,eAAO,MAAM,kBAAkB,GAAI,OAAO;IACxC,QAAQ,CAAC,MAAM,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAC;CACjD,KAAG,MAAM,GAAG,SAKZ,CAAC"}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mint a synthetic test permit with exactly the declared scopes.
|
|
3
|
+
* No admin permit, no wildcard — tests get only what the trail declares.
|
|
4
|
+
*/
|
|
5
|
+
export const mintTestPermit = (options) => ({
|
|
6
|
+
id: options?.id ??
|
|
7
|
+
`test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
8
|
+
scopes: [...(options?.scopes ?? [])],
|
|
9
|
+
...(options?.roles === undefined ? {} : { roles: [...options.roles] }),
|
|
10
|
+
...(options?.tenantId === undefined ? {} : { tenantId: options.tenantId }),
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Create a test permit matching a trail's permit requirement.
|
|
14
|
+
* Extracts scopes from the requirement and mints a permit with exactly those scopes.
|
|
15
|
+
*/
|
|
16
|
+
export const mintPermitForTrail = (trail) => {
|
|
17
|
+
if (!trail.permit || trail.permit === 'public') {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return mintTestPermit({ scopes: trail.permit.scopes });
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=testing.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"AAIA;;;GAGG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,OAK9B,EAAU,EAAE,CAAC,CAAC;IACb,EAAE,EACA,OAAO,EAAE,EAAE;QACX,QAAQ,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE;IAChE,MAAM,EAAE,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,IAAI,EAAE,CAAC,CAAC;IACpC,GAAG,CAAC,OAAO,EAAE,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;IACtE,GAAG,CAAC,OAAO,EAAE,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC;CAC3E,CAAC,CAAC;AAEH;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,KAElC,EAAsB,EAAE;IACvB,IAAI,CAAC,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;QAC/C,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,cAAc,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC;AACzD,CAAC,CAAC"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure trail that verifies a bearer token and returns the resolved permit.
|
|
3
|
+
*
|
|
4
|
+
* Reads the auth adapter from `authService` — the adapter is configured at
|
|
5
|
+
* bootstrap (e.g. JWT with HMAC secret). The mock adapter always succeeds with
|
|
6
|
+
* a null permit, so `testAll(app)` works without configuration.
|
|
7
|
+
*/
|
|
8
|
+
export declare const authVerify: import("@ontrails/core").Trail<{
|
|
9
|
+
token: string;
|
|
10
|
+
}, {
|
|
11
|
+
valid: boolean;
|
|
12
|
+
error?: string | undefined;
|
|
13
|
+
errorCode?: "expired_token" | "insufficient_scope" | "invalid_token" | "missing_credentials" | undefined;
|
|
14
|
+
permit?: {
|
|
15
|
+
id: string;
|
|
16
|
+
scopes: string[];
|
|
17
|
+
metadata?: Record<string, unknown> | undefined;
|
|
18
|
+
roles?: string[] | undefined;
|
|
19
|
+
tenantId?: string | undefined;
|
|
20
|
+
} | undefined;
|
|
21
|
+
}>;
|
|
22
|
+
//# sourceMappingURL=auth-verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-verify.d.ts","sourceRoot":"","sources":["../../src/trails/auth-verify.ts"],"names":[],"mappings":"AAwCA;;;;;;GAMG;AACH,eAAO,MAAM,UAAU;;;;;;;;;;;;;EAiDrB,CAAC"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Result, SURFACE_KEY, trail } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { authService } from '../auth-service.js';
|
|
4
|
+
const permitSchema = z.object({
|
|
5
|
+
id: z.string(),
|
|
6
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
7
|
+
roles: z.array(z.string()).optional(),
|
|
8
|
+
scopes: z.array(z.string()),
|
|
9
|
+
tenantId: z.string().optional(),
|
|
10
|
+
});
|
|
11
|
+
const authErrorCodeSchema = z.enum([
|
|
12
|
+
'expired_token',
|
|
13
|
+
'insufficient_scope',
|
|
14
|
+
'invalid_token',
|
|
15
|
+
'missing_credentials',
|
|
16
|
+
]);
|
|
17
|
+
const toOutputPermit = (permit) => ({
|
|
18
|
+
...(permit.metadata === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: { metadata: { ...permit.metadata } }),
|
|
21
|
+
...(permit.roles === undefined ? {} : { roles: [...permit.roles] }),
|
|
22
|
+
...(permit.tenantId === undefined ? {} : { tenantId: permit.tenantId }),
|
|
23
|
+
id: permit.id,
|
|
24
|
+
scopes: [...permit.scopes],
|
|
25
|
+
});
|
|
26
|
+
const isSurface = (value) => value === 'http' || value === 'mcp' || value === 'cli';
|
|
27
|
+
const getSurface = (ctx) => {
|
|
28
|
+
const surface = ctx.extensions?.[SURFACE_KEY];
|
|
29
|
+
return isSurface(surface) ? surface : 'http';
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Infrastructure trail that verifies a bearer token and returns the resolved permit.
|
|
33
|
+
*
|
|
34
|
+
* Reads the auth adapter from `authService` — the adapter is configured at
|
|
35
|
+
* bootstrap (e.g. JWT with HMAC secret). The mock adapter always succeeds with
|
|
36
|
+
* a null permit, so `testAll(app)` works without configuration.
|
|
37
|
+
*/
|
|
38
|
+
export const authVerify = trail('auth.verify', {
|
|
39
|
+
examples: [
|
|
40
|
+
{
|
|
41
|
+
input: { token: 'test-token' },
|
|
42
|
+
name: 'Verify a token',
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
input: z.object({
|
|
46
|
+
token: z.string().min(1).describe('Bearer token to verify'),
|
|
47
|
+
}),
|
|
48
|
+
intent: 'read',
|
|
49
|
+
metadata: { category: 'infrastructure' },
|
|
50
|
+
output: z.object({
|
|
51
|
+
error: z.string().optional(),
|
|
52
|
+
errorCode: authErrorCodeSchema.optional(),
|
|
53
|
+
permit: permitSchema.optional(),
|
|
54
|
+
valid: z.boolean(),
|
|
55
|
+
}),
|
|
56
|
+
run: async (input, ctx) => {
|
|
57
|
+
const adapter = authService.from(ctx);
|
|
58
|
+
const result = await adapter.authenticate({
|
|
59
|
+
bearerToken: input.token,
|
|
60
|
+
requestId: ctx.requestId,
|
|
61
|
+
surface: getSurface(ctx),
|
|
62
|
+
});
|
|
63
|
+
if (result.isErr()) {
|
|
64
|
+
return Result.ok({
|
|
65
|
+
error: result.error.message,
|
|
66
|
+
errorCode: result.error.code,
|
|
67
|
+
valid: false,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const permit = result.value;
|
|
71
|
+
if (!permit) {
|
|
72
|
+
return Result.ok({
|
|
73
|
+
error: 'No credentials',
|
|
74
|
+
errorCode: 'missing_credentials',
|
|
75
|
+
valid: false,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return Result.ok({
|
|
79
|
+
permit: toOutputPermit(permit),
|
|
80
|
+
valid: true,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
services: [authService],
|
|
84
|
+
});
|
|
85
|
+
//# sourceMappingURL=auth-verify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-verify.js","sourceRoot":"","sources":["../../src/trails/auth-verify.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ontrails/permits",
|
|
3
|
+
"version": "1.0.0-beta.12",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./jwt": "./src/adapters/jwt.ts",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc -b",
|
|
12
|
+
"test": "bun test",
|
|
13
|
+
"typecheck": "tsc --noEmit",
|
|
14
|
+
"lint": "oxlint ./src",
|
|
15
|
+
"clean": "rm -rf dist *.tsbuildinfo"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"@ontrails/core": "^1.0.0-beta.12",
|
|
19
|
+
"zod": "^4.3.5"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Result } from '@ontrails/core';
|
|
4
|
+
|
|
5
|
+
import type { AuthAdapter, AuthError } from '../adapter.js';
|
|
6
|
+
import type { PermitExtractionInput } from '../extraction.js';
|
|
7
|
+
import type { Permit } from '../permit.js';
|
|
8
|
+
import { createJwtAdapter } from '../adapters/jwt.js';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Test helper: sign a JWT with HMAC-SHA256 using crypto.subtle
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const base64url = (buf: ArrayBuffer): string => {
|
|
15
|
+
const bytes = new Uint8Array(buf);
|
|
16
|
+
let binary = '';
|
|
17
|
+
for (const b of bytes) {
|
|
18
|
+
binary += String.fromCodePoint(b);
|
|
19
|
+
}
|
|
20
|
+
return btoa(binary)
|
|
21
|
+
.replaceAll('+', '-')
|
|
22
|
+
.replaceAll('/', '_')
|
|
23
|
+
.replace(/=+$/, '');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const base64urlEncode = (str: string): string => {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
return base64url(encoder.encode(str).buffer as ArrayBuffer);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const signJwt = async (
|
|
32
|
+
payload: Record<string, unknown>,
|
|
33
|
+
secret: string
|
|
34
|
+
): Promise<string> => {
|
|
35
|
+
const header = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
36
|
+
const body = base64urlEncode(JSON.stringify(payload));
|
|
37
|
+
const data = `${header}.${body}`;
|
|
38
|
+
const encoder = new TextEncoder();
|
|
39
|
+
const key = await crypto.subtle.importKey(
|
|
40
|
+
'raw',
|
|
41
|
+
encoder.encode(secret),
|
|
42
|
+
{ hash: 'SHA-256', name: 'HMAC' },
|
|
43
|
+
false,
|
|
44
|
+
['sign']
|
|
45
|
+
);
|
|
46
|
+
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
47
|
+
return `${data}.${base64url(sig)}`;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const TEST_SECRET = 'test-secret-for-hmac-256';
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Tests
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/** Minimal extraction input for tests that don't need all fields. */
|
|
57
|
+
const testInput = (
|
|
58
|
+
overrides?: Partial<PermitExtractionInput>
|
|
59
|
+
): PermitExtractionInput => ({
|
|
60
|
+
requestId: 'test-req',
|
|
61
|
+
surface: 'http',
|
|
62
|
+
...overrides,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('AuthAdapter interface', () => {
|
|
66
|
+
test('accepts a valid adapter implementation', async () => {
|
|
67
|
+
const adapter: AuthAdapter = {
|
|
68
|
+
// oxlint-disable-next-line require-await -- stub adapter for type test
|
|
69
|
+
authenticate: async (_input: PermitExtractionInput) => Result.ok(null),
|
|
70
|
+
};
|
|
71
|
+
const result = await adapter.authenticate(testInput());
|
|
72
|
+
expect(result.isOk()).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/* oxlint-disable max-statements -- test suite with multiple concern groups */
|
|
77
|
+
describe('createJwtAdapter', () => {
|
|
78
|
+
test('returns an AuthAdapter', () => {
|
|
79
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
80
|
+
expect(adapter).toBeDefined();
|
|
81
|
+
expect(adapter.authenticate).toBeInstanceOf(Function);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('verifies a valid HS256 token and returns a Permit', async () => {
|
|
85
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
86
|
+
const now = Math.floor(Date.now() / 1000);
|
|
87
|
+
const token = await signJwt(
|
|
88
|
+
{ exp: now + 3600, scope: 'read write', sub: 'user-123' },
|
|
89
|
+
TEST_SECRET
|
|
90
|
+
);
|
|
91
|
+
const result = await adapter.authenticate(
|
|
92
|
+
testInput({ bearerToken: token })
|
|
93
|
+
);
|
|
94
|
+
expect(result.isOk()).toBe(true);
|
|
95
|
+
const permit = result.unwrap() as Permit;
|
|
96
|
+
expect(permit).not.toBeNull();
|
|
97
|
+
expect(permit.id).toBe('user-123');
|
|
98
|
+
expect(permit.scopes).toEqual(['read', 'write']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('rejects an expired token', async () => {
|
|
102
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
103
|
+
const past = Math.floor(Date.now() / 1000) - 3600;
|
|
104
|
+
const token = await signJwt(
|
|
105
|
+
{ exp: past, sub: 'user-expired' },
|
|
106
|
+
TEST_SECRET
|
|
107
|
+
);
|
|
108
|
+
const result = await adapter.authenticate(
|
|
109
|
+
testInput({ bearerToken: token })
|
|
110
|
+
);
|
|
111
|
+
expect(result.isErr()).toBe(true);
|
|
112
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
113
|
+
expect(err.code).toBe('expired_token');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('rejects an invalid signature', async () => {
|
|
117
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
118
|
+
const now = Math.floor(Date.now() / 1000);
|
|
119
|
+
const token = await signJwt(
|
|
120
|
+
{ exp: now + 3600, sub: 'user-bad-sig' },
|
|
121
|
+
'wrong-secret'
|
|
122
|
+
);
|
|
123
|
+
const result = await adapter.authenticate(
|
|
124
|
+
testInput({ bearerToken: token })
|
|
125
|
+
);
|
|
126
|
+
expect(result.isErr()).toBe(true);
|
|
127
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
128
|
+
expect(err.code).toBe('invalid_token');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('rejects tokens with a missing subject claim', async () => {
|
|
132
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
133
|
+
const now = Math.floor(Date.now() / 1000);
|
|
134
|
+
const token = await signJwt(
|
|
135
|
+
{ exp: now + 3600, scope: 'read' },
|
|
136
|
+
TEST_SECRET
|
|
137
|
+
);
|
|
138
|
+
const result = await adapter.authenticate(
|
|
139
|
+
testInput({ bearerToken: token })
|
|
140
|
+
);
|
|
141
|
+
expect(result.isErr()).toBe(true);
|
|
142
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
143
|
+
expect(err.code).toBe('invalid_token');
|
|
144
|
+
expect(err.message).toContain('Missing subject claim');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('extracts scopes from token claims', async () => {
|
|
148
|
+
const adapter = createJwtAdapter({
|
|
149
|
+
scopesClaim: 'permissions',
|
|
150
|
+
secret: TEST_SECRET,
|
|
151
|
+
});
|
|
152
|
+
const now = Math.floor(Date.now() / 1000);
|
|
153
|
+
const token = await signJwt(
|
|
154
|
+
{
|
|
155
|
+
exp: now + 3600,
|
|
156
|
+
permissions: 'admin:read admin:write',
|
|
157
|
+
sub: 'user-scopes',
|
|
158
|
+
},
|
|
159
|
+
TEST_SECRET
|
|
160
|
+
);
|
|
161
|
+
const result = await adapter.authenticate(
|
|
162
|
+
testInput({ bearerToken: token })
|
|
163
|
+
);
|
|
164
|
+
expect(result.isOk()).toBe(true);
|
|
165
|
+
const permit = result.unwrap() as Permit;
|
|
166
|
+
expect(permit.scopes).toEqual(['admin:read', 'admin:write']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('extracts roles from token claims', async () => {
|
|
170
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
171
|
+
const now = Math.floor(Date.now() / 1000);
|
|
172
|
+
const token = await signJwt(
|
|
173
|
+
{ exp: now + 3600, roles: ['admin', 'editor'], sub: 'user-roles' },
|
|
174
|
+
TEST_SECRET
|
|
175
|
+
);
|
|
176
|
+
const result = await adapter.authenticate(
|
|
177
|
+
testInput({ bearerToken: token })
|
|
178
|
+
);
|
|
179
|
+
expect(result.isOk()).toBe(true);
|
|
180
|
+
const permit = result.unwrap() as Permit;
|
|
181
|
+
expect(permit.roles).toEqual(['admin', 'editor']);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('returns null for missing credentials', async () => {
|
|
185
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
186
|
+
const result = await adapter.authenticate(testInput());
|
|
187
|
+
expect(result.isOk()).toBe(true);
|
|
188
|
+
expect(result.unwrap()).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('checks issuer claim when configured', async () => {
|
|
192
|
+
const adapter = createJwtAdapter({
|
|
193
|
+
issuer: 'https://auth.example.com',
|
|
194
|
+
secret: TEST_SECRET,
|
|
195
|
+
});
|
|
196
|
+
const now = Math.floor(Date.now() / 1000);
|
|
197
|
+
const token = await signJwt(
|
|
198
|
+
{ exp: now + 3600, iss: 'https://evil.example.com', sub: 'user-iss' },
|
|
199
|
+
TEST_SECRET
|
|
200
|
+
);
|
|
201
|
+
const result = await adapter.authenticate(
|
|
202
|
+
testInput({ bearerToken: token })
|
|
203
|
+
);
|
|
204
|
+
expect(result.isErr()).toBe(true);
|
|
205
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
206
|
+
expect(err.code).toBe('invalid_token');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('checks audience claim when configured', async () => {
|
|
210
|
+
const adapter = createJwtAdapter({
|
|
211
|
+
audience: 'my-api',
|
|
212
|
+
secret: TEST_SECRET,
|
|
213
|
+
});
|
|
214
|
+
const now = Math.floor(Date.now() / 1000);
|
|
215
|
+
const token = await signJwt(
|
|
216
|
+
{ aud: 'other-api', exp: now + 3600, sub: 'user-aud' },
|
|
217
|
+
TEST_SECRET
|
|
218
|
+
);
|
|
219
|
+
const result = await adapter.authenticate(
|
|
220
|
+
testInput({ bearerToken: token })
|
|
221
|
+
);
|
|
222
|
+
expect(result.isErr()).toBe(true);
|
|
223
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
224
|
+
expect(err.code).toBe('invalid_token');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('accepts array-format audience containing configured value', async () => {
|
|
228
|
+
const adapter = createJwtAdapter({
|
|
229
|
+
audience: 'my-api',
|
|
230
|
+
secret: TEST_SECRET,
|
|
231
|
+
});
|
|
232
|
+
const now = Math.floor(Date.now() / 1000);
|
|
233
|
+
const token = await signJwt(
|
|
234
|
+
{ aud: ['my-api', 'account'], exp: now + 3600, sub: 'user-arr-aud' },
|
|
235
|
+
TEST_SECRET
|
|
236
|
+
);
|
|
237
|
+
const result = await adapter.authenticate(
|
|
238
|
+
testInput({ bearerToken: token })
|
|
239
|
+
);
|
|
240
|
+
expect(result.isOk()).toBe(true);
|
|
241
|
+
const permit = result.unwrap() as Permit;
|
|
242
|
+
expect(permit.id).toBe('user-arr-aud');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('rejects array-format audience not containing configured value', async () => {
|
|
246
|
+
const adapter = createJwtAdapter({
|
|
247
|
+
audience: 'my-api',
|
|
248
|
+
secret: TEST_SECRET,
|
|
249
|
+
});
|
|
250
|
+
const now = Math.floor(Date.now() / 1000);
|
|
251
|
+
const token = await signJwt(
|
|
252
|
+
{ aud: ['other-api', 'account'], exp: now + 3600, sub: 'user-no-aud' },
|
|
253
|
+
TEST_SECRET
|
|
254
|
+
);
|
|
255
|
+
const result = await adapter.authenticate(
|
|
256
|
+
testInput({ bearerToken: token })
|
|
257
|
+
);
|
|
258
|
+
expect(result.isErr()).toBe(true);
|
|
259
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
260
|
+
expect(err.code).toBe('invalid_token');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('extracts scopes from array-format claim', async () => {
|
|
264
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
265
|
+
const now = Math.floor(Date.now() / 1000);
|
|
266
|
+
const token = await signJwt(
|
|
267
|
+
{ exp: now + 3600, scope: ['read', 'write'], sub: 'user-arr-scope' },
|
|
268
|
+
TEST_SECRET
|
|
269
|
+
);
|
|
270
|
+
const result = await adapter.authenticate(
|
|
271
|
+
testInput({ bearerToken: token })
|
|
272
|
+
);
|
|
273
|
+
expect(result.isOk()).toBe(true);
|
|
274
|
+
const permit = result.unwrap() as Permit;
|
|
275
|
+
expect(permit.scopes).toEqual(['read', 'write']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('filters empty strings from array-format scope claims', async () => {
|
|
279
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
280
|
+
const now = Math.floor(Date.now() / 1000);
|
|
281
|
+
const token = await signJwt(
|
|
282
|
+
{
|
|
283
|
+
exp: now + 3600,
|
|
284
|
+
scope: ['', 'read', '', 'write'],
|
|
285
|
+
sub: 'user-arr-scope-empty',
|
|
286
|
+
},
|
|
287
|
+
TEST_SECRET
|
|
288
|
+
);
|
|
289
|
+
const result = await adapter.authenticate(
|
|
290
|
+
testInput({ bearerToken: token })
|
|
291
|
+
);
|
|
292
|
+
expect(result.isOk()).toBe(true);
|
|
293
|
+
const permit = result.unwrap() as Permit;
|
|
294
|
+
expect(permit.scopes).toEqual(['read', 'write']);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('returns error for malformed signature bytes', async () => {
|
|
298
|
+
const adapter = createJwtAdapter({ secret: TEST_SECRET });
|
|
299
|
+
const now = Math.floor(Date.now() / 1000);
|
|
300
|
+
const token = await signJwt(
|
|
301
|
+
{ exp: now + 3600, sub: 'user-bad' },
|
|
302
|
+
TEST_SECRET
|
|
303
|
+
);
|
|
304
|
+
const parts = token.split('.');
|
|
305
|
+
const malformed = `${parts[0]}.${parts[1]}.!!!invalid-base64!!!`;
|
|
306
|
+
const result = await adapter.authenticate(
|
|
307
|
+
testInput({ bearerToken: malformed })
|
|
308
|
+
);
|
|
309
|
+
expect(result.isErr()).toBe(true);
|
|
310
|
+
const err = (result as ReturnType<typeof Result.err<AuthError>>).error;
|
|
311
|
+
expect(err.code).toBe('invalid_token');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test('accepts token with matching issuer and audience', async () => {
|
|
315
|
+
const adapter = createJwtAdapter({
|
|
316
|
+
audience: 'my-api',
|
|
317
|
+
issuer: 'https://auth.example.com',
|
|
318
|
+
secret: TEST_SECRET,
|
|
319
|
+
});
|
|
320
|
+
const now = Math.floor(Date.now() / 1000);
|
|
321
|
+
const token = await signJwt(
|
|
322
|
+
{
|
|
323
|
+
aud: 'my-api',
|
|
324
|
+
exp: now + 3600,
|
|
325
|
+
iss: 'https://auth.example.com',
|
|
326
|
+
scope: 'read',
|
|
327
|
+
sub: 'user-valid',
|
|
328
|
+
},
|
|
329
|
+
TEST_SECRET
|
|
330
|
+
);
|
|
331
|
+
const result = await adapter.authenticate(
|
|
332
|
+
testInput({ bearerToken: token })
|
|
333
|
+
);
|
|
334
|
+
expect(result.isOk()).toBe(true);
|
|
335
|
+
const permit = result.unwrap() as Permit;
|
|
336
|
+
expect(permit.id).toBe('user-valid');
|
|
337
|
+
});
|
|
338
|
+
});
|