@ontrails/permits 1.0.0-beta.15 → 1.0.0-beta.17
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 +30 -22
- package/package.json +12 -3
- package/src/adapters/adapter.ts +41 -0
- package/src/{connectors → adapters}/jwt.ts +148 -21
- package/src/auth-resource.ts +81 -18
- package/src/boundary.ts +200 -0
- package/src/errors.ts +1 -18
- package/src/extraction.ts +22 -16
- package/src/index.ts +22 -10
- package/src/trails/auth-verify.ts +11 -13
- package/.turbo/turbo-build.log +0 -1
- package/.turbo/turbo-lint.log +0 -3
- package/.turbo/turbo-typecheck.log +0 -1
- package/dist/auth-layer.d.ts +0 -18
- package/dist/auth-layer.d.ts.map +0 -1
- package/dist/auth-layer.js +0 -56
- package/dist/auth-layer.js.map +0 -1
- package/dist/auth-resource.d.ts +0 -11
- package/dist/auth-resource.d.ts.map +0 -1
- package/dist/auth-resource.js +0 -22
- package/dist/auth-resource.js.map +0 -1
- package/dist/connectors/connector.d.ts +0 -26
- package/dist/connectors/connector.d.ts.map +0 -1
- package/dist/connectors/connector.js +0 -2
- package/dist/connectors/connector.js.map +0 -1
- package/dist/connectors/jwt.d.ts +0 -25
- package/dist/connectors/jwt.d.ts.map +0 -1
- package/dist/connectors/jwt.js +0 -148
- package/dist/connectors/jwt.js.map +0 -1
- package/dist/errors.d.ts +0 -15
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js +0 -15
- package/dist/errors.js.map +0 -1
- package/dist/extraction.d.ts +0 -20
- package/dist/extraction.d.ts.map +0 -1
- package/dist/extraction.js +0 -2
- package/dist/extraction.js.map +0 -1
- package/dist/index.d.ts +0 -11
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -11
- package/dist/index.js.map +0 -1
- package/dist/permit.d.ts +0 -26
- package/dist/permit.d.ts.map +0 -1
- package/dist/permit.js +0 -17
- package/dist/permit.js.map +0 -1
- package/dist/rules.d.ts +0 -47
- package/dist/rules.d.ts.map +0 -1
- package/dist/rules.js +0 -127
- package/dist/rules.js.map +0 -1
- package/dist/testing.d.ts +0 -20
- package/dist/testing.d.ts.map +0 -1
- package/dist/testing.js +0 -22
- package/dist/testing.js.map +0 -1
- package/dist/trails/auth-verify.d.ts +0 -22
- package/dist/trails/auth-verify.d.ts.map +0 -1
- package/dist/trails/auth-verify.js +0 -85
- package/dist/trails/auth-verify.js.map +0 -1
- package/src/__tests__/auth-layer.test.ts +0 -130
- package/src/__tests__/auth-resource.test.ts +0 -62
- package/src/__tests__/auth-verify.test.ts +0 -278
- package/src/__tests__/connector.test.ts +0 -338
- package/src/__tests__/permit.test.ts +0 -122
- package/src/__tests__/rules.test.ts +0 -239
- package/src/__tests__/testing.test.ts +0 -57
- package/src/auth-layer.ts +0 -80
- package/src/connectors/connector.ts +0 -35
- package/tsconfig.json +0 -9
- package/tsconfig.tests.json +0 -10
- package/tsconfig.tsbuildinfo +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,81 @@
|
|
|
1
1
|
# @ontrails/permits
|
|
2
2
|
|
|
3
|
+
## 1.0.0-beta.17
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [3dc8254]
|
|
8
|
+
- @ontrails/core@1.0.0-beta.17
|
|
9
|
+
|
|
10
|
+
## 1.0.0-beta.16
|
|
11
|
+
|
|
12
|
+
### Major Changes
|
|
13
|
+
|
|
14
|
+
- 200bece: BREAKING: rename auth connector vocabulary to adapter.
|
|
15
|
+
|
|
16
|
+
This stays on the current `1.0.0-beta` prerelease line: the package is part of
|
|
17
|
+
the fixed `@ontrails/*` beta group, so beta-breaking API renames advance the
|
|
18
|
+
next beta rather than opening a stable-major release line.
|
|
19
|
+
|
|
20
|
+
- `AuthConnector` -> `AuthAdapter`
|
|
21
|
+
- `authConnectorSchema` -> `authAdapterSchema`
|
|
22
|
+
- `JwtConnectorOptions` -> `JwtAdapterOptions`
|
|
23
|
+
- `createJwtConnector` -> `createJwtAdapter`
|
|
24
|
+
- auth resource config discriminant `{ connector: 'jwt' | 'none' }` -> `{ adapter: 'jwt' | 'none' }`
|
|
25
|
+
|
|
26
|
+
The `@ontrails/permits/jwt` subpath is unchanged. The internal `connectors/`
|
|
27
|
+
source directory becomes `adapters/`. See
|
|
28
|
+
`docs/migration/connector-to-adapter.md` for the full rename map.
|
|
29
|
+
|
|
30
|
+
The Trails CLI package updates its generated auth-resource configuration to use
|
|
31
|
+
the new `adapter` discriminant.
|
|
32
|
+
|
|
33
|
+
### Minor Changes
|
|
34
|
+
|
|
35
|
+
- 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.
|
|
36
|
+
- 4b8d13b: **BREAKING:** Remove the deprecated `AuthCredentials` alias from the permits public API.
|
|
37
|
+
|
|
38
|
+
Use `PermitExtractionInput` instead. See `docs/migration/trailhead-to-surface.md` for the full migration map.
|
|
39
|
+
|
|
40
|
+
- 66056ac: **BREAKING:** TRL-475 drops user-facing exports of `authLayer`, `autoIterateLayer`, and `dateShortcutsLayer`. Breaking change for any app still wiring these layers manually.
|
|
41
|
+
|
|
42
|
+
Migration:
|
|
43
|
+
|
|
44
|
+
- **`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.
|
|
45
|
+
- **`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.
|
|
46
|
+
- **`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.
|
|
47
|
+
|
|
48
|
+
The `Layer` type, `composeLayers`, and canonical per-call `executeTrail({ layers })` option remain available; only the legacy layer exports were removed.
|
|
49
|
+
|
|
50
|
+
### Patch Changes
|
|
51
|
+
|
|
52
|
+
- 199304e: Harden JWT permit validation by requiring `exp` by default, validating the
|
|
53
|
+
header algorithm allowlist before signature verification, and enforcing finite
|
|
54
|
+
clock skew for `exp` and `nbf` checks.
|
|
55
|
+
- e4beec9: Document `@ontrails/permits/jwt` as the canonical JWT adapter import while keeping root JWT re-exports as intentional convenience exports.
|
|
56
|
+
- Updated dependencies [73622ae]
|
|
57
|
+
- Updated dependencies [6300f70]
|
|
58
|
+
- Updated dependencies [d172013]
|
|
59
|
+
- Updated dependencies [c3fc5c3]
|
|
60
|
+
- Updated dependencies [20d7a5c]
|
|
61
|
+
- Updated dependencies [be5fb46]
|
|
62
|
+
- Updated dependencies [e898cc4]
|
|
63
|
+
- Updated dependencies [3395234]
|
|
64
|
+
- Updated dependencies [bcdc484]
|
|
65
|
+
- Updated dependencies [331e3a9]
|
|
66
|
+
- Updated dependencies [4399fdb]
|
|
67
|
+
- Updated dependencies [4b8d13b]
|
|
68
|
+
- Updated dependencies [112b9f2]
|
|
69
|
+
- Updated dependencies [893025e]
|
|
70
|
+
- Updated dependencies [eec5e9d]
|
|
71
|
+
- Updated dependencies [ebd4434]
|
|
72
|
+
- Updated dependencies [863d473]
|
|
73
|
+
- Updated dependencies [344f2f7]
|
|
74
|
+
- Updated dependencies [26f9ffd]
|
|
75
|
+
- Updated dependencies [10eae9a]
|
|
76
|
+
- Updated dependencies [22c6c06]
|
|
77
|
+
- @ontrails/core@1.0.0-beta.16
|
|
78
|
+
|
|
3
79
|
## 1.0.0-beta.15
|
|
4
80
|
|
|
5
81
|
### Patch 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
|
|
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 `authResource` and `authLayer`.
|
|
|
12
12
|
export const create = trail('gist.create', {
|
|
13
13
|
permit: { scopes: ['gist:write'] },
|
|
14
14
|
blaze: async (input, ctx) => {
|
|
15
|
-
//
|
|
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.
|
|
29
|
+
### 2. Resolve a permit at the surface
|
|
30
30
|
|
|
31
31
|
```typescript
|
|
32
|
-
import { authLayer } from '@ontrails/permits';
|
|
33
|
-
|
|
34
32
|
export const graph = topo('my-app', gistModule);
|
|
35
|
-
//
|
|
33
|
+
// Surface auth verifies credentials and passes { permit } into executeTrail.
|
|
36
34
|
```
|
|
37
35
|
|
|
38
|
-
The
|
|
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
|
-
|
|
41
|
-
- `{ scopes: [...] }` — layer 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
|
|
43
|
+
### 3. Bind an adapter at bootstrap
|
|
44
44
|
|
|
45
45
|
```typescript
|
|
46
|
-
import {
|
|
46
|
+
import { createJwtAdapter } from '@ontrails/permits/jwt';
|
|
47
47
|
|
|
48
|
-
const
|
|
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
|
|
55
|
+
## Auth adapters
|
|
56
56
|
|
|
57
|
-
An auth
|
|
57
|
+
An auth adapter authenticates requests and produces permits.
|
|
58
58
|
|
|
59
|
-
### Built-in: JWT
|
|
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 {
|
|
64
|
+
import { createJwtAdapter } from '@ontrails/permits/jwt';
|
|
65
65
|
|
|
66
|
-
const
|
|
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
|
-
|
|
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 `
|
|
82
|
+
Implement the `AuthAdapter` interface:
|
|
78
83
|
|
|
79
84
|
```typescript
|
|
80
|
-
import type {
|
|
85
|
+
import type { AuthAdapter, PermitExtractionInput, Permit } from '@ontrails/permits';
|
|
81
86
|
|
|
82
|
-
const
|
|
87
|
+
const myAdapter: AuthAdapter = {
|
|
83
88
|
authenticate: async (input: PermitExtractionInput) => {
|
|
84
89
|
if (!input.bearerToken) return Result.ok(null);
|
|
85
90
|
const permit: Permit = {
|
|
@@ -138,7 +143,10 @@ import { authVerify } from '@ontrails/permits';
|
|
|
138
143
|
Use `createTestPermit()` and `createPermitForTrail()` in tests:
|
|
139
144
|
|
|
140
145
|
```typescript
|
|
141
|
-
import {
|
|
146
|
+
import {
|
|
147
|
+
createTestPermit,
|
|
148
|
+
createPermitForTrail,
|
|
149
|
+
} from '@ontrails/permits/testing';
|
|
142
150
|
|
|
143
151
|
const permit = createTestPermit({
|
|
144
152
|
id: 'user-123',
|
package/package.json
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ontrails/permits",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.17",
|
|
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/
|
|
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.
|
|
27
|
+
"@ontrails/core": "^1.0.0-beta.16",
|
|
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 {
|
|
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
|
|
8
|
-
export interface
|
|
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
|
-
|
|
56
|
-
|
|
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(
|
|
63
|
-
return JSON.parse(json) as
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
package/src/auth-resource.ts
CHANGED
|
@@ -1,26 +1,89 @@
|
|
|
1
1
|
import { Result, resource } from '@ontrails/core';
|
|
2
|
+
import { z } from 'zod';
|
|
2
3
|
|
|
3
|
-
import type {
|
|
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
|
+
};
|
|
4
75
|
|
|
5
76
|
/**
|
|
6
|
-
* Auth resource — manages the auth
|
|
77
|
+
* Auth resource — manages the auth adapter lifecycle.
|
|
7
78
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* succeeds.
|
|
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.
|
|
12
82
|
*/
|
|
13
|
-
export const authResource = resource<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
authenticate: async () => Result.ok(null),
|
|
18
|
-
} satisfies AuthConnector),
|
|
19
|
-
description: 'Authentication connector',
|
|
83
|
+
export const authResource = resource<AuthAdapter>('auth', {
|
|
84
|
+
config: authResourceConfigSchema,
|
|
85
|
+
create: (svc) => Result.ok(createAdapter(svc.config as AuthResourceConfig)),
|
|
86
|
+
description: 'Authentication adapter',
|
|
20
87
|
meta: { category: 'infrastructure' },
|
|
21
|
-
mock:
|
|
22
|
-
({
|
|
23
|
-
// oxlint-disable-next-line require-await -- mock connector satisfies async interface
|
|
24
|
-
authenticate: async () => Result.ok(null),
|
|
25
|
-
}) satisfies AuthConnector,
|
|
88
|
+
mock: createNoopAdapter,
|
|
26
89
|
});
|