@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +35 -27
  3. package/package.json +12 -3
  4. package/src/adapters/adapter.ts +41 -0
  5. package/src/{connectors → adapters}/jwt.ts +148 -21
  6. package/src/auth-resource.ts +89 -0
  7. package/src/boundary.ts +200 -0
  8. package/src/errors.ts +1 -18
  9. package/src/extraction.ts +22 -16
  10. package/src/index.ts +22 -10
  11. package/src/permit.ts +2 -2
  12. package/src/rules.ts +2 -2
  13. package/src/testing.ts +5 -5
  14. package/src/trails/auth-verify.ts +13 -15
  15. package/.turbo/turbo-build.log +0 -1
  16. package/.turbo/turbo-lint.log +0 -3
  17. package/.turbo/turbo-typecheck.log +0 -1
  18. package/dist/adapter.d.ts +0 -26
  19. package/dist/adapter.d.ts.map +0 -1
  20. package/dist/adapter.js +0 -2
  21. package/dist/adapter.js.map +0 -1
  22. package/dist/adapters/jwt.d.ts +0 -25
  23. package/dist/adapters/jwt.d.ts.map +0 -1
  24. package/dist/adapters/jwt.js +0 -148
  25. package/dist/adapters/jwt.js.map +0 -1
  26. package/dist/auth-gate.d.ts +0 -18
  27. package/dist/auth-gate.d.ts.map +0 -1
  28. package/dist/auth-gate.js +0 -56
  29. package/dist/auth-gate.js.map +0 -1
  30. package/dist/auth-layer.d.ts +0 -18
  31. package/dist/auth-layer.d.ts.map +0 -1
  32. package/dist/auth-layer.js +0 -56
  33. package/dist/auth-layer.js.map +0 -1
  34. package/dist/auth-provision.d.ts +0 -11
  35. package/dist/auth-provision.d.ts.map +0 -1
  36. package/dist/auth-provision.js +0 -22
  37. package/dist/auth-provision.js.map +0 -1
  38. package/dist/auth-service.d.ts +0 -10
  39. package/dist/auth-service.d.ts.map +0 -1
  40. package/dist/auth-service.js +0 -21
  41. package/dist/auth-service.js.map +0 -1
  42. package/dist/connectors/connector.d.ts +0 -26
  43. package/dist/connectors/connector.d.ts.map +0 -1
  44. package/dist/connectors/connector.js +0 -2
  45. package/dist/connectors/connector.js.map +0 -1
  46. package/dist/connectors/jwt.d.ts +0 -25
  47. package/dist/connectors/jwt.d.ts.map +0 -1
  48. package/dist/connectors/jwt.js +0 -148
  49. package/dist/connectors/jwt.js.map +0 -1
  50. package/dist/errors.d.ts +0 -15
  51. package/dist/errors.d.ts.map +0 -1
  52. package/dist/errors.js +0 -15
  53. package/dist/errors.js.map +0 -1
  54. package/dist/extraction.d.ts +0 -20
  55. package/dist/extraction.d.ts.map +0 -1
  56. package/dist/extraction.js +0 -2
  57. package/dist/extraction.js.map +0 -1
  58. package/dist/index.d.ts +0 -11
  59. package/dist/index.d.ts.map +0 -1
  60. package/dist/index.js +0 -11
  61. package/dist/index.js.map +0 -1
  62. package/dist/permit.d.ts +0 -26
  63. package/dist/permit.d.ts.map +0 -1
  64. package/dist/permit.js +0 -17
  65. package/dist/permit.js.map +0 -1
  66. package/dist/rules.d.ts +0 -47
  67. package/dist/rules.d.ts.map +0 -1
  68. package/dist/rules.js +0 -127
  69. package/dist/rules.js.map +0 -1
  70. package/dist/testing.d.ts +0 -20
  71. package/dist/testing.d.ts.map +0 -1
  72. package/dist/testing.js +0 -22
  73. package/dist/testing.js.map +0 -1
  74. package/dist/trails/auth-verify.d.ts +0 -22
  75. package/dist/trails/auth-verify.d.ts.map +0 -1
  76. package/dist/trails/auth-verify.js +0 -85
  77. package/dist/trails/auth-verify.js.map +0 -1
  78. package/src/__tests__/auth-gate.test.ts +0 -130
  79. package/src/__tests__/auth-provision.test.ts +0 -62
  80. package/src/__tests__/auth-verify.test.ts +0 -278
  81. package/src/__tests__/connector.test.ts +0 -338
  82. package/src/__tests__/permit.test.ts +0 -122
  83. package/src/__tests__/rules.test.ts +0 -239
  84. package/src/__tests__/testing.test.ts +0 -57
  85. package/src/auth-gate.ts +0 -80
  86. package/src/auth-provision.ts +0 -26
  87. package/src/connectors/connector.ts +0 -35
  88. package/tsconfig.json +0 -9
  89. package/tsconfig.tsbuildinfo +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # @ontrails/permits
2
2
 
3
+ ## 1.0.0-beta.16
4
+
5
+ ### Major Changes
6
+
7
+ - 200bece: BREAKING: rename auth connector vocabulary to adapter.
8
+
9
+ This stays on the current `1.0.0-beta` prerelease line: the package is part of
10
+ the fixed `@ontrails/*` beta group, so beta-breaking API renames advance the
11
+ next beta rather than opening a stable-major release line.
12
+
13
+ - `AuthConnector` -> `AuthAdapter`
14
+ - `authConnectorSchema` -> `authAdapterSchema`
15
+ - `JwtConnectorOptions` -> `JwtAdapterOptions`
16
+ - `createJwtConnector` -> `createJwtAdapter`
17
+ - auth resource config discriminant `{ connector: 'jwt' | 'none' }` -> `{ adapter: 'jwt' | 'none' }`
18
+
19
+ The `@ontrails/permits/jwt` subpath is unchanged. The internal `connectors/`
20
+ source directory becomes `adapters/`. See
21
+ `docs/migration/connector-to-adapter.md` for the full rename map.
22
+
23
+ The Trails CLI package updates its generated auth-resource configuration to use
24
+ the new `adapter` discriminant.
25
+
26
+ ### Minor Changes
27
+
28
+ - 73622ae: Thread `ResourceSpec.config` through the built-in auth resource. Resource config schemas that accept `undefined` now receive their parsed default when config values are omitted, and `authResource` can materialize the no-op or JWT adapter from typed config while preserving existing mock and override paths.
29
+ - 4b8d13b: **BREAKING:** Remove the deprecated `AuthCredentials` alias from the permits public API.
30
+
31
+ Use `PermitExtractionInput` instead. See `docs/migration/trailhead-to-surface.md` for the full migration map.
32
+
33
+ - 66056ac: **BREAKING:** TRL-475 drops user-facing exports of `authLayer`, `autoIterateLayer`, and `dateShortcutsLayer`. Breaking change for any app still wiring these layers manually.
34
+
35
+ Migration:
36
+
37
+ - **`autoIterateLayer`** — remove from `blaze`/`run`/`surface` options. The CLI surface now derives the `--all` flag and multi-page collection automatically from any trail whose output matches the pagination pattern (`items`, `hasMore`, `nextCursor`). See TRL-469.
38
+ - **`dateShortcutsLayer`** — remove from `blaze`/`run`/`surface` options. The CLI surface now expands `since`/`until` shortcut strings (`today`, `yesterday`, `7d`, `30d`, `this-week`, `this-month`) automatically from input schema shape. See TRL-470.
39
+ - **`authLayer`** — remove from `blaze`/`run`/`surface` options. Permit scope enforcement is intrinsic to `executeTrail` (`enforcePermitRequirement` runs before resource creation and layer composition). The compatibility shim was already a no-op.
40
+
41
+ The `Layer` type, `composeLayers`, and canonical per-call `executeTrail({ layers })` option remain available; only the legacy layer exports were removed.
42
+
43
+ ### Patch Changes
44
+
45
+ - 199304e: Harden JWT permit validation by requiring `exp` by default, validating the
46
+ header algorithm allowlist before signature verification, and enforcing finite
47
+ clock skew for `exp` and `nbf` checks.
48
+ - e4beec9: Document `@ontrails/permits/jwt` as the canonical JWT adapter import while keeping root JWT re-exports as intentional convenience exports.
49
+ - Updated dependencies [73622ae]
50
+ - Updated dependencies [6300f70]
51
+ - Updated dependencies [d172013]
52
+ - Updated dependencies [c3fc5c3]
53
+ - Updated dependencies [20d7a5c]
54
+ - Updated dependencies [be5fb46]
55
+ - Updated dependencies [e898cc4]
56
+ - Updated dependencies [3395234]
57
+ - Updated dependencies [bcdc484]
58
+ - Updated dependencies [331e3a9]
59
+ - Updated dependencies [4399fdb]
60
+ - Updated dependencies [4b8d13b]
61
+ - Updated dependencies [112b9f2]
62
+ - Updated dependencies [893025e]
63
+ - Updated dependencies [eec5e9d]
64
+ - Updated dependencies [ebd4434]
65
+ - Updated dependencies [863d473]
66
+ - Updated dependencies [344f2f7]
67
+ - Updated dependencies [26f9ffd]
68
+ - Updated dependencies [10eae9a]
69
+ - Updated dependencies [22c6c06]
70
+ - @ontrails/core@1.0.0-beta.16
71
+
72
+ ## 1.0.0-beta.15
73
+
74
+ ### Patch Changes
75
+
76
+ - Updated dependencies [4ad6b25]
77
+ - @ontrails/core@1.0.0-beta.15
78
+
3
79
  ## 1.0.0-beta.14
4
80
 
5
81
  ### Minor Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Scope-based authorization for Trails.
4
4
 
5
- The permits package owns the connector-agnostic `authProvision` and `authGate`. Connector packages bind those declarations to concrete auth logic, just like a trailhead connector binds a topo to CLI, MCP, or HTTP.
5
+ The permits package owns adapter-agnostic auth resources, adapters, and helpers. Core `executeTrail` enforces trail `permit` declarations once a surface has resolved a permit into `ctx.permit`.
6
6
 
7
7
  ## The core pattern
8
8
 
@@ -12,7 +12,7 @@ The permits package owns the connector-agnostic `authProvision` and `authGate`.
12
12
  export const create = trail('gist.create', {
13
13
  permit: { scopes: ['gist:write'] },
14
14
  blaze: async (input, ctx) => {
15
- // authGate enforces scopes before blaze runs
15
+ // executeTrail enforces scopes before blaze runs
16
16
  return Result.ok(newGist);
17
17
  },
18
18
  });
@@ -26,44 +26,44 @@ export const search = trail('gist.search', {
26
26
  });
27
27
  ```
28
28
 
29
- ### 2. Register the auth gate
29
+ ### 2. Resolve a permit at the surface
30
30
 
31
31
  ```typescript
32
- import { authGate } from '@ontrails/permits';
33
-
34
- export const app = topo('my-app', gistModule);
35
- // Register authGate with your trailhead
32
+ export const graph = topo('my-app', gistModule);
33
+ // Surface auth verifies credentials and passes { permit } into executeTrail.
36
34
  ```
37
35
 
38
- The gate reads each trail's `permit` field:
36
+ The execution pipeline reads each trail's `permit` field:
37
+
38
+ - `'public'` or `undefined` — execution passes through
39
+ - `{ scopes: [...] }` — execution checks that `ctx.permit` contains all required scopes
39
40
 
40
- - `'public'` or `undefined` gate passes through
41
- - `{ scopes: [...] }` — gate checks that `ctx.permit` contains all required scopes
41
+ Permit enforcement is intrinsic to `executeTrail`; there is no opt-in layer to wire. The previously deprecated `authLayer` compatibility wrapper has been removed (TRL-475).
42
42
 
43
- ### 3. Bind a connector at bootstrap
43
+ ### 3. Bind an adapter at bootstrap
44
44
 
45
45
  ```typescript
46
- import { createJwtConnector } from '@ontrails/permits/jwt';
46
+ import { createJwtAdapter } from '@ontrails/permits/jwt';
47
47
 
48
- const connector = createJwtConnector({
48
+ const adapter = createJwtAdapter({
49
49
  secret: process.env.JWT_SECRET,
50
50
  issuer: 'https://auth.example.com',
51
51
  audience: 'api.example.com',
52
52
  });
53
53
  ```
54
54
 
55
- ## Auth connectors
55
+ ## Auth adapters
56
56
 
57
- An auth connector authenticates requests and produces permits.
57
+ An auth adapter authenticates requests and produces permits.
58
58
 
59
- ### Built-in: JWT connector
59
+ ### Built-in: JWT adapter
60
60
 
61
61
  Verifies HS256-signed JWTs and extracts claims into permits:
62
62
 
63
63
  ```typescript
64
- import { createJwtConnector } from '@ontrails/permits/jwt';
64
+ import { createJwtAdapter } from '@ontrails/permits/jwt';
65
65
 
66
- const connector = createJwtConnector({
66
+ const adapter = createJwtAdapter({
67
67
  secret: 'your-hmac-secret',
68
68
  issuer: 'https://auth.example.com',
69
69
  audience: 'api.example.com',
@@ -72,14 +72,19 @@ const connector = createJwtConnector({
72
72
  });
73
73
  ```
74
74
 
75
- ### Custom connectors
75
+ `@ontrails/permits/jwt` is the canonical built-in JWT adapter subpath. The root
76
+ package intentionally keeps convenience re-exports for interactive discovery,
77
+ but generated code, docs, and adapter-specific examples should import JWT names
78
+ from the subpath.
79
+
80
+ ### Custom adapters
76
81
 
77
- Implement the `AuthConnector` interface:
82
+ Implement the `AuthAdapter` interface:
78
83
 
79
84
  ```typescript
80
- import type { AuthConnector, PermitExtractionInput, Permit } from '@ontrails/permits';
85
+ import type { AuthAdapter, PermitExtractionInput, Permit } from '@ontrails/permits';
81
86
 
82
- const myConnector: AuthConnector = {
87
+ const myAdapter: AuthAdapter = {
83
88
  authenticate: async (input: PermitExtractionInput) => {
84
89
  if (!input.bearerToken) return Result.ok(null);
85
90
  const permit: Permit = {
@@ -135,19 +140,22 @@ import { authVerify } from '@ontrails/permits';
135
140
 
136
141
  ## Testing with mock permits
137
142
 
138
- Use `mintTestPermit()` and `mintPermitForTrail()` in tests:
143
+ Use `createTestPermit()` and `createPermitForTrail()` in tests:
139
144
 
140
145
  ```typescript
141
- import { mintTestPermit, mintPermitForTrail } from '@ontrails/permits';
146
+ import {
147
+ createTestPermit,
148
+ createPermitForTrail,
149
+ } from '@ontrails/permits/testing';
142
150
 
143
- const permit = mintTestPermit({
151
+ const permit = createTestPermit({
144
152
  id: 'user-123',
145
153
  scopes: ['gist:read', 'gist:write'],
146
154
  roles: ['editor'],
147
155
  });
148
156
 
149
- // Mint a permit matching a trail's requirements
150
- const trailPermit = mintPermitForTrail(myTrail);
157
+ // Create a permit matching a trail's requirements
158
+ const trailPermit = createPermitForTrail(myTrail);
151
159
  // { id: 'test-...', scopes: ['gist:write'] }
152
160
  ```
153
161
 
package/package.json CHANGED
@@ -1,10 +1,19 @@
1
1
  {
2
2
  "name": "@ontrails/permits",
3
- "version": "1.0.0-beta.14",
3
+ "version": "1.0.0-beta.16",
4
+ "files": [
5
+ "src/**/*.ts",
6
+ "!src/**/__tests__/**",
7
+ "!src/**/*.test.ts",
8
+ "!src/**/*.test-d.ts",
9
+ "README.md",
10
+ "CHANGELOG.md"
11
+ ],
4
12
  "type": "module",
5
13
  "exports": {
6
14
  ".": "./src/index.ts",
7
- "./jwt": "./src/connectors/jwt.ts",
15
+ "./jwt": "./src/adapters/jwt.ts",
16
+ "./testing": "./src/testing.ts",
8
17
  "./package.json": "./package.json"
9
18
  },
10
19
  "scripts": {
@@ -15,7 +24,7 @@
15
24
  "clean": "rm -rf dist *.tsbuildinfo"
16
25
  },
17
26
  "peerDependencies": {
18
- "@ontrails/core": "^1.0.0-beta.13",
27
+ "@ontrails/core": "^1.0.0-beta.15",
19
28
  "zod": "^4.3.5"
20
29
  }
21
30
  }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import type { Result } from '@ontrails/core';
3
+
4
+ import type { PermitExtractionInput } from '../extraction.js';
5
+ import type { Permit } from '../permit.js';
6
+
7
+ /** Errors from auth adapters. */
8
+ export const authErrorSchema = z
9
+ .object({
10
+ code: z.enum([
11
+ 'expired_token',
12
+ 'insufficient_scope',
13
+ 'invalid_token',
14
+ 'missing_credentials',
15
+ ]),
16
+ message: z.string(),
17
+ })
18
+ .readonly();
19
+
20
+ export type AuthError = z.infer<typeof authErrorSchema>;
21
+
22
+ export const authAdapterSchema = z
23
+ .object({
24
+ authenticate: z.function(),
25
+ })
26
+ .readonly();
27
+
28
+ /**
29
+ * Auth adapter port. Given extraction input, produce a permit or an error.
30
+ *
31
+ * The adapter receives the full {@link PermitExtractionInput} — surface,
32
+ * headers, requestId, and credential fields — so it can make richer
33
+ * decisions (e.g., rate-limit by surface or correlate via requestId).
34
+ *
35
+ * Deliberately narrow — no session management, no token refresh.
36
+ */
37
+ export interface AuthAdapter {
38
+ readonly authenticate: (
39
+ input: PermitExtractionInput
40
+ ) => Promise<Result<Permit | null, AuthError>>;
41
+ }
@@ -1,13 +1,19 @@
1
1
  import { Result } from '@ontrails/core';
2
2
 
3
- import type { AuthConnector, AuthError } from './connector.js';
3
+ import type { AuthAdapter, AuthError } from './adapter.js';
4
4
  import type { PermitExtractionInput } from '../extraction.js';
5
5
  import type { Permit } from '../permit.js';
6
6
 
7
- /** Configuration for the JWT auth connector. */
8
- export interface JwtConnectorOptions {
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;
9
13
  /** HMAC secret for HS256 verification. */
10
14
  readonly secret?: string;
15
+ /** Whether accepted tokens must include exp (default: true). */
16
+ readonly requireExpiration?: boolean;
11
17
  /** JWKS endpoint for RS256/ES256 (not yet implemented). */
12
18
  readonly jwksUrl?: string;
13
19
  /** Expected issuer claim. */
@@ -20,12 +26,22 @@ export interface JwtConnectorOptions {
20
26
  readonly rolesClaim?: string;
21
27
  }
22
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
+
23
38
  /** JWT payload with standard claims. */
24
39
  interface JwtPayload {
25
40
  readonly sub?: string;
26
41
  readonly iss?: string;
27
42
  readonly aud?: string | readonly string[];
28
- readonly exp?: number;
43
+ readonly exp?: number | null;
44
+ readonly nbf?: number;
29
45
  readonly [key: string]: unknown;
30
46
  }
31
47
 
@@ -33,6 +49,14 @@ interface JwtPayload {
33
49
  // Helpers (defined before callers)
34
50
  // ---------------------------------------------------------------------------
35
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
+
36
60
  const authErr = (
37
61
  code: AuthError['code'],
38
62
  message: string
@@ -52,20 +76,66 @@ const base64urlDecode = (input: string): Uint8Array => {
52
76
  return bytes;
53
77
  };
54
78
 
55
- /** Decode a JWT payload without verifying the signature. */
56
- const decodePayload = (token: string): JwtPayload | undefined => {
79
+ const splitToken = (
80
+ token: string
81
+ ): readonly [string, string, string] | undefined => {
57
82
  const parts = token.split('.');
58
83
  if (parts.length !== 3) {
59
84
  return undefined;
60
85
  }
86
+ return [parts[0] ?? '', parts[1] ?? '', parts[2] ?? ''];
87
+ };
88
+
89
+ const decodeJsonPart = <T>(part: string): T | undefined => {
61
90
  try {
62
- const json = new TextDecoder().decode(base64urlDecode(parts[1] ?? ''));
63
- return JSON.parse(json) as JwtPayload;
91
+ const json = new TextDecoder().decode(base64urlDecode(part));
92
+ return JSON.parse(json) as T;
64
93
  } catch {
65
94
  return undefined;
66
95
  }
67
96
  };
68
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
+
69
139
  /** Import a secret as an HMAC CryptoKey. */
70
140
  const importHmacKey = (secret: string): Promise<CryptoKey> => {
71
141
  const encoder = new TextEncoder();
@@ -98,17 +168,59 @@ const verifyHmacSignature = (
98
168
  );
99
169
  };
100
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
+
101
189
  /** Validate standard claims (exp, iss, aud). */
102
190
  const validateClaims = (
103
191
  payload: JwtPayload,
104
- options: JwtConnectorOptions
192
+ options: JwtAdapterOptions
105
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
+ }
106
200
  if (
107
201
  payload.exp !== undefined &&
108
- payload.exp < Math.floor(Date.now() / 1000)
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
109
210
  ) {
110
211
  return { code: 'expired_token', message: 'Token has expired' };
111
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
+ }
112
224
  if (options.issuer && payload.iss !== options.issuer) {
113
225
  return { code: 'invalid_token', message: 'Issuer mismatch' };
114
226
  }
@@ -156,7 +268,7 @@ const extractRoles = (
156
268
  /** Build a Permit from a validated JWT payload. */
157
269
  const buildPermit = (
158
270
  payload: JwtPayload,
159
- options: JwtConnectorOptions
271
+ options: JwtAdapterOptions
160
272
  ): Result<Permit, AuthError> => {
161
273
  if (!payload.sub) {
162
274
  return authErr('invalid_token', 'Missing subject claim (sub)');
@@ -172,15 +284,28 @@ const buildPermit = (
172
284
  /** Verify the signature and return the decoded payload, or an error. */
173
285
  const decodeAndVerify = async (
174
286
  token: string,
175
- secret: string
287
+ secret: string,
288
+ options: JwtAdapterOptions
176
289
  ): Promise<Result<JwtPayload, AuthError>> => {
177
- const payload = decodePayload(token);
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);
178
304
  if (!payload) {
179
305
  return authErr('invalid_token', 'Malformed JWT');
180
306
  }
181
307
  try {
182
- const key = await importHmacKey(secret);
183
- const valid = await verifyHmacSignature(token, key);
308
+ const valid = await verifyJwtSignature(token, secret, headerResult.value);
184
309
  return valid
185
310
  ? Result.ok(payload)
186
311
  : authErr('invalid_token', 'Invalid signature');
@@ -192,7 +317,7 @@ const decodeAndVerify = async (
192
317
  /** Validate claims and build a permit from a verified payload. */
193
318
  const payloadToPermit = (
194
319
  payload: JwtPayload,
195
- options: JwtConnectorOptions
320
+ options: JwtAdapterOptions
196
321
  ): Result<Permit, AuthError> => {
197
322
  const claimError = validateClaims(payload, options);
198
323
  if (claimError) {
@@ -206,15 +331,13 @@ const payloadToPermit = (
206
331
  // ---------------------------------------------------------------------------
207
332
 
208
333
  /**
209
- * Create a JWT auth connector using Bun's native crypto.
334
+ * Create a JWT auth adapter using Bun's native crypto.
210
335
  *
211
336
  * Verifies HS256-signed JWTs, extracts claims into a Permit, and checks
212
337
  * issuer/audience when configured. Returns `Result.ok(null)` when no
213
338
  * credentials are provided.
214
339
  */
215
- export const createJwtConnector = (
216
- options: JwtConnectorOptions
217
- ): AuthConnector => {
340
+ export const createJwtAdapter = (options: JwtAdapterOptions): AuthAdapter => {
218
341
  const authenticate = async (
219
342
  input: PermitExtractionInput
220
343
  ): Promise<Result<Permit | null, AuthError>> => {
@@ -224,7 +347,11 @@ export const createJwtConnector = (
224
347
  if (!options.secret) {
225
348
  return authErr('invalid_token', 'No secret configured');
226
349
  }
227
- const decoded = await decodeAndVerify(input.bearerToken, options.secret);
350
+ const decoded = await decodeAndVerify(
351
+ input.bearerToken,
352
+ options.secret,
353
+ options
354
+ );
228
355
  return decoded.isErr() ? decoded : payloadToPermit(decoded.value, options);
229
356
  };
230
357
 
@@ -0,0 +1,89 @@
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: (svc) => Result.ok(createAdapter(svc.config as AuthResourceConfig)),
86
+ description: 'Authentication adapter',
87
+ meta: { category: 'infrastructure' },
88
+ mock: createNoopAdapter,
89
+ });