@ontrails/permits 1.0.0-beta.15 → 1.0.0-beta.16
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/CHANGELOG.md +69 -0
- package/README.md +30 -22
- package/package.json +12 -3
- package/src/adapters/adapter.ts +41 -0
- package/src/{connectors → adapters}/jwt.ts +148 -21
- package/src/auth-resource.ts +81 -18
- package/src/boundary.ts +200 -0
- package/src/errors.ts +1 -18
- package/src/extraction.ts +22 -16
- package/src/index.ts +22 -10
- package/src/trails/auth-verify.ts +11 -13
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/auth-layer.d.ts +0 -18
- package/dist/auth-layer.d.ts.map +0 -1
- package/dist/auth-layer.js +0 -56
- package/dist/auth-layer.js.map +0 -1
- package/dist/auth-resource.d.ts +0 -11
- package/dist/auth-resource.d.ts.map +0 -1
- package/dist/auth-resource.js +0 -22
- package/dist/auth-resource.js.map +0 -1
- package/dist/connectors/connector.d.ts +0 -26
- package/dist/connectors/connector.d.ts.map +0 -1
- package/dist/connectors/connector.js +0 -2
- package/dist/connectors/connector.js.map +0 -1
- package/dist/connectors/jwt.d.ts +0 -25
- package/dist/connectors/jwt.d.ts.map +0 -1
- package/dist/connectors/jwt.js +0 -148
- package/dist/connectors/jwt.js.map +0 -1
- package/dist/errors.d.ts +0 -15
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -15
- package/dist/errors.js.map +0 -1
- package/dist/extraction.d.ts +0 -20
- package/dist/extraction.d.ts.map +0 -1
- package/dist/extraction.js +0 -2
- package/dist/extraction.js.map +0 -1
- package/dist/index.d.ts +0 -11
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -11
- package/dist/index.js.map +0 -1
- package/dist/permit.d.ts +0 -26
- package/dist/permit.d.ts.map +0 -1
- package/dist/permit.js +0 -17
- package/dist/permit.js.map +0 -1
- package/dist/rules.d.ts +0 -47
- package/dist/rules.d.ts.map +0 -1
- package/dist/rules.js +0 -127
- package/dist/rules.js.map +0 -1
- package/dist/testing.d.ts +0 -20
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -22
- package/dist/testing.js.map +0 -1
- package/dist/trails/auth-verify.d.ts +0 -22
- package/dist/trails/auth-verify.d.ts.map +0 -1
- package/dist/trails/auth-verify.js +0 -85
- package/dist/trails/auth-verify.js.map +0 -1
- package/src/__tests__/auth-layer.test.ts +0 -130
- package/src/__tests__/auth-resource.test.ts +0 -62
- package/src/__tests__/auth-verify.test.ts +0 -278
- package/src/__tests__/connector.test.ts +0 -338
- package/src/__tests__/permit.test.ts +0 -122
- package/src/__tests__/rules.test.ts +0 -239
- package/src/__tests__/testing.test.ts +0 -57
- package/src/auth-layer.ts +0 -80
- package/src/connectors/connector.ts +0 -35
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { Result, TRAILHEAD_KEY, trail } from '@ontrails/core';
|
|
2
|
-
import { z } from 'zod';
|
|
3
|
-
import { authResource } from '../auth-resource.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 isTrailhead = (value) => value === 'http' || value === 'mcp' || value === 'cli';
|
|
27
|
-
const getTrailhead = (ctx) => {
|
|
28
|
-
const trailhead = ctx.extensions?.[TRAILHEAD_KEY];
|
|
29
|
-
return isTrailhead(trailhead) ? trailhead : 'http';
|
|
30
|
-
};
|
|
31
|
-
/**
|
|
32
|
-
* Infrastructure trail that verifies a bearer token and returns the resolved permit.
|
|
33
|
-
*
|
|
34
|
-
* Reads the auth connector from `authResource` — the connector is configured
|
|
35
|
-
* at bootstrap (e.g. JWT with HMAC secret). The mock connector always
|
|
36
|
-
* succeeds with a null permit, so `testAll(app)` works without configuration.
|
|
37
|
-
*/
|
|
38
|
-
export const authVerify = trail('auth.verify', {
|
|
39
|
-
blaze: async (input, ctx) => {
|
|
40
|
-
const connector = authResource.from(ctx);
|
|
41
|
-
const result = await connector.authenticate({
|
|
42
|
-
bearerToken: input.token,
|
|
43
|
-
requestId: ctx.requestId,
|
|
44
|
-
trailhead: getTrailhead(ctx),
|
|
45
|
-
});
|
|
46
|
-
if (result.isErr()) {
|
|
47
|
-
return Result.ok({
|
|
48
|
-
error: result.error.message,
|
|
49
|
-
errorCode: result.error.code,
|
|
50
|
-
valid: false,
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
const permit = result.value;
|
|
54
|
-
if (!permit) {
|
|
55
|
-
return Result.ok({
|
|
56
|
-
error: 'No credentials',
|
|
57
|
-
errorCode: 'missing_credentials',
|
|
58
|
-
valid: false,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
return Result.ok({
|
|
62
|
-
permit: toOutputPermit(permit),
|
|
63
|
-
valid: true,
|
|
64
|
-
});
|
|
65
|
-
},
|
|
66
|
-
examples: [
|
|
67
|
-
{
|
|
68
|
-
input: { token: 'test-token' },
|
|
69
|
-
name: 'Verify a token',
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
input: z.object({
|
|
73
|
-
token: z.string().min(1).describe('Bearer token to verify'),
|
|
74
|
-
}),
|
|
75
|
-
intent: 'read',
|
|
76
|
-
meta: { category: 'infrastructure' },
|
|
77
|
-
output: z.object({
|
|
78
|
-
error: z.string().optional(),
|
|
79
|
-
errorCode: authErrorCodeSchema.optional(),
|
|
80
|
-
permit: permitSchema.optional(),
|
|
81
|
-
valid: z.boolean(),
|
|
82
|
-
}),
|
|
83
|
-
resources: [authResource],
|
|
84
|
-
});
|
|
85
|
-
//# sourceMappingURL=auth-verify.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth-verify.js","sourceRoot":"","sources":["../../src/trails/auth-verify.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/* oxlint-disable require-await -- layer wrappers satisfy async interfaces without awaiting */
|
|
2
|
-
import { describe, expect, test } from 'bun:test';
|
|
3
|
-
|
|
4
|
-
import { Result, trail } from '@ontrails/core';
|
|
5
|
-
import type { TrailContext } from '@ontrails/core';
|
|
6
|
-
import { z } from 'zod';
|
|
7
|
-
|
|
8
|
-
import { authLayer } from '../auth-layer';
|
|
9
|
-
import { PermitError } from '../errors';
|
|
10
|
-
|
|
11
|
-
// ---------------------------------------------------------------------------
|
|
12
|
-
// Helpers
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
|
|
15
|
-
const makeCtx = (permit?: {
|
|
16
|
-
id: string;
|
|
17
|
-
scopes: readonly string[];
|
|
18
|
-
}): TrailContext => ({
|
|
19
|
-
abortSignal: AbortSignal.timeout(5000),
|
|
20
|
-
permit,
|
|
21
|
-
requestId: 'test-auth',
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
const okImpl = async () => Result.ok({ done: true });
|
|
25
|
-
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
// Tests
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
|
|
30
|
-
describe('authLayer', () => {
|
|
31
|
-
test('has correct name and description', () => {
|
|
32
|
-
expect(authLayer.name).toBe('auth');
|
|
33
|
-
expect(authLayer.description).toBeDefined();
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('pass-through cases', () => {
|
|
37
|
-
test('passes through when trail has no permit field', async () => {
|
|
38
|
-
const t = trail('test.nopermit', {
|
|
39
|
-
blaze: okImpl,
|
|
40
|
-
input: z.object({}),
|
|
41
|
-
output: z.object({ done: z.boolean() }),
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const wrapped = authLayer.wrap(t, okImpl);
|
|
45
|
-
const result = await wrapped({}, makeCtx());
|
|
46
|
-
|
|
47
|
-
expect(result.isOk()).toBe(true);
|
|
48
|
-
expect(result.unwrap()).toEqual({ done: true });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test('passes through when trail permit is public', async () => {
|
|
52
|
-
const t = trail('test.public', {
|
|
53
|
-
blaze: okImpl,
|
|
54
|
-
input: z.object({}),
|
|
55
|
-
output: z.object({ done: z.boolean() }),
|
|
56
|
-
permit: 'public',
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
const wrapped = authLayer.wrap(t, okImpl);
|
|
60
|
-
const result = await wrapped({}, makeCtx());
|
|
61
|
-
|
|
62
|
-
expect(result.isOk()).toBe(true);
|
|
63
|
-
expect(result.unwrap()).toEqual({ done: true });
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('scope enforcement', () => {
|
|
68
|
-
const scopedTrail = trail('test.scoped', {
|
|
69
|
-
blaze: okImpl,
|
|
70
|
-
input: z.object({}),
|
|
71
|
-
output: z.object({ done: z.boolean() }),
|
|
72
|
-
permit: { scopes: ['user:read'] },
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test('passes when ctx.permit has matching scopes', async () => {
|
|
76
|
-
const wrapped = authLayer.wrap(scopedTrail, okImpl);
|
|
77
|
-
const result = await wrapped(
|
|
78
|
-
{},
|
|
79
|
-
makeCtx({ id: 'usr-1', scopes: ['user:read'] })
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
expect(result.isOk()).toBe(true);
|
|
83
|
-
expect(result.unwrap()).toEqual({ done: true });
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('returns error when ctx has no permit', async () => {
|
|
87
|
-
const wrapped = authLayer.wrap(scopedTrail, okImpl);
|
|
88
|
-
const result = await wrapped({}, makeCtx());
|
|
89
|
-
|
|
90
|
-
expect(result.isErr()).toBe(true);
|
|
91
|
-
const err = (result as unknown as { error: PermitError }).error;
|
|
92
|
-
expect(err).toBeInstanceOf(PermitError);
|
|
93
|
-
expect(err.message).toContain('No permit');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('returns error when permit is missing required scopes', async () => {
|
|
97
|
-
const multiScopeTrail = trail('test.multi', {
|
|
98
|
-
blaze: okImpl,
|
|
99
|
-
input: z.object({}),
|
|
100
|
-
output: z.object({ done: z.boolean() }),
|
|
101
|
-
permit: { scopes: ['user:read', 'user:write'] },
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const wrapped = authLayer.wrap(multiScopeTrail, okImpl);
|
|
105
|
-
const result = await wrapped(
|
|
106
|
-
{},
|
|
107
|
-
makeCtx({ id: 'usr-1', scopes: ['user:read'] })
|
|
108
|
-
);
|
|
109
|
-
|
|
110
|
-
expect(result.isErr()).toBe(true);
|
|
111
|
-
const err = (result as unknown as { error: PermitError }).error;
|
|
112
|
-
expect(err).toBeInstanceOf(PermitError);
|
|
113
|
-
expect(err.message).toContain('user:write');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('passes when permit has superset of required scopes', async () => {
|
|
117
|
-
const wrapped = authLayer.wrap(scopedTrail, okImpl);
|
|
118
|
-
const result = await wrapped(
|
|
119
|
-
{},
|
|
120
|
-
makeCtx({
|
|
121
|
-
id: 'usr-1',
|
|
122
|
-
scopes: ['user:read', 'user:write', 'admin'],
|
|
123
|
-
})
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
expect(result.isOk()).toBe(true);
|
|
127
|
-
expect(result.unwrap()).toEqual({ done: true });
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
});
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import type { ResourceContext } from '@ontrails/core';
|
|
4
|
-
|
|
5
|
-
import type { AuthConnector } from '../connectors/connector.js';
|
|
6
|
-
import { authResource } from '../auth-resource.js';
|
|
7
|
-
import type { PermitExtractionInput } from '../extraction.js';
|
|
8
|
-
|
|
9
|
-
/** Minimal extraction input for tests. */
|
|
10
|
-
const testInput = (
|
|
11
|
-
overrides?: Partial<PermitExtractionInput>
|
|
12
|
-
): PermitExtractionInput => ({
|
|
13
|
-
requestId: 'test-svc-req',
|
|
14
|
-
trailhead: 'http',
|
|
15
|
-
...overrides,
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Helpers
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
const testSvcCtx: ResourceContext = {
|
|
23
|
-
config: undefined,
|
|
24
|
-
cwd: '/tmp',
|
|
25
|
-
env: {},
|
|
26
|
-
workspaceRoot: '/tmp',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
// Tests
|
|
31
|
-
// ---------------------------------------------------------------------------
|
|
32
|
-
|
|
33
|
-
describe('authResource', () => {
|
|
34
|
-
test('has correct id and kind', () => {
|
|
35
|
-
expect(authResource.id).toBe('auth');
|
|
36
|
-
expect(authResource.kind).toBe('resource');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('has infrastructure meta', () => {
|
|
40
|
-
expect(authResource.meta).toEqual({ category: 'infrastructure' });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test('mock returns an AuthConnector', async () => {
|
|
44
|
-
const mock = authResource.mock?.();
|
|
45
|
-
expect(mock).toBeDefined();
|
|
46
|
-
|
|
47
|
-
const connector = mock as AuthConnector;
|
|
48
|
-
const result = await connector.authenticate(testInput());
|
|
49
|
-
expect(result.isOk()).toBe(true);
|
|
50
|
-
expect(result.unwrap()).toBeNull();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test('create returns Result.ok with an AuthConnector', async () => {
|
|
54
|
-
const result = await authResource.create(testSvcCtx);
|
|
55
|
-
expect(result.isOk()).toBe(true);
|
|
56
|
-
|
|
57
|
-
const connector = result.unwrap() as AuthConnector;
|
|
58
|
-
const authResult = await connector.authenticate(testInput());
|
|
59
|
-
expect(authResult.isOk()).toBe(true);
|
|
60
|
-
expect(authResult.unwrap()).toBeNull();
|
|
61
|
-
});
|
|
62
|
-
});
|
|
@@ -1,278 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
Result,
|
|
5
|
-
TRAILHEAD_KEY,
|
|
6
|
-
ValidationError,
|
|
7
|
-
executeTrail,
|
|
8
|
-
} from '@ontrails/core';
|
|
9
|
-
|
|
10
|
-
import type { AuthConnector } from '../connectors/connector.js';
|
|
11
|
-
import { authResource } from '../auth-resource.js';
|
|
12
|
-
import { createJwtConnector } from '../connectors/jwt.js';
|
|
13
|
-
import type { Permit } from '../permit.js';
|
|
14
|
-
import { authVerify } from '../trails/auth-verify.js';
|
|
15
|
-
|
|
16
|
-
// ---------------------------------------------------------------------------
|
|
17
|
-
// Test helper: sign a JWT with HMAC-SHA256 using crypto.subtle
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
|
|
20
|
-
const base64url = (buf: ArrayBuffer): string => {
|
|
21
|
-
const bytes = new Uint8Array(buf);
|
|
22
|
-
let binary = '';
|
|
23
|
-
for (const b of bytes) {
|
|
24
|
-
binary += String.fromCodePoint(b);
|
|
25
|
-
}
|
|
26
|
-
return btoa(binary)
|
|
27
|
-
.replaceAll('+', '-')
|
|
28
|
-
.replaceAll('/', '_')
|
|
29
|
-
.replace(/=+$/, '');
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
const base64urlEncode = (str: string): string => {
|
|
33
|
-
const encoder = new TextEncoder();
|
|
34
|
-
return base64url(encoder.encode(str).buffer as ArrayBuffer);
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
const signJwt = async (
|
|
38
|
-
payload: Record<string, unknown>,
|
|
39
|
-
secret: string
|
|
40
|
-
): Promise<string> => {
|
|
41
|
-
const header = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
42
|
-
const body = base64urlEncode(JSON.stringify(payload));
|
|
43
|
-
const data = `${header}.${body}`;
|
|
44
|
-
const encoder = new TextEncoder();
|
|
45
|
-
const key = await crypto.subtle.importKey(
|
|
46
|
-
'raw',
|
|
47
|
-
encoder.encode(secret),
|
|
48
|
-
{ hash: 'SHA-256', name: 'HMAC' },
|
|
49
|
-
false,
|
|
50
|
-
['sign']
|
|
51
|
-
);
|
|
52
|
-
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
|
53
|
-
return `${data}.${base64url(sig)}`;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const TEST_SECRET = 'test-secret-for-hmac-256';
|
|
57
|
-
|
|
58
|
-
/** Create an AuthConnector wired to a JWT secret. */
|
|
59
|
-
const jwtConnector = (): AuthConnector =>
|
|
60
|
-
createJwtConnector({ secret: TEST_SECRET });
|
|
61
|
-
|
|
62
|
-
/** Execute auth.verify with a given connector injected as the auth resource. */
|
|
63
|
-
const runVerify = async (
|
|
64
|
-
token: string,
|
|
65
|
-
connector: AuthConnector,
|
|
66
|
-
options?: {
|
|
67
|
-
trailhead?: 'http' | 'mcp' | 'cli';
|
|
68
|
-
}
|
|
69
|
-
): Promise<
|
|
70
|
-
Result<
|
|
71
|
-
{
|
|
72
|
-
error?: string;
|
|
73
|
-
errorCode?:
|
|
74
|
-
| 'expired_token'
|
|
75
|
-
| 'insufficient_scope'
|
|
76
|
-
| 'invalid_token'
|
|
77
|
-
| 'missing_credentials';
|
|
78
|
-
permit?: {
|
|
79
|
-
id: string;
|
|
80
|
-
metadata?: Record<string, unknown>;
|
|
81
|
-
roles?: string[];
|
|
82
|
-
scopes: string[];
|
|
83
|
-
tenantId?: string;
|
|
84
|
-
};
|
|
85
|
-
valid: boolean;
|
|
86
|
-
},
|
|
87
|
-
Error
|
|
88
|
-
>
|
|
89
|
-
> => {
|
|
90
|
-
const result = await executeTrail(
|
|
91
|
-
authVerify,
|
|
92
|
-
{ token },
|
|
93
|
-
{
|
|
94
|
-
ctx:
|
|
95
|
-
options?.trailhead === undefined
|
|
96
|
-
? undefined
|
|
97
|
-
: { extensions: { [TRAILHEAD_KEY]: options.trailhead } },
|
|
98
|
-
resources: { [authResource.id]: connector },
|
|
99
|
-
}
|
|
100
|
-
);
|
|
101
|
-
return result as Result<
|
|
102
|
-
{
|
|
103
|
-
error?: string;
|
|
104
|
-
permit?: {
|
|
105
|
-
id: string;
|
|
106
|
-
metadata?: Record<string, unknown>;
|
|
107
|
-
roles?: string[];
|
|
108
|
-
scopes: string[];
|
|
109
|
-
tenantId?: string;
|
|
110
|
-
};
|
|
111
|
-
valid: boolean;
|
|
112
|
-
},
|
|
113
|
-
Error
|
|
114
|
-
>;
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// Tests
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
describe('auth.verify trail', () => {
|
|
122
|
-
describe('contract', () => {
|
|
123
|
-
test('has correct id and intent', () => {
|
|
124
|
-
expect(authVerify.id).toBe('auth.verify');
|
|
125
|
-
expect(authVerify.intent).toBe('read');
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test('has infrastructure meta', () => {
|
|
129
|
-
expect(authVerify.meta).toEqual({ category: 'infrastructure' });
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test('has examples', () => {
|
|
133
|
-
expect(authVerify.examples).toBeDefined();
|
|
134
|
-
expect(authVerify.examples?.length).toBeGreaterThan(0);
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('declares authResource dependency', () => {
|
|
138
|
-
expect(authVerify.resources).toHaveLength(1);
|
|
139
|
-
expect(authVerify.resources[0]?.id).toBe('auth');
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
describe('with mock connector (no credentials)', () => {
|
|
144
|
-
test('returns valid: false with error message', async () => {
|
|
145
|
-
const noopConnector: AuthConnector = {
|
|
146
|
-
// oxlint-disable-next-line require-await -- satisfies async interface
|
|
147
|
-
authenticate: async () => Result.ok(null),
|
|
148
|
-
};
|
|
149
|
-
|
|
150
|
-
const result = await runVerify('some-token', noopConnector);
|
|
151
|
-
|
|
152
|
-
expect(result.isOk()).toBe(true);
|
|
153
|
-
const value = result.unwrap();
|
|
154
|
-
expect(value.valid).toBe(false);
|
|
155
|
-
expect(value.error).toBe('No credentials');
|
|
156
|
-
expect(value.errorCode).toBe('missing_credentials');
|
|
157
|
-
expect(value.permit).toBeUndefined();
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('with valid token and secret', () => {
|
|
162
|
-
test('returns valid: true with permit', async () => {
|
|
163
|
-
const now = Math.floor(Date.now() / 1000);
|
|
164
|
-
const token = await signJwt(
|
|
165
|
-
{ exp: now + 3600, scope: 'read write', sub: 'user-42' },
|
|
166
|
-
TEST_SECRET
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const result = await runVerify(token, jwtConnector());
|
|
170
|
-
|
|
171
|
-
expect(result.isOk()).toBe(true);
|
|
172
|
-
const value = result.unwrap();
|
|
173
|
-
expect(value.valid).toBe(true);
|
|
174
|
-
expect(value.permit).toEqual({
|
|
175
|
-
id: 'user-42',
|
|
176
|
-
scopes: ['read', 'write'],
|
|
177
|
-
});
|
|
178
|
-
expect(value.error).toBeUndefined();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test('returns the full permit payload from the connector', async () => {
|
|
182
|
-
const permit: Permit = {
|
|
183
|
-
id: 'user-42',
|
|
184
|
-
metadata: { plan: 'pro' },
|
|
185
|
-
roles: ['admin'],
|
|
186
|
-
scopes: ['read', 'write'],
|
|
187
|
-
tenantId: 'tenant-1',
|
|
188
|
-
};
|
|
189
|
-
const connector: AuthConnector = {
|
|
190
|
-
// oxlint-disable-next-line require-await -- satisfies async interface
|
|
191
|
-
authenticate: async () => Result.ok(permit),
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
const result = await runVerify('full-permit-token', connector);
|
|
195
|
-
|
|
196
|
-
expect(result.isOk()).toBe(true);
|
|
197
|
-
expect(result.unwrap().permit).toEqual({
|
|
198
|
-
id: 'user-42',
|
|
199
|
-
metadata: { plan: 'pro' },
|
|
200
|
-
roles: ['admin'],
|
|
201
|
-
scopes: ['read', 'write'],
|
|
202
|
-
tenantId: 'tenant-1',
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
test('forwards the invoking trailhead from trail context', async () => {
|
|
207
|
-
let seenTrailhead: string | undefined;
|
|
208
|
-
const connector: AuthConnector = {
|
|
209
|
-
// oxlint-disable-next-line require-await -- captures connector input
|
|
210
|
-
authenticate: async (input) => {
|
|
211
|
-
seenTrailhead = input.trailhead;
|
|
212
|
-
return Result.ok({
|
|
213
|
-
id: 'user-42',
|
|
214
|
-
scopes: ['read'],
|
|
215
|
-
});
|
|
216
|
-
},
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
const result = await runVerify('trailhead-aware-token', connector, {
|
|
220
|
-
trailhead: 'mcp',
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
expect(result.isOk()).toBe(true);
|
|
224
|
-
expect(seenTrailhead).toBe('mcp');
|
|
225
|
-
});
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
describe('with invalid token', () => {
|
|
229
|
-
test('returns valid: false with error for bad signature', async () => {
|
|
230
|
-
const now = Math.floor(Date.now() / 1000);
|
|
231
|
-
const token = await signJwt(
|
|
232
|
-
{ exp: now + 3600, sub: 'user-bad' },
|
|
233
|
-
'wrong-secret'
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
const result = await runVerify(token, jwtConnector());
|
|
237
|
-
|
|
238
|
-
expect(result.isOk()).toBe(true);
|
|
239
|
-
const value = result.unwrap();
|
|
240
|
-
expect(value.valid).toBe(false);
|
|
241
|
-
expect(value.error).toBeDefined();
|
|
242
|
-
expect(value.errorCode).toBe('invalid_token');
|
|
243
|
-
expect(value.permit).toBeUndefined();
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
test('returns valid: false for expired token', async () => {
|
|
247
|
-
const past = Math.floor(Date.now() / 1000) - 3600;
|
|
248
|
-
const token = await signJwt(
|
|
249
|
-
{ exp: past, sub: 'user-expired' },
|
|
250
|
-
TEST_SECRET
|
|
251
|
-
);
|
|
252
|
-
|
|
253
|
-
const result = await runVerify(token, jwtConnector());
|
|
254
|
-
|
|
255
|
-
expect(result.isOk()).toBe(true);
|
|
256
|
-
const value = result.unwrap();
|
|
257
|
-
expect(value.valid).toBe(false);
|
|
258
|
-
expect(value.error).toBeDefined();
|
|
259
|
-
expect(value.errorCode).toBe('expired_token');
|
|
260
|
-
expect(value.permit).toBeUndefined();
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
describe('input validation', () => {
|
|
265
|
-
test('rejects empty bearer tokens at the boundary', async () => {
|
|
266
|
-
const result = await executeTrail(
|
|
267
|
-
authVerify,
|
|
268
|
-
{ token: '' },
|
|
269
|
-
{
|
|
270
|
-
resources: { [authResource.id]: jwtConnector() },
|
|
271
|
-
}
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
expect(result.isErr()).toBe(true);
|
|
275
|
-
expect(result.error).toBeInstanceOf(ValidationError);
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
});
|