@ontrails/permits 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +416 -0
- package/README.md +182 -0
- package/package.json +35 -0
- package/src/adapters/adapter.ts +41 -0
- package/src/adapters/jwt.ts +359 -0
- package/src/auth-resource.ts +90 -0
- package/src/boundary.ts +200 -0
- package/src/errors.ts +1 -0
- package/src/extraction.ts +25 -0
- package/src/index.ts +33 -0
- package/src/permit.ts +26 -0
- package/src/rules.ts +189 -0
- package/src/testing.ts +34 -0
- package/src/trails/auth-verify.ts +99 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { Result } from '@ontrails/core';
|
|
2
|
+
|
|
3
|
+
import type { AuthAdapter, AuthError } from './adapter.js';
|
|
4
|
+
import type { PermitExtractionInput } from '../extraction.js';
|
|
5
|
+
import type { Permit } from '../permit.js';
|
|
6
|
+
|
|
7
|
+
/** Configuration for the JWT auth adapter. */
|
|
8
|
+
export interface JwtAdapterOptions {
|
|
9
|
+
/** Accepted JWT header algorithms (default: ['HS256']). */
|
|
10
|
+
readonly allowedAlgorithms?: readonly JwtAlgorithm[];
|
|
11
|
+
/** Clock skew tolerated for exp/nbf checks, in seconds (default: 60). */
|
|
12
|
+
readonly clockSkewSeconds?: number;
|
|
13
|
+
/** HMAC secret for HS256 verification. */
|
|
14
|
+
readonly secret?: string;
|
|
15
|
+
/** Whether accepted tokens must include exp (default: true). */
|
|
16
|
+
readonly requireExpiration?: boolean;
|
|
17
|
+
/** JWKS endpoint for RS256/ES256 (not yet implemented). */
|
|
18
|
+
readonly jwksUrl?: string;
|
|
19
|
+
/** Expected issuer claim. */
|
|
20
|
+
readonly issuer?: string;
|
|
21
|
+
/** Expected audience claim. */
|
|
22
|
+
readonly audience?: string;
|
|
23
|
+
/** Claim containing scopes (default: 'scope'). */
|
|
24
|
+
readonly scopesClaim?: string;
|
|
25
|
+
/** Claim containing roles (default: 'roles'). */
|
|
26
|
+
readonly rolesClaim?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** JWT algorithms this adapter can verify today. */
|
|
30
|
+
export type JwtAlgorithm = 'HS256';
|
|
31
|
+
|
|
32
|
+
interface JwtHeader {
|
|
33
|
+
readonly alg?: unknown;
|
|
34
|
+
readonly typ?: unknown;
|
|
35
|
+
readonly [key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** JWT payload with standard claims. */
|
|
39
|
+
interface JwtPayload {
|
|
40
|
+
readonly sub?: string;
|
|
41
|
+
readonly iss?: string;
|
|
42
|
+
readonly aud?: string | readonly string[];
|
|
43
|
+
readonly exp?: number | null;
|
|
44
|
+
readonly nbf?: number;
|
|
45
|
+
readonly [key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helpers (defined before callers)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const DEFAULT_ALLOWED_ALGORITHMS = [
|
|
53
|
+
'HS256',
|
|
54
|
+
] as const satisfies readonly JwtAlgorithm[];
|
|
55
|
+
const SUPPORTED_JWT_ALGORITHMS = [
|
|
56
|
+
'HS256',
|
|
57
|
+
] as const satisfies readonly JwtAlgorithm[];
|
|
58
|
+
const DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
|
59
|
+
|
|
60
|
+
const authErr = (
|
|
61
|
+
code: AuthError['code'],
|
|
62
|
+
message: string
|
|
63
|
+
): Result<never, AuthError> => Result.err({ code, message });
|
|
64
|
+
|
|
65
|
+
/** Base64url-decode a string to bytes. */
|
|
66
|
+
const base64urlDecode = (input: string): Uint8Array => {
|
|
67
|
+
const padded = input
|
|
68
|
+
.replaceAll('-', '+')
|
|
69
|
+
.replaceAll('_', '/')
|
|
70
|
+
.padEnd(input.length + ((4 - (input.length % 4)) % 4), '=');
|
|
71
|
+
const binary = atob(padded);
|
|
72
|
+
const bytes = new Uint8Array(binary.length);
|
|
73
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
74
|
+
bytes[i] = binary.codePointAt(i) ?? 0;
|
|
75
|
+
}
|
|
76
|
+
return bytes;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const splitToken = (
|
|
80
|
+
token: string
|
|
81
|
+
): readonly [string, string, string] | undefined => {
|
|
82
|
+
const parts = token.split('.');
|
|
83
|
+
if (parts.length !== 3) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return [parts[0] ?? '', parts[1] ?? '', parts[2] ?? ''];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const decodeJsonPart = <T>(part: string): T | undefined => {
|
|
90
|
+
try {
|
|
91
|
+
const json = new TextDecoder().decode(base64urlDecode(part));
|
|
92
|
+
return JSON.parse(json) as T;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const normalizeClockSkewSeconds = (options: JwtAdapterOptions): number => {
|
|
99
|
+
const raw = options.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS;
|
|
100
|
+
const value = Math.floor(raw);
|
|
101
|
+
return Number.isFinite(value)
|
|
102
|
+
? Math.max(0, value)
|
|
103
|
+
: DEFAULT_CLOCK_SKEW_SECONDS;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const allowedAlgorithms = (
|
|
107
|
+
options: JwtAdapterOptions
|
|
108
|
+
): readonly JwtAlgorithm[] =>
|
|
109
|
+
options.allowedAlgorithms ?? DEFAULT_ALLOWED_ALGORITHMS;
|
|
110
|
+
|
|
111
|
+
const isSupportedJwtAlgorithm = (
|
|
112
|
+
algorithm: string
|
|
113
|
+
): algorithm is JwtAlgorithm =>
|
|
114
|
+
(SUPPORTED_JWT_ALGORITHMS as readonly string[]).includes(algorithm);
|
|
115
|
+
|
|
116
|
+
const validateHeader = (
|
|
117
|
+
header: JwtHeader,
|
|
118
|
+
options: JwtAdapterOptions
|
|
119
|
+
): Result<JwtAlgorithm, AuthError> => {
|
|
120
|
+
if (typeof header.alg !== 'string') {
|
|
121
|
+
return authErr('invalid_token', 'Missing JWT alg header');
|
|
122
|
+
}
|
|
123
|
+
if (!isSupportedJwtAlgorithm(header.alg)) {
|
|
124
|
+
return authErr('invalid_token', 'Unsupported JWT alg header');
|
|
125
|
+
}
|
|
126
|
+
const configuredAlgorithms = allowedAlgorithms(options);
|
|
127
|
+
if (configuredAlgorithms.length === 0) {
|
|
128
|
+
return authErr(
|
|
129
|
+
'invalid_token',
|
|
130
|
+
'JWT allowedAlgorithms must include at least one algorithm'
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (!configuredAlgorithms.includes(header.alg)) {
|
|
134
|
+
return authErr('invalid_token', 'Unsupported JWT alg header');
|
|
135
|
+
}
|
|
136
|
+
return Result.ok(header.alg);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** Import a secret as an HMAC CryptoKey. */
|
|
140
|
+
const importHmacKey = (secret: string): Promise<CryptoKey> => {
|
|
141
|
+
const encoder = new TextEncoder();
|
|
142
|
+
return crypto.subtle.importKey(
|
|
143
|
+
'raw',
|
|
144
|
+
encoder.encode(secret),
|
|
145
|
+
{ hash: 'SHA-256', name: 'HMAC' },
|
|
146
|
+
false,
|
|
147
|
+
['verify']
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** Verify the HMAC-SHA256 signature of a JWT. */
|
|
152
|
+
const verifyHmacSignature = (
|
|
153
|
+
token: string,
|
|
154
|
+
key: CryptoKey
|
|
155
|
+
): Promise<boolean> => {
|
|
156
|
+
const lastDot = token.lastIndexOf('.');
|
|
157
|
+
if (lastDot === -1) {
|
|
158
|
+
return Promise.resolve(false);
|
|
159
|
+
}
|
|
160
|
+
const data = token.slice(0, lastDot);
|
|
161
|
+
const signature = base64urlDecode(token.slice(lastDot + 1));
|
|
162
|
+
const encoder = new TextEncoder();
|
|
163
|
+
return crypto.subtle.verify(
|
|
164
|
+
'HMAC',
|
|
165
|
+
key,
|
|
166
|
+
signature.buffer as ArrayBuffer,
|
|
167
|
+
encoder.encode(data)
|
|
168
|
+
);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const verifyJwtSignature = async (
|
|
172
|
+
token: string,
|
|
173
|
+
secret: string,
|
|
174
|
+
algorithm: JwtAlgorithm
|
|
175
|
+
): Promise<boolean> => {
|
|
176
|
+
switch (algorithm) {
|
|
177
|
+
case 'HS256': {
|
|
178
|
+
const key = await importHmacKey(secret);
|
|
179
|
+
return await verifyHmacSignature(token, key);
|
|
180
|
+
}
|
|
181
|
+
default: {
|
|
182
|
+
const exhaustive: never = algorithm;
|
|
183
|
+
void exhaustive;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/** Validate standard claims (exp, iss, aud). */
|
|
190
|
+
const validateClaims = (
|
|
191
|
+
payload: JwtPayload,
|
|
192
|
+
options: JwtAdapterOptions
|
|
193
|
+
): AuthError | undefined => {
|
|
194
|
+
const now = Math.floor(Date.now() / 1000);
|
|
195
|
+
const skew = normalizeClockSkewSeconds(options);
|
|
196
|
+
const hasExpirationClaim = payload.exp !== undefined && payload.exp !== null;
|
|
197
|
+
if (!hasExpirationClaim && options.requireExpiration !== false) {
|
|
198
|
+
return { code: 'invalid_token', message: 'Missing expiration claim (exp)' };
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
payload.exp !== undefined &&
|
|
202
|
+
(typeof payload.exp !== 'number' || !Number.isFinite(payload.exp))
|
|
203
|
+
) {
|
|
204
|
+
return { code: 'invalid_token', message: 'Invalid expiration claim (exp)' };
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
payload.exp !== undefined &&
|
|
208
|
+
payload.exp !== null &&
|
|
209
|
+
payload.exp < now - skew
|
|
210
|
+
) {
|
|
211
|
+
return { code: 'expired_token', message: 'Token has expired' };
|
|
212
|
+
}
|
|
213
|
+
if (payload.nbf !== undefined) {
|
|
214
|
+
if (typeof payload.nbf !== 'number' || !Number.isFinite(payload.nbf)) {
|
|
215
|
+
return {
|
|
216
|
+
code: 'invalid_token',
|
|
217
|
+
message: 'Invalid not-before claim (nbf)',
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
if (payload.nbf > now + skew) {
|
|
221
|
+
return { code: 'invalid_token', message: 'Token is not valid yet' };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (options.issuer && payload.iss !== options.issuer) {
|
|
225
|
+
return { code: 'invalid_token', message: 'Issuer mismatch' };
|
|
226
|
+
}
|
|
227
|
+
if (options.audience) {
|
|
228
|
+
const { aud } = payload;
|
|
229
|
+
const matches = Array.isArray(aud)
|
|
230
|
+
? aud.includes(options.audience)
|
|
231
|
+
: aud === options.audience;
|
|
232
|
+
if (!matches) {
|
|
233
|
+
return { code: 'invalid_token', message: 'Audience mismatch' };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/** Extract scopes from a payload claim (space-separated string or array). */
|
|
240
|
+
const extractScopes = (
|
|
241
|
+
payload: JwtPayload,
|
|
242
|
+
claim: string
|
|
243
|
+
): readonly string[] => {
|
|
244
|
+
const raw = payload[claim];
|
|
245
|
+
if (typeof raw === 'string') {
|
|
246
|
+
return raw.split(' ').filter(Boolean);
|
|
247
|
+
}
|
|
248
|
+
if (Array.isArray(raw)) {
|
|
249
|
+
return raw.filter(
|
|
250
|
+
(s): s is string => typeof s === 'string' && s.length > 0
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return [];
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/** Extract roles from a payload claim (string array). */
|
|
257
|
+
const extractRoles = (
|
|
258
|
+
payload: JwtPayload,
|
|
259
|
+
claim: string
|
|
260
|
+
): readonly string[] | undefined => {
|
|
261
|
+
const raw = payload[claim];
|
|
262
|
+
if (!Array.isArray(raw)) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
return raw.filter((r): r is string => typeof r === 'string');
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
/** Build a Permit from a validated JWT payload. */
|
|
269
|
+
const buildPermit = (
|
|
270
|
+
payload: JwtPayload,
|
|
271
|
+
options: JwtAdapterOptions
|
|
272
|
+
): Result<Permit, AuthError> => {
|
|
273
|
+
if (!payload.sub) {
|
|
274
|
+
return authErr('invalid_token', 'Missing subject claim (sub)');
|
|
275
|
+
}
|
|
276
|
+
const roles = extractRoles(payload, options.rolesClaim ?? 'roles');
|
|
277
|
+
return Result.ok({
|
|
278
|
+
id: payload.sub,
|
|
279
|
+
scopes: extractScopes(payload, options.scopesClaim ?? 'scope'),
|
|
280
|
+
...(roles ? { roles } : {}),
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** Verify the signature and return the decoded payload, or an error. */
|
|
285
|
+
const decodeAndVerify = async (
|
|
286
|
+
token: string,
|
|
287
|
+
secret: string,
|
|
288
|
+
options: JwtAdapterOptions
|
|
289
|
+
): Promise<Result<JwtPayload, AuthError>> => {
|
|
290
|
+
const parts = splitToken(token);
|
|
291
|
+
if (!parts) {
|
|
292
|
+
return authErr('invalid_token', 'Malformed JWT');
|
|
293
|
+
}
|
|
294
|
+
const [rawHeader, rawPayload] = parts;
|
|
295
|
+
const header = decodeJsonPart<JwtHeader>(rawHeader);
|
|
296
|
+
if (!header) {
|
|
297
|
+
return authErr('invalid_token', 'Malformed JWT header');
|
|
298
|
+
}
|
|
299
|
+
const headerResult = validateHeader(header, options);
|
|
300
|
+
if (headerResult.isErr()) {
|
|
301
|
+
return headerResult;
|
|
302
|
+
}
|
|
303
|
+
const payload = decodeJsonPart<JwtPayload>(rawPayload);
|
|
304
|
+
if (!payload) {
|
|
305
|
+
return authErr('invalid_token', 'Malformed JWT');
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const valid = await verifyJwtSignature(token, secret, headerResult.value);
|
|
309
|
+
return valid
|
|
310
|
+
? Result.ok(payload)
|
|
311
|
+
: authErr('invalid_token', 'Invalid signature');
|
|
312
|
+
} catch {
|
|
313
|
+
return authErr('invalid_token', 'Malformed token signature');
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/** Validate claims and build a permit from a verified payload. */
|
|
318
|
+
const payloadToPermit = (
|
|
319
|
+
payload: JwtPayload,
|
|
320
|
+
options: JwtAdapterOptions
|
|
321
|
+
): Result<Permit, AuthError> => {
|
|
322
|
+
const claimError = validateClaims(payload, options);
|
|
323
|
+
if (claimError) {
|
|
324
|
+
return Result.err(claimError);
|
|
325
|
+
}
|
|
326
|
+
return buildPermit(payload, options);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Factory
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Create a JWT auth adapter using Bun's native crypto.
|
|
335
|
+
*
|
|
336
|
+
* Verifies HS256-signed JWTs, extracts claims into a Permit, and checks
|
|
337
|
+
* issuer/audience when configured. Returns `Result.ok(null)` when no
|
|
338
|
+
* credentials are provided.
|
|
339
|
+
*/
|
|
340
|
+
export const createJwtAdapter = (options: JwtAdapterOptions): AuthAdapter => {
|
|
341
|
+
const authenticate = async (
|
|
342
|
+
input: PermitExtractionInput
|
|
343
|
+
): Promise<Result<Permit | null, AuthError>> => {
|
|
344
|
+
if (!input.bearerToken) {
|
|
345
|
+
return Result.ok(null);
|
|
346
|
+
}
|
|
347
|
+
if (!options.secret) {
|
|
348
|
+
return authErr('invalid_token', 'No secret configured');
|
|
349
|
+
}
|
|
350
|
+
const decoded = await decodeAndVerify(
|
|
351
|
+
input.bearerToken,
|
|
352
|
+
options.secret,
|
|
353
|
+
options
|
|
354
|
+
);
|
|
355
|
+
return decoded.isErr() ? decoded : payloadToPermit(decoded.value, options);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
return { authenticate };
|
|
359
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Result, resource } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
import type { AuthAdapter } from './adapters/adapter.js';
|
|
5
|
+
import { createJwtAdapter } from './adapters/jwt.js';
|
|
6
|
+
import type { JwtAdapterOptions } from './adapters/jwt.js';
|
|
7
|
+
|
|
8
|
+
const authNoneConfigSchema = z
|
|
9
|
+
.object({
|
|
10
|
+
adapter: z.literal('none'),
|
|
11
|
+
})
|
|
12
|
+
.readonly();
|
|
13
|
+
|
|
14
|
+
const authJwtConfigSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
adapter: z.literal('jwt'),
|
|
17
|
+
allowedAlgorithms: z.array(z.literal('HS256')).readonly().optional(),
|
|
18
|
+
audience: z.string().optional(),
|
|
19
|
+
clockSkewSeconds: z.number().int().nonnegative().optional(),
|
|
20
|
+
issuer: z.string().optional(),
|
|
21
|
+
requireExpiration: z.boolean().optional(),
|
|
22
|
+
rolesClaim: z.string().min(1).optional(),
|
|
23
|
+
scopesClaim: z.string().min(1).optional(),
|
|
24
|
+
secret: z.string().min(1),
|
|
25
|
+
})
|
|
26
|
+
.strict()
|
|
27
|
+
.readonly();
|
|
28
|
+
|
|
29
|
+
export const authResourceConfigSchema = z
|
|
30
|
+
.discriminatedUnion('adapter', [authNoneConfigSchema, authJwtConfigSchema])
|
|
31
|
+
.default({ adapter: 'none' });
|
|
32
|
+
|
|
33
|
+
export type AuthResourceConfig = z.infer<typeof authResourceConfigSchema>;
|
|
34
|
+
|
|
35
|
+
const createNoopAdapter = (): AuthAdapter => ({
|
|
36
|
+
// oxlint-disable-next-line require-await -- no-op adapter satisfies async interface
|
|
37
|
+
authenticate: async () => Result.ok(null),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const createAdapter = (config: AuthResourceConfig): AuthAdapter => {
|
|
41
|
+
switch (config.adapter) {
|
|
42
|
+
case 'none': {
|
|
43
|
+
return createNoopAdapter();
|
|
44
|
+
}
|
|
45
|
+
case 'jwt': {
|
|
46
|
+
const jwtOptions: JwtAdapterOptions = {
|
|
47
|
+
...(config.allowedAlgorithms === undefined
|
|
48
|
+
? {}
|
|
49
|
+
: { allowedAlgorithms: config.allowedAlgorithms }),
|
|
50
|
+
...(config.audience === undefined ? {} : { audience: config.audience }),
|
|
51
|
+
...(config.clockSkewSeconds === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: { clockSkewSeconds: config.clockSkewSeconds }),
|
|
54
|
+
...(config.issuer === undefined ? {} : { issuer: config.issuer }),
|
|
55
|
+
...(config.requireExpiration === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { requireExpiration: config.requireExpiration }),
|
|
58
|
+
...(config.rolesClaim === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { rolesClaim: config.rolesClaim }),
|
|
61
|
+
...(config.scopesClaim === undefined
|
|
62
|
+
? {}
|
|
63
|
+
: { scopesClaim: config.scopesClaim }),
|
|
64
|
+
secret: config.secret,
|
|
65
|
+
};
|
|
66
|
+
return createJwtAdapter(jwtOptions);
|
|
67
|
+
}
|
|
68
|
+
default: {
|
|
69
|
+
const exhaustive: never = config;
|
|
70
|
+
void exhaustive;
|
|
71
|
+
return createNoopAdapter();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Auth resource — manages the auth adapter lifecycle.
|
|
78
|
+
*
|
|
79
|
+
* Defaults to a no-op adapter that always succeeds with a null permit, and
|
|
80
|
+
* can be configured through `ResourceSpec.config` to materialize built-in
|
|
81
|
+
* adapters such as JWT.
|
|
82
|
+
*/
|
|
83
|
+
export const authResource = resource<AuthAdapter>('auth', {
|
|
84
|
+
config: authResourceConfigSchema,
|
|
85
|
+
create: (resourceCtx) =>
|
|
86
|
+
Result.ok(createAdapter(resourceCtx.config as AuthResourceConfig)),
|
|
87
|
+
description: 'Authentication adapter',
|
|
88
|
+
meta: { category: 'infrastructure' },
|
|
89
|
+
mock: createNoopAdapter,
|
|
90
|
+
});
|
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyResource,
|
|
3
|
+
BasePermit,
|
|
4
|
+
ResourceOverrideMap,
|
|
5
|
+
SurfaceConfigValues,
|
|
6
|
+
Topo,
|
|
7
|
+
} from '@ontrails/core';
|
|
8
|
+
import {
|
|
9
|
+
AuthError,
|
|
10
|
+
Result,
|
|
11
|
+
ValidationError,
|
|
12
|
+
basePermitSchema,
|
|
13
|
+
resolveResourceConfig,
|
|
14
|
+
} from '@ontrails/core';
|
|
15
|
+
|
|
16
|
+
import type { AuthAdapter } from './adapters/adapter.js';
|
|
17
|
+
import { authAdapterSchema, authErrorSchema } from './adapters/adapter.js';
|
|
18
|
+
import type { PermitExtractionInput } from './extraction.js';
|
|
19
|
+
import { permitExtractionInputSchema } from './extraction.js';
|
|
20
|
+
|
|
21
|
+
/** Resource id of the auth adapter resource provided by `@ontrails/permits`. */
|
|
22
|
+
export const AUTH_RESOURCE_ID = 'auth';
|
|
23
|
+
|
|
24
|
+
type LocatedAuthResource =
|
|
25
|
+
| { readonly kind: 'override'; readonly value: unknown }
|
|
26
|
+
| { readonly kind: 'declared'; readonly resource: AnyResource };
|
|
27
|
+
|
|
28
|
+
export interface ResolvePermitFromBearerTokenOptions {
|
|
29
|
+
readonly bearerToken: string;
|
|
30
|
+
readonly graph: Topo;
|
|
31
|
+
readonly requestId: string;
|
|
32
|
+
readonly surface: PermitExtractionInput['surface'];
|
|
33
|
+
readonly resources?: ResourceOverrideMap | undefined;
|
|
34
|
+
readonly configValues?: SurfaceConfigValues | undefined;
|
|
35
|
+
readonly headers?: Headers | undefined;
|
|
36
|
+
readonly sessionId?: string | undefined;
|
|
37
|
+
readonly cwd?: string | undefined;
|
|
38
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
39
|
+
readonly workspaceRoot?: string | undefined;
|
|
40
|
+
readonly missingAuthResourceMessage?: string | undefined;
|
|
41
|
+
readonly nullPermitMessage?: string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Resolve the auth resource override or registered resource on the topo. */
|
|
45
|
+
const lookupAuthResource = (
|
|
46
|
+
graph: Topo,
|
|
47
|
+
resources: ResourceOverrideMap | undefined,
|
|
48
|
+
missingAuthResourceMessage: string | undefined
|
|
49
|
+
): Result<LocatedAuthResource, ValidationError> => {
|
|
50
|
+
if (resources !== undefined && Object.hasOwn(resources, AUTH_RESOURCE_ID)) {
|
|
51
|
+
return Result.ok({
|
|
52
|
+
kind: 'override',
|
|
53
|
+
value: resources[AUTH_RESOURCE_ID],
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const declared = graph.getResource(AUTH_RESOURCE_ID);
|
|
57
|
+
if (declared !== undefined) {
|
|
58
|
+
return Result.ok({ kind: 'declared', resource: declared });
|
|
59
|
+
}
|
|
60
|
+
return Result.err(
|
|
61
|
+
new ValidationError(
|
|
62
|
+
missingAuthResourceMessage ??
|
|
63
|
+
'Bearer token auth requires an auth adapter. Register authResource from @ontrails/permits in your topo.'
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Materialize the auth adapter from an override or by invoking the declared
|
|
70
|
+
* resource's `create()` factory.
|
|
71
|
+
*/
|
|
72
|
+
const materializeAuthAdapter = async (
|
|
73
|
+
resolved: LocatedAuthResource,
|
|
74
|
+
options: Pick<
|
|
75
|
+
ResolvePermitFromBearerTokenOptions,
|
|
76
|
+
'configValues' | 'cwd' | 'env' | 'workspaceRoot'
|
|
77
|
+
>
|
|
78
|
+
): Promise<Result<AuthAdapter, Error>> => {
|
|
79
|
+
if (resolved.kind === 'override') {
|
|
80
|
+
const parsed = authAdapterSchema.safeParse(resolved.value);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
return Result.err(
|
|
83
|
+
new ValidationError(
|
|
84
|
+
'Override for resource "auth" does not expose an authenticate() function.'
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return Result.ok(resolved.value as AuthAdapter);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const cwd = options.cwd ?? process.cwd();
|
|
92
|
+
const configResult = resolveResourceConfig(
|
|
93
|
+
resolved.resource,
|
|
94
|
+
options.configValues
|
|
95
|
+
);
|
|
96
|
+
if (configResult.isErr()) {
|
|
97
|
+
return configResult;
|
|
98
|
+
}
|
|
99
|
+
const created = await resolved.resource.create({
|
|
100
|
+
config: configResult.value,
|
|
101
|
+
cwd,
|
|
102
|
+
env: options.env ?? {},
|
|
103
|
+
workspaceRoot: options.workspaceRoot ?? cwd,
|
|
104
|
+
});
|
|
105
|
+
if (created.isErr()) {
|
|
106
|
+
return created;
|
|
107
|
+
}
|
|
108
|
+
const parsed = authAdapterSchema.safeParse(created.value);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
return Result.err(
|
|
111
|
+
new ValidationError(
|
|
112
|
+
'Auth resource factory returned a value without an authenticate() function.'
|
|
113
|
+
)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return Result.ok(created.value as AuthAdapter);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve a surface-extracted bearer token to a `BasePermit`.
|
|
121
|
+
*
|
|
122
|
+
* Surfaces own credential extraction. This helper owns the shared auth
|
|
123
|
+
* adapter lookup, invocation, error normalization, and BasePermit rendering.
|
|
124
|
+
*/
|
|
125
|
+
export const resolvePermitFromBearerToken = async (
|
|
126
|
+
options: ResolvePermitFromBearerTokenOptions
|
|
127
|
+
): Promise<Result<BasePermit, Error>> => {
|
|
128
|
+
const located = lookupAuthResource(
|
|
129
|
+
options.graph,
|
|
130
|
+
options.resources,
|
|
131
|
+
options.missingAuthResourceMessage
|
|
132
|
+
);
|
|
133
|
+
if (located.isErr()) {
|
|
134
|
+
return located;
|
|
135
|
+
}
|
|
136
|
+
const adapterResult = await materializeAuthAdapter(located.value, options);
|
|
137
|
+
if (adapterResult.isErr()) {
|
|
138
|
+
return adapterResult;
|
|
139
|
+
}
|
|
140
|
+
const inputResult = permitExtractionInputSchema.safeParse({
|
|
141
|
+
bearerToken: options.bearerToken,
|
|
142
|
+
...(options.headers === undefined ? {} : { headers: options.headers }),
|
|
143
|
+
requestId: options.requestId,
|
|
144
|
+
...(options.sessionId === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: { sessionId: options.sessionId }),
|
|
147
|
+
surface: options.surface,
|
|
148
|
+
});
|
|
149
|
+
if (!inputResult.success) {
|
|
150
|
+
return Result.err(
|
|
151
|
+
new ValidationError('Invalid bearer token extraction input.', {
|
|
152
|
+
context: { issues: inputResult.error.issues },
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
let authResult: Awaited<ReturnType<AuthAdapter['authenticate']>>;
|
|
157
|
+
try {
|
|
158
|
+
authResult = await adapterResult.value.authenticate(inputResult.data);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
const errorOptions =
|
|
161
|
+
error instanceof Error
|
|
162
|
+
? { cause: error, context: { code: 'invalid_token' } }
|
|
163
|
+
: { context: { code: 'invalid_token' } };
|
|
164
|
+
return Result.err(
|
|
165
|
+
new AuthError('Auth adapter threw while authenticating bearer token', {
|
|
166
|
+
...errorOptions,
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (authResult.isErr()) {
|
|
171
|
+
const parsedError = authErrorSchema.safeParse(authResult.error);
|
|
172
|
+
const { code, message } = parsedError.success
|
|
173
|
+
? parsedError.data
|
|
174
|
+
: {
|
|
175
|
+
code: 'invalid_token' as const,
|
|
176
|
+
message: 'Auth adapter returned a malformed error',
|
|
177
|
+
};
|
|
178
|
+
return Result.err(new AuthError(message, { context: { code } }));
|
|
179
|
+
}
|
|
180
|
+
if (authResult.value === null) {
|
|
181
|
+
return Result.err(
|
|
182
|
+
new AuthError(
|
|
183
|
+
options.nullPermitMessage ??
|
|
184
|
+
'Auth adapter did not produce a permit for bearer token',
|
|
185
|
+
{
|
|
186
|
+
context: { code: 'missing_credentials' },
|
|
187
|
+
}
|
|
188
|
+
)
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const permit = basePermitSchema.safeParse(authResult.value);
|
|
192
|
+
if (!permit.success) {
|
|
193
|
+
return Result.err(
|
|
194
|
+
new AuthError('Auth adapter returned a malformed permit', {
|
|
195
|
+
context: { code: 'invalid_token', issues: permit.error.issues },
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return Result.ok(permit.data);
|
|
200
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PermitError } from '@ontrails/core';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const permitExtractionInputSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
/** Bearer token from Authorization header or equivalent. */
|
|
6
|
+
bearerToken: z.string().optional(),
|
|
7
|
+
/** Raw headers (HTTP surface only, typically). */
|
|
8
|
+
headers: z.instanceof(Headers).optional(),
|
|
9
|
+
/** Correlation ID for tracing. */
|
|
10
|
+
requestId: z.string(),
|
|
11
|
+
/** Session identifier from transport handshake. */
|
|
12
|
+
sessionId: z.string().optional(),
|
|
13
|
+
/** Which surface produced this extraction. */
|
|
14
|
+
surface: z.enum(['http', 'mcp', 'cli']),
|
|
15
|
+
})
|
|
16
|
+
.readonly();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Normalized input for auth adapters.
|
|
20
|
+
*
|
|
21
|
+
* Each surface extracts raw credentials from its transport and normalizes them
|
|
22
|
+
* into this shape. No surface types (Request, McpSession, etc.) cross into
|
|
23
|
+
* core -- only this schema-derived contract.
|
|
24
|
+
*/
|
|
25
|
+
export type PermitExtractionInput = z.infer<typeof permitExtractionInputSchema>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export {
|
|
2
|
+
authAdapterSchema,
|
|
3
|
+
authErrorSchema,
|
|
4
|
+
type AuthAdapter,
|
|
5
|
+
type AuthError,
|
|
6
|
+
} from './adapters/adapter.js';
|
|
7
|
+
export {
|
|
8
|
+
createJwtAdapter,
|
|
9
|
+
type JwtAlgorithm,
|
|
10
|
+
type JwtAdapterOptions,
|
|
11
|
+
} from './adapters/jwt.js';
|
|
12
|
+
export {
|
|
13
|
+
authResource,
|
|
14
|
+
authResourceConfigSchema,
|
|
15
|
+
type AuthResourceConfig,
|
|
16
|
+
} from './auth-resource.js';
|
|
17
|
+
export {
|
|
18
|
+
AUTH_RESOURCE_ID,
|
|
19
|
+
resolvePermitFromBearerToken,
|
|
20
|
+
type ResolvePermitFromBearerTokenOptions,
|
|
21
|
+
} from './boundary.js';
|
|
22
|
+
export { authVerify } from './trails/auth-verify.js';
|
|
23
|
+
export { PermitError } from './errors.js';
|
|
24
|
+
export {
|
|
25
|
+
permitExtractionInputSchema,
|
|
26
|
+
type PermitExtractionInput,
|
|
27
|
+
} from './extraction.js';
|
|
28
|
+
export { type Permit, getPermit } from './permit.js';
|
|
29
|
+
export {
|
|
30
|
+
validatePermits,
|
|
31
|
+
type PermitDiagnostic,
|
|
32
|
+
type PermitDiagnosticSeverity,
|
|
33
|
+
} from './rules.js';
|