@ontrails/permits 1.0.0-beta.14 → 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 +76 -0
- package/README.md +35 -27
- 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 +89 -0
- 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/permit.ts +2 -2
- package/src/rules.ts +2 -2
- package/src/testing.ts +5 -5
- package/src/trails/auth-verify.ts +13 -15
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/adapter.d.ts +0 -26
- package/dist/adapter.d.ts.map +0 -1
- package/dist/adapter.js +0 -2
- package/dist/adapter.js.map +0 -1
- package/dist/adapters/jwt.d.ts +0 -25
- package/dist/adapters/jwt.d.ts.map +0 -1
- package/dist/adapters/jwt.js +0 -148
- package/dist/adapters/jwt.js.map +0 -1
- package/dist/auth-gate.d.ts +0 -18
- package/dist/auth-gate.d.ts.map +0 -1
- package/dist/auth-gate.js +0 -56
- package/dist/auth-gate.js.map +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-provision.d.ts +0 -11
- package/dist/auth-provision.d.ts.map +0 -1
- package/dist/auth-provision.js +0 -22
- package/dist/auth-provision.js.map +0 -1
- package/dist/auth-service.d.ts +0 -10
- package/dist/auth-service.d.ts.map +0 -1
- package/dist/auth-service.js +0 -21
- package/dist/auth-service.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-gate.test.ts +0 -130
- package/src/__tests__/auth-provision.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-gate.ts +0 -80
- package/src/auth-provision.ts +0 -26
- package/src/connectors/connector.ts +0 -35
- package/tsconfig.json +0 -9
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -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 { authProvision } from '../auth-provision.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 provision. */
|
|
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
|
-
provisions: { [authProvision.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 authProvision dependency', () => {
|
|
138
|
-
expect(authVerify.provisions).toHaveLength(1);
|
|
139
|
-
expect(authVerify.provisions[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
|
-
provisions: { [authProvision.id]: jwtConnector() },
|
|
271
|
-
}
|
|
272
|
-
);
|
|
273
|
-
|
|
274
|
-
expect(result.isErr()).toBe(true);
|
|
275
|
-
expect(result.error).toBeInstanceOf(ValidationError);
|
|
276
|
-
});
|
|
277
|
-
});
|
|
278
|
-
});
|
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
|
|
3
|
-
import { Result } from '@ontrails/core';
|
|
4
|
-
|
|
5
|
-
import type { AuthConnector, AuthError } from '../connectors/connector.js';
|
|
6
|
-
import type { PermitExtractionInput } from '../extraction.js';
|
|
7
|
-
import type { Permit } from '../permit.js';
|
|
8
|
-
import { createJwtConnector } from '../connectors/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
|
-
trailhead: 'http',
|
|
62
|
-
...overrides,
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe('AuthConnector interface', () => {
|
|
66
|
-
test('accepts a valid connector implementation', async () => {
|
|
67
|
-
const connector: AuthConnector = {
|
|
68
|
-
// oxlint-disable-next-line require-await -- stub connector for type test
|
|
69
|
-
authenticate: async (_input: PermitExtractionInput) => Result.ok(null),
|
|
70
|
-
};
|
|
71
|
-
const result = await connector.authenticate(testInput());
|
|
72
|
-
expect(result.isOk()).toBe(true);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
/* oxlint-disable max-statements -- test suite with multiple concern groups */
|
|
77
|
-
describe('createJwtConnector', () => {
|
|
78
|
-
test('returns an AuthConnector', () => {
|
|
79
|
-
const connector = createJwtConnector({ secret: TEST_SECRET });
|
|
80
|
-
expect(connector).toBeDefined();
|
|
81
|
-
expect(connector.authenticate).toBeInstanceOf(Function);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('verifies a valid HS256 token and returns a Permit', async () => {
|
|
85
|
-
const connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({
|
|
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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({ secret: TEST_SECRET });
|
|
186
|
-
const result = await connector.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 connector = createJwtConnector({
|
|
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 connector.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 connector = createJwtConnector({
|
|
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 connector.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 connector = createJwtConnector({
|
|
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 connector.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 connector = createJwtConnector({
|
|
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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({ 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 connector.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 connector = createJwtConnector({
|
|
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 connector.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
|
-
});
|