@north-light/crouter-identity 0.0.0 → 0.3.351
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/dist/index.d.ts +54 -0
- package/dist/index.js +166 -0
- package/package.json +29 -5
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { type JSONWebKeySet, type JWTPayload } from 'jose';
|
|
2
|
+
export interface AccessTokenClaims extends JWTPayload {
|
|
3
|
+
sub: string;
|
|
4
|
+
grantee: string;
|
|
5
|
+
client_id: string;
|
|
6
|
+
grant: string;
|
|
7
|
+
exp: number;
|
|
8
|
+
}
|
|
9
|
+
export interface IdTokenClaims extends JWTPayload {
|
|
10
|
+
sub: string;
|
|
11
|
+
}
|
|
12
|
+
export type Scope = 'crtr:llm' | 'crtr:act' | 'crtr:schedule' | `crtr:memory:${'read' | 'rw'}:user${'' | `:${string}`}` | 'crtr:memory:manage:user' | `crtr:memory:${'read' | 'rw'}:app:${string}` | `crtr:files:${'read' | 'rw'}:${'user' | `app:${string}`}:${string}` | 'crtr:files:share' | `crtr:${'conversations' | 'objects'}:read:app:${string}` | `crtr:peer:${string}` | `${string}:${string}`;
|
|
13
|
+
/** App-memory form keys omit the app id. */
|
|
14
|
+
export declare const ENFORCED: ReadonlySet<string>;
|
|
15
|
+
export declare function parseScope(value: string, grantee?: string): Scope;
|
|
16
|
+
export declare function covers(held: string, requested: string): boolean;
|
|
17
|
+
/** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. */
|
|
18
|
+
export declare class JwksCache {
|
|
19
|
+
readonly issuer: string;
|
|
20
|
+
private readonly fetcher;
|
|
21
|
+
private keys;
|
|
22
|
+
private lastUnknownRefresh;
|
|
23
|
+
private discovery;
|
|
24
|
+
constructor(issuer: string, fetcher?: typeof fetch, jwksUri?: string);
|
|
25
|
+
load(): Promise<JSONWebKeySet>;
|
|
26
|
+
forKid(kid: string | undefined): Promise<JSONWebKeySet>;
|
|
27
|
+
}
|
|
28
|
+
export declare function verifyAccessToken(options: {
|
|
29
|
+
token: string;
|
|
30
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
31
|
+
issuer: string;
|
|
32
|
+
audience: string;
|
|
33
|
+
}): Promise<AccessTokenClaims>;
|
|
34
|
+
export interface SyncNoticeClaims extends JWTPayload {
|
|
35
|
+
purpose: 'grant_sync';
|
|
36
|
+
iat: number;
|
|
37
|
+
exp: number;
|
|
38
|
+
jti: string;
|
|
39
|
+
}
|
|
40
|
+
/** The directory's grant-sync notice: ES256 with the directory signing key named
|
|
41
|
+
* by `kid`, no `typ`, `aud` = the runtime's registered URL, a lifetime of at most
|
|
42
|
+
* five minutes, and a `jti` the runtime uses to answer replays without a sync. */
|
|
43
|
+
export declare function verifySyncNotice(options: {
|
|
44
|
+
token: string;
|
|
45
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
46
|
+
issuer: string;
|
|
47
|
+
audience: string;
|
|
48
|
+
}): Promise<SyncNoticeClaims>;
|
|
49
|
+
export declare function verifyIdToken(options: {
|
|
50
|
+
token: string;
|
|
51
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
52
|
+
issuer: string;
|
|
53
|
+
audience: string;
|
|
54
|
+
}): Promise<IdTokenClaims>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createLocalJWKSet, decodeProtectedHeader, jwtVerify } from 'jose';
|
|
2
|
+
/** App-memory form keys omit the app id. */
|
|
3
|
+
export const ENFORCED = new Set(['crtr:llm', 'crtr:act', 'crtr:memory:read:app', 'crtr:memory:rw:app', 'crtr:files:read:user:.', 'crtr:files:rw:user:.']);
|
|
4
|
+
const id = /^[A-Za-z0-9_-]{1,64}$/;
|
|
5
|
+
function validPath(path, directoryOnly = false) {
|
|
6
|
+
if (path === '.' && directoryOnly)
|
|
7
|
+
return true;
|
|
8
|
+
return path.length > 0 && !path.startsWith('/') && !path.includes('..') && !path.includes(':') &&
|
|
9
|
+
!path.includes('\\') && !/\s/.test(path) &&
|
|
10
|
+
!path.split('/').some((part, index, parts) => part === '.' || (part === '' && index !== parts.length - 1)) &&
|
|
11
|
+
(!directoryOnly || !path.endsWith('/'));
|
|
12
|
+
}
|
|
13
|
+
function selector(value) {
|
|
14
|
+
if (value === 'crtr:llm' || value === 'crtr:act' || value === 'crtr:schedule')
|
|
15
|
+
return { kind: value.slice(5), value };
|
|
16
|
+
if (value === 'crtr:files:share')
|
|
17
|
+
return { kind: 'share', value };
|
|
18
|
+
if (value === 'crtr:memory:manage:user')
|
|
19
|
+
return { kind: 'memory', access: 'manage', holder: 'user' };
|
|
20
|
+
let match = /^crtr:memory:(read|rw):user(?::(.+))?$/.exec(value);
|
|
21
|
+
if (match && (match[2] === undefined || validPath(match[2])))
|
|
22
|
+
return { kind: 'memory', access: match[1], holder: 'user', path: match[2] };
|
|
23
|
+
match = /^crtr:memory:(read|rw):app:([A-Za-z0-9_-]{1,64})(?::profile:([A-Za-z0-9_-]{1,64})(?::(.+))?)?$/.exec(value);
|
|
24
|
+
if (match && (match[4] === undefined || validPath(match[4]))) {
|
|
25
|
+
return { kind: 'memory', access: match[1], holder: `app:${match[2]}`, profile: match[3], path: match[4] };
|
|
26
|
+
}
|
|
27
|
+
match = /^crtr:files:(read|rw):(user|app:[A-Za-z0-9_-]{1,64}):(.+)$/.exec(value);
|
|
28
|
+
if (match && validPath(match[3], true))
|
|
29
|
+
return { kind: 'files', access: match[1], holder: match[2], path: match[3] };
|
|
30
|
+
match = /^crtr:(conversations|objects):read:app:([A-Za-z0-9_-]{1,64})$/.exec(value);
|
|
31
|
+
if (match)
|
|
32
|
+
return { kind: match[1], value };
|
|
33
|
+
match = /^crtr:peer:([A-Za-z0-9_-]{1,64})$/.exec(value);
|
|
34
|
+
if (match)
|
|
35
|
+
return { kind: 'peer', value };
|
|
36
|
+
match = /^([^:]+):([^:]+)$/.exec(value);
|
|
37
|
+
if (match && match[1] !== 'crtr' && id.test(match[1]) && id.test(match[2]))
|
|
38
|
+
return { kind: 'provider', value };
|
|
39
|
+
throw new TypeError(`Unsupported scope: ${value}`);
|
|
40
|
+
}
|
|
41
|
+
export function parseScope(value, grantee) {
|
|
42
|
+
const parsed = selector(value);
|
|
43
|
+
if (grantee && (parsed.kind === 'memory' || parsed.kind === 'files') && parsed.holder.startsWith('app:') && parsed.holder !== grantee) {
|
|
44
|
+
throw new TypeError(`Unsupported scope: ${value}`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function containsPath(held, requested) {
|
|
49
|
+
if (held === undefined)
|
|
50
|
+
return true;
|
|
51
|
+
if (requested === undefined)
|
|
52
|
+
return false;
|
|
53
|
+
return held === requested || (held.endsWith('/') && requested.startsWith(held));
|
|
54
|
+
}
|
|
55
|
+
export function covers(held, requested) {
|
|
56
|
+
const h = selector(held);
|
|
57
|
+
const r = selector(requested);
|
|
58
|
+
if (h.kind !== r.kind)
|
|
59
|
+
return false;
|
|
60
|
+
if (h.kind === 'memory' && r.kind === 'memory') {
|
|
61
|
+
return h.holder === r.holder && (h.access === r.access || h.access === 'manage' || (h.access === 'rw' && r.access === 'read')) &&
|
|
62
|
+
(h.profile === undefined || h.profile === r.profile) && containsPath(h.path, r.path);
|
|
63
|
+
}
|
|
64
|
+
if (h.kind === 'files' && r.kind === 'files') {
|
|
65
|
+
return h.holder === r.holder && (h.access === 'rw' || r.access === 'read') &&
|
|
66
|
+
(h.path === '.' || h.path === r.path || r.path.startsWith(`${h.path}/`));
|
|
67
|
+
}
|
|
68
|
+
return held === requested;
|
|
69
|
+
}
|
|
70
|
+
/** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. */
|
|
71
|
+
export class JwksCache {
|
|
72
|
+
issuer;
|
|
73
|
+
fetcher;
|
|
74
|
+
keys;
|
|
75
|
+
lastUnknownRefresh = -Infinity;
|
|
76
|
+
discovery;
|
|
77
|
+
constructor(issuer, fetcher = fetch, jwksUri) {
|
|
78
|
+
this.issuer = issuer;
|
|
79
|
+
this.fetcher = fetcher;
|
|
80
|
+
if (jwksUri)
|
|
81
|
+
this.discovery = { jwks_uri: jwksUri };
|
|
82
|
+
}
|
|
83
|
+
async load() {
|
|
84
|
+
if (!this.discovery) {
|
|
85
|
+
const response = await this.fetcher(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`);
|
|
86
|
+
if (!response.ok)
|
|
87
|
+
throw new Error(`Directory discovery failed: ${response.status}`);
|
|
88
|
+
const value = await response.json();
|
|
89
|
+
if (!value || typeof value !== 'object' || !('jwks_uri' in value) || typeof value.jwks_uri !== 'string')
|
|
90
|
+
throw new Error('Directory discovery has no jwks_uri');
|
|
91
|
+
this.discovery = { jwks_uri: value.jwks_uri };
|
|
92
|
+
}
|
|
93
|
+
const response = await this.fetcher(this.discovery.jwks_uri);
|
|
94
|
+
if (!response.ok)
|
|
95
|
+
throw new Error(`Directory JWKS failed: ${response.status}`);
|
|
96
|
+
const keys = await response.json();
|
|
97
|
+
if (!keys || typeof keys !== 'object' || !('keys' in keys) || !Array.isArray(keys.keys))
|
|
98
|
+
throw new Error('Invalid directory JWKS');
|
|
99
|
+
this.keys = keys;
|
|
100
|
+
return this.keys;
|
|
101
|
+
}
|
|
102
|
+
async forKid(kid) {
|
|
103
|
+
if (!this.keys)
|
|
104
|
+
return this.load();
|
|
105
|
+
if (this.keys.keys.some((key) => key.kid === kid))
|
|
106
|
+
return this.keys;
|
|
107
|
+
if (Date.now() - this.lastUnknownRefresh >= 30_000) {
|
|
108
|
+
this.lastUnknownRefresh = Date.now();
|
|
109
|
+
await this.load();
|
|
110
|
+
}
|
|
111
|
+
return this.keys;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function validatePrincipal(value, kind) {
|
|
115
|
+
return typeof value === 'string' && value.startsWith(`${kind}:`) && id.test(value.slice(kind.length + 1));
|
|
116
|
+
}
|
|
117
|
+
function keySet(jwks, kid) {
|
|
118
|
+
return jwks instanceof JwksCache ? jwks.forKid(kid) : Promise.resolve(jwks);
|
|
119
|
+
}
|
|
120
|
+
export async function verifyAccessToken(options) {
|
|
121
|
+
const header = decodeProtectedHeader(options.token);
|
|
122
|
+
if (header.typ !== 'at+jwt' || header.alg !== 'ES256')
|
|
123
|
+
throw new Error('Invalid access token header');
|
|
124
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
125
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
126
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'], typ: 'at+jwt',
|
|
127
|
+
});
|
|
128
|
+
if (payload.aud !== options.audience || !validatePrincipal(payload.sub, 'user') || !validatePrincipal(payload.grantee, 'app') ||
|
|
129
|
+
typeof payload.client_id !== 'string' || !id.test(payload.client_id) || typeof payload.grant !== 'string' ||
|
|
130
|
+
typeof payload.exp !== 'number') {
|
|
131
|
+
throw new Error('Invalid access token claims');
|
|
132
|
+
}
|
|
133
|
+
return payload;
|
|
134
|
+
}
|
|
135
|
+
/** The directory's grant-sync notice: ES256 with the directory signing key named
|
|
136
|
+
* by `kid`, no `typ`, `aud` = the runtime's registered URL, a lifetime of at most
|
|
137
|
+
* five minutes, and a `jti` the runtime uses to answer replays without a sync. */
|
|
138
|
+
export async function verifySyncNotice(options) {
|
|
139
|
+
const header = decodeProtectedHeader(options.token);
|
|
140
|
+
if (header.alg !== 'ES256')
|
|
141
|
+
throw new Error('Invalid sync notice algorithm');
|
|
142
|
+
if (typeof header.kid !== 'string' || header.kid === '')
|
|
143
|
+
throw new Error('Sync notice has no key id');
|
|
144
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
145
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
146
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'], requiredClaims: ['exp', 'iat', 'jti'],
|
|
147
|
+
maxTokenAge: 300, clockTolerance: 30,
|
|
148
|
+
});
|
|
149
|
+
if (payload.aud !== options.audience || payload.purpose !== 'grant_sync' || typeof payload.jti !== 'string' || payload.jti === '' ||
|
|
150
|
+
typeof payload.exp !== 'number' || typeof payload.iat !== 'number' || payload.exp - payload.iat > 300) {
|
|
151
|
+
throw new Error('Invalid sync notice claims');
|
|
152
|
+
}
|
|
153
|
+
return payload;
|
|
154
|
+
}
|
|
155
|
+
export async function verifyIdToken(options) {
|
|
156
|
+
const header = decodeProtectedHeader(options.token);
|
|
157
|
+
if (header.alg !== 'ES256')
|
|
158
|
+
throw new Error('Invalid ID token algorithm');
|
|
159
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
160
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
161
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'],
|
|
162
|
+
});
|
|
163
|
+
if (payload.aud !== options.audience || typeof payload.exp !== 'number' || !validatePrincipal(payload.sub, 'user'))
|
|
164
|
+
throw new Error('Invalid ID token claims');
|
|
165
|
+
return payload;
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter-identity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"version": "0.3.351",
|
|
4
|
+
"description": "Northlight access-token verification and scope rules",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"jose": "^6.2.1"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/vallum-security/crouter.git",
|
|
30
|
+
"directory": "packages/crouter-identity"
|
|
31
|
+
},
|
|
8
32
|
"license": "GPL-3.0-only"
|
|
9
33
|
}
|
package/README.md
DELETED