@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.
@@ -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
+ });
@@ -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';