@crouter/identity 0.3.377
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/README.md +90 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +218 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @crouter/identity — verify directory-issued tokens and evaluate crouter scopes
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@crouter/identity"><img alt="npm" src="https://img.shields.io/npm/v/@crouter/identity?label=npm"></a>
|
|
7
|
+
<a href="https://github.com/crouton-labs/crouter/blob/main/LICENSE"><img alt="license" src="https://img.shields.io/badge/license-GPL--3.0-blue"></a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
`@crouter/identity` is the token contract shared by crouter's runtime, [`@crouter/sdk`](../crouter-sdk) and [`@crouter/plugin`](../crouter-plugin). It does two things: it verifies the signed JWTs that a crouter cloud directory issues (access tokens, ID tokens, outbound provider tokens and grant-sync notices) against the directory's published keys, and it parses and compares scope strings such as `crtr:llm` or `crtr:memory:read:user:work/`. It is ESM-only, depends only on [`jose`](https://github.com/panva/jose), and has no side effects on import.
|
|
11
|
+
|
|
12
|
+
It is a verifier, not an identity provider. It never issues, signs or stores tokens, and it does not run a login flow; the directory does that. Most applications do not import it directly: the SDK's OAuth helpers and the plugin kit's `auth` option call it for you. Use it when you write your own provider or service that receives these tokens.
|
|
13
|
+
|
|
14
|
+
[Docs](https://docs.crouter.ai/docs/concepts/scopes-and-trust) · [Plugin authentication](https://docs.crouter.ai/docs/plugin/deploying) · [Apps with many users](https://docs.crouter.ai/docs/sdk/guides/apps-with-many-users) · [crouter repository](https://github.com/crouton-labs/crouter)
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @crouter/identity
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Verify a token
|
|
23
|
+
|
|
24
|
+
Every verifier takes `{ token, jwks, issuer, audience }` and returns the typed claims or throws. `jwks` is either a static JWKS object or a `JwksCache`.
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { JwksCache, verifyOutboundToken } from '@crouter/identity';
|
|
28
|
+
|
|
29
|
+
const issuer = 'https://directory.example.com';
|
|
30
|
+
const jwks = new JwksCache(issuer, fetch, `${issuer}/.well-known/jwks.json`);
|
|
31
|
+
|
|
32
|
+
export async function authorize(request: Request) {
|
|
33
|
+
const token = request.headers.get('authorization')?.replace(/^Bearer /, '') ?? '';
|
|
34
|
+
const claims = await verifyOutboundToken({
|
|
35
|
+
token,
|
|
36
|
+
jwks,
|
|
37
|
+
issuer,
|
|
38
|
+
audience: 'https://provider.example.com/crtr', // exactly the URL the provider registered
|
|
39
|
+
});
|
|
40
|
+
return claims.scope.split(' '); // the provider's `<provider>:<group>` strings the caller holds
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`JwksCache` keeps the last good key set for one issuer. When a token names a `kid` it has not seen, it refetches, at most once every 30 seconds, and concurrent requests share that fetch. A failed refresh leaves the previous keys in place. If you omit the third argument, it discovers `jwks_uri` from `<issuer>/.well-known/openid-configuration`.
|
|
45
|
+
|
|
46
|
+
| Function | Checks | Returns |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `verifyAccessToken` | `typ: at+jwt`, ES256, signature, `iss`, `aud`, `exp`; `sub` is `user:<id>`, `grantee` is `app:<id>` | `AccessTokenClaims` (`sub`, `grantee`, `client_id`, `grant`, `exp`) |
|
|
49
|
+
| `verifyOutboundToken` | `typ: at+jwt`, ES256 or RS256 with a `kid`, signature, `iss`, `aud`, `exp`; claim presence and types, including `scope`, `initiating_app`, `act`, `jti` | `OutboundTokenClaims` |
|
|
50
|
+
| `verifyIdToken` | ES256, signature, `iss`, `aud`, `exp`; `sub` is `user:<id>` | `IdTokenClaims` (`email*` when `email` was granted; `name`, `given_name`, `family_name` when `profile` was) |
|
|
51
|
+
| `verifySyncNotice` | ES256 with a `kid`, `purpose: grant_sync`, lifetime of at most five minutes, 30 s clock tolerance | `SyncNoticeClaims` (`jti`, `iat`, `exp`) |
|
|
52
|
+
|
|
53
|
+
`aud` must match exactly; a trailing-slash difference is refused. The `act` claim of an outbound token is for audit only: nothing in this package authorizes on it.
|
|
54
|
+
|
|
55
|
+
## Parse and compare scopes
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { parseScope, covers } from '@crouter/identity';
|
|
59
|
+
|
|
60
|
+
parseScope('crtr:files:rw:user:reports'); // returns the string, typed as Scope
|
|
61
|
+
parseScope('crtr:files:rw:user:../etc'); // throws TypeError: Unsupported scope
|
|
62
|
+
|
|
63
|
+
covers('crtr:memory:rw:user', 'crtr:memory:read:user:work'); // true: rw covers read, no path covers any path
|
|
64
|
+
covers('crtr:memory:read:user', 'crtr:memory:rw:user'); // false
|
|
65
|
+
covers('crtr:files:rw:user:.', 'crtr:files:read:user:a/b'); // true: `.` is the whole directory
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The grammar `parseScope` accepts:
|
|
69
|
+
|
|
70
|
+
| Scope | Meaning |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `crtr:llm`, `crtr:act`, `crtr:schedule` | Model calls and starting runs; acting on nodes, files and bash; scheduling crons |
|
|
73
|
+
| `crtr:memory:read:user[:path]`, `crtr:memory:rw:user[:path]`, `crtr:memory:manage:user` | The person's documents, optionally limited to a path; `manage` covers both access levels |
|
|
74
|
+
| `crtr:memory:read\|rw:app:<id>[:profile:<profile>[:path]]` | An app's documents |
|
|
75
|
+
| `crtr:files:read\|rw:user:<dir>`, `crtr:files:read\|rw:app:<id>:<dir>`, `crtr:files:share` | Host files a run may mount, and sharing them |
|
|
76
|
+
| `crtr:conversations:read:app:<id>`, `crtr:objects:read:app:<id>` | Reading another app's conversations or node objects |
|
|
77
|
+
| `crtr:peer:<id>` | A peer grant |
|
|
78
|
+
| `<provider>:<group>` | A capability provider's own scope, such as the ones in an outbound token's `scope` |
|
|
79
|
+
|
|
80
|
+
Paths are relative and refuse `..`, `:`, backslashes, whitespace and empty segments. A memory path ending in `/` covers that directory and everything below it; a file path names a directory and covers it and everything below, with `.` meaning the whole root. Passing `grantee` as the second argument to `parseScope` also refuses an `app:` holder other than the grantee. `ENFORCED` is the set of scopes `crtr sys connect --scopes` accepts when it mints a token. What each scope allows is in [Scopes and trust](https://docs.crouter.ai/docs/concepts/scopes-and-trust).
|
|
81
|
+
|
|
82
|
+
## Related
|
|
83
|
+
|
|
84
|
+
- [`@crouter/sdk`](../crouter-sdk): uses this package for ID-token verification and scope validation in its OAuth helpers.
|
|
85
|
+
- [`@crouter/plugin`](../crouter-plugin): verifies the directory's outbound tokens for providers registered in the directory.
|
|
86
|
+
- [Main repository](https://github.com/crouton-labs/crouter): the `crtr` CLI, the `crtrd` daemon, and [contributing guidelines](https://github.com/crouton-labs/crouter/blob/main/CONTRIBUTING.md).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
[GPL-3.0-only](https://github.com/crouton-labs/crouter/blob/main/LICENSE).
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
/** `email*` appear when `email` was granted; `name`, `given_name`, `family_name` when `profile` was granted and the person has a name. A person with no name has none of the three: never the phone number. `picture` (an https URL of the person's Google photo) when `profile` was granted and the account has one. */
|
|
10
|
+
export interface IdTokenClaims extends JWTPayload {
|
|
11
|
+
sub: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
email_verified?: boolean;
|
|
14
|
+
name?: string;
|
|
15
|
+
given_name?: string;
|
|
16
|
+
family_name?: string;
|
|
17
|
+
picture?: string;
|
|
18
|
+
}
|
|
19
|
+
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}`;
|
|
20
|
+
/** App-memory form keys omit the app id. */
|
|
21
|
+
export declare const ENFORCED: ReadonlySet<string>;
|
|
22
|
+
export declare function parseScope(value: string, grantee?: string): Scope;
|
|
23
|
+
export declare function covers(held: string, requested: string): boolean;
|
|
24
|
+
/** Keys are scoped to one configured issuer; failed refreshes keep the last successful set. */
|
|
25
|
+
export declare class JwksCache {
|
|
26
|
+
readonly issuer: string;
|
|
27
|
+
private readonly fetcher;
|
|
28
|
+
private keys;
|
|
29
|
+
private lastUnknownRefresh;
|
|
30
|
+
private loading;
|
|
31
|
+
private discovery;
|
|
32
|
+
constructor(issuer: string, fetcher?: typeof fetch, jwksUri?: string);
|
|
33
|
+
load(): Promise<JSONWebKeySet>;
|
|
34
|
+
private fetchKeys;
|
|
35
|
+
forKid(kid: string | undefined): Promise<JSONWebKeySet>;
|
|
36
|
+
}
|
|
37
|
+
export declare function verifyAccessToken(options: {
|
|
38
|
+
token: string;
|
|
39
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
40
|
+
issuer: string;
|
|
41
|
+
audience: string;
|
|
42
|
+
}): Promise<AccessTokenClaims>;
|
|
43
|
+
/** One level of the outbound token's RFC 8693 `act` nesting: the actor, and the actor it acted for. */
|
|
44
|
+
export interface OutboundActor {
|
|
45
|
+
sub: string;
|
|
46
|
+
act?: OutboundActor;
|
|
47
|
+
}
|
|
48
|
+
/** The claims of the outbound token the directory mints for one provider call (contract 3). */
|
|
49
|
+
export interface OutboundTokenClaims extends JWTPayload {
|
|
50
|
+
iss: string;
|
|
51
|
+
aud: string;
|
|
52
|
+
/** The user the call is made for, `user:<id>`. */
|
|
53
|
+
sub: string;
|
|
54
|
+
/** The principal the grant was issued to, `app:<id>` or the user. */
|
|
55
|
+
grantee: string;
|
|
56
|
+
/** The runtime's registered OAuth client id. */
|
|
57
|
+
client_id: string;
|
|
58
|
+
/** The provider's `<provider>:<group>` strings the caller holds, space-separated; may be empty. */
|
|
59
|
+
scope: string;
|
|
60
|
+
initiating_app: string;
|
|
61
|
+
/** Audit only; nothing authorizes on it. */
|
|
62
|
+
act: OutboundActor;
|
|
63
|
+
iat: number;
|
|
64
|
+
exp: number;
|
|
65
|
+
jti: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Verifies the outbound token a provider receives (contract 3 D3-TOK-12): ES256 or RS256 with
|
|
69
|
+
* a `kid`, typ `at+jwt`, signature against `jwks` (a `JwksCache` refetches once on an unknown
|
|
70
|
+
* `kid`), `iss` = `issuer`, `aud` exactly `audience` (a trailing-slash difference is refused),
|
|
71
|
+
* and `exp`. `sub` must be a `user:` principal and `grantee` and `initiating_app`
|
|
72
|
+
* an `app:` or `user:` principal; the remaining claims are checked for presence and type only; which of them gates a
|
|
73
|
+
* call is the caller's rule (D3-TOK-13 gates on `scope` alone, and nothing gates on `act`).
|
|
74
|
+
*/
|
|
75
|
+
export declare function verifyOutboundToken(options: {
|
|
76
|
+
token: string;
|
|
77
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
78
|
+
issuer: string;
|
|
79
|
+
audience: string;
|
|
80
|
+
}): Promise<OutboundTokenClaims>;
|
|
81
|
+
export interface SyncNoticeClaims extends JWTPayload {
|
|
82
|
+
purpose: 'grant_sync';
|
|
83
|
+
iat: number;
|
|
84
|
+
exp: number;
|
|
85
|
+
jti: string;
|
|
86
|
+
}
|
|
87
|
+
/** The directory's grant-sync notice: ES256 with the directory signing key named
|
|
88
|
+
* by `kid`, no `typ`, `aud` = the runtime's registered URL, a lifetime of at most
|
|
89
|
+
* five minutes, and a `jti` the runtime uses to answer replays without a sync. */
|
|
90
|
+
export declare function verifySyncNotice(options: {
|
|
91
|
+
token: string;
|
|
92
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
93
|
+
issuer: string;
|
|
94
|
+
audience: string;
|
|
95
|
+
}): Promise<SyncNoticeClaims>;
|
|
96
|
+
export declare function verifyIdToken(options: {
|
|
97
|
+
token: string;
|
|
98
|
+
jwks: JSONWebKeySet | JwksCache;
|
|
99
|
+
issuer: string;
|
|
100
|
+
audience: string;
|
|
101
|
+
}): Promise<IdTokenClaims>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
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:schedule', 'crtr:memory:read:user', '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
|
+
loading;
|
|
77
|
+
discovery;
|
|
78
|
+
constructor(issuer, fetcher = fetch, jwksUri) {
|
|
79
|
+
this.issuer = issuer;
|
|
80
|
+
this.fetcher = fetcher;
|
|
81
|
+
if (jwksUri)
|
|
82
|
+
this.discovery = { jwks_uri: jwksUri };
|
|
83
|
+
}
|
|
84
|
+
async load() {
|
|
85
|
+
if (this.loading)
|
|
86
|
+
return this.loading;
|
|
87
|
+
const loading = this.fetchKeys();
|
|
88
|
+
this.loading = loading;
|
|
89
|
+
try {
|
|
90
|
+
return await loading;
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
this.loading = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async fetchKeys() {
|
|
97
|
+
if (!this.discovery) {
|
|
98
|
+
const response = await this.fetcher(`${this.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`);
|
|
99
|
+
if (!response.ok)
|
|
100
|
+
throw new Error(`Directory discovery failed: ${response.status}`);
|
|
101
|
+
const value = await response.json();
|
|
102
|
+
if (!value || typeof value !== 'object' || !('jwks_uri' in value) || typeof value.jwks_uri !== 'string')
|
|
103
|
+
throw new Error('Directory discovery has no jwks_uri');
|
|
104
|
+
this.discovery = { jwks_uri: value.jwks_uri };
|
|
105
|
+
}
|
|
106
|
+
const response = await this.fetcher(this.discovery.jwks_uri);
|
|
107
|
+
if (!response.ok)
|
|
108
|
+
throw new Error(`Directory JWKS failed: ${response.status}`);
|
|
109
|
+
const keys = await response.json();
|
|
110
|
+
if (!keys || typeof keys !== 'object' || !('keys' in keys) || !Array.isArray(keys.keys))
|
|
111
|
+
throw new Error('Invalid directory JWKS');
|
|
112
|
+
this.keys = keys;
|
|
113
|
+
return this.keys;
|
|
114
|
+
}
|
|
115
|
+
async forKid(kid) {
|
|
116
|
+
if (!this.keys)
|
|
117
|
+
return this.load();
|
|
118
|
+
if (this.keys.keys.some((key) => key.kid === kid))
|
|
119
|
+
return this.keys;
|
|
120
|
+
// Concurrent requests for a newly published kid must await the same refetch, not use
|
|
121
|
+
// the stale set while the 30-second unknown-kid throttle is running.
|
|
122
|
+
if (this.loading)
|
|
123
|
+
return this.loading;
|
|
124
|
+
if (Date.now() - this.lastUnknownRefresh >= 30_000) {
|
|
125
|
+
this.lastUnknownRefresh = Date.now();
|
|
126
|
+
await this.load();
|
|
127
|
+
}
|
|
128
|
+
return this.keys;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function validatePrincipal(value, kind) {
|
|
132
|
+
return typeof value === 'string' && value.startsWith(`${kind}:`) && id.test(value.slice(kind.length + 1));
|
|
133
|
+
}
|
|
134
|
+
function keySet(jwks, kid) {
|
|
135
|
+
return jwks instanceof JwksCache ? jwks.forKid(kid) : Promise.resolve(jwks);
|
|
136
|
+
}
|
|
137
|
+
export async function verifyAccessToken(options) {
|
|
138
|
+
const header = decodeProtectedHeader(options.token);
|
|
139
|
+
if (header.typ !== 'at+jwt' || header.alg !== 'ES256')
|
|
140
|
+
throw new Error('Invalid access token header');
|
|
141
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
142
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
143
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'], typ: 'at+jwt',
|
|
144
|
+
});
|
|
145
|
+
if (payload.aud !== options.audience || !validatePrincipal(payload.sub, 'user') || !validatePrincipal(payload.grantee, 'app') ||
|
|
146
|
+
typeof payload.client_id !== 'string' || !id.test(payload.client_id) || typeof payload.grant !== 'string' ||
|
|
147
|
+
typeof payload.exp !== 'number') {
|
|
148
|
+
throw new Error('Invalid access token claims');
|
|
149
|
+
}
|
|
150
|
+
return payload;
|
|
151
|
+
}
|
|
152
|
+
function nonEmptyString(value) {
|
|
153
|
+
return typeof value === 'string' && value !== '';
|
|
154
|
+
}
|
|
155
|
+
function appOrUser(value) {
|
|
156
|
+
return validatePrincipal(value, 'app') || validatePrincipal(value, 'user');
|
|
157
|
+
}
|
|
158
|
+
function isActor(value) {
|
|
159
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && nonEmptyString(value.sub);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Verifies the outbound token a provider receives (contract 3 D3-TOK-12): ES256 or RS256 with
|
|
163
|
+
* a `kid`, typ `at+jwt`, signature against `jwks` (a `JwksCache` refetches once on an unknown
|
|
164
|
+
* `kid`), `iss` = `issuer`, `aud` exactly `audience` (a trailing-slash difference is refused),
|
|
165
|
+
* and `exp`. `sub` must be a `user:` principal and `grantee` and `initiating_app`
|
|
166
|
+
* an `app:` or `user:` principal; the remaining claims are checked for presence and type only; which of them gates a
|
|
167
|
+
* call is the caller's rule (D3-TOK-13 gates on `scope` alone, and nothing gates on `act`).
|
|
168
|
+
*/
|
|
169
|
+
export async function verifyOutboundToken(options) {
|
|
170
|
+
const header = decodeProtectedHeader(options.token);
|
|
171
|
+
if (header.typ !== 'at+jwt' || (header.alg !== 'ES256' && header.alg !== 'RS256'))
|
|
172
|
+
throw new Error('Invalid outbound token header');
|
|
173
|
+
if (!nonEmptyString(header.kid))
|
|
174
|
+
throw new Error('Outbound token has no key id');
|
|
175
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
176
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
177
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256', 'RS256'], typ: 'at+jwt', requiredClaims: ['exp'],
|
|
178
|
+
});
|
|
179
|
+
if (payload.aud !== options.audience || typeof payload.exp !== 'number' || typeof payload.iat !== 'number' ||
|
|
180
|
+
!validatePrincipal(payload.sub, 'user') || !appOrUser(payload.grantee) || !nonEmptyString(payload.client_id) ||
|
|
181
|
+
typeof payload.scope !== 'string' || !appOrUser(payload.initiating_app) || !isActor(payload.act) ||
|
|
182
|
+
!nonEmptyString(payload.jti)) {
|
|
183
|
+
throw new Error('Invalid outbound token claims');
|
|
184
|
+
}
|
|
185
|
+
return payload;
|
|
186
|
+
}
|
|
187
|
+
/** The directory's grant-sync notice: ES256 with the directory signing key named
|
|
188
|
+
* by `kid`, no `typ`, `aud` = the runtime's registered URL, a lifetime of at most
|
|
189
|
+
* five minutes, and a `jti` the runtime uses to answer replays without a sync. */
|
|
190
|
+
export async function verifySyncNotice(options) {
|
|
191
|
+
const header = decodeProtectedHeader(options.token);
|
|
192
|
+
if (header.alg !== 'ES256')
|
|
193
|
+
throw new Error('Invalid sync notice algorithm');
|
|
194
|
+
if (typeof header.kid !== 'string' || header.kid === '')
|
|
195
|
+
throw new Error('Sync notice has no key id');
|
|
196
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
197
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
198
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'], requiredClaims: ['exp', 'iat', 'jti'],
|
|
199
|
+
maxTokenAge: 300, clockTolerance: 30,
|
|
200
|
+
});
|
|
201
|
+
if (payload.aud !== options.audience || payload.purpose !== 'grant_sync' || typeof payload.jti !== 'string' || payload.jti === '' ||
|
|
202
|
+
typeof payload.exp !== 'number' || typeof payload.iat !== 'number' || payload.exp - payload.iat > 300) {
|
|
203
|
+
throw new Error('Invalid sync notice claims');
|
|
204
|
+
}
|
|
205
|
+
return payload;
|
|
206
|
+
}
|
|
207
|
+
export async function verifyIdToken(options) {
|
|
208
|
+
const header = decodeProtectedHeader(options.token);
|
|
209
|
+
if (header.alg !== 'ES256')
|
|
210
|
+
throw new Error('Invalid ID token algorithm');
|
|
211
|
+
const keys = await keySet(options.jwks, header.kid);
|
|
212
|
+
const { payload } = await jwtVerify(options.token, createLocalJWKSet(keys), {
|
|
213
|
+
issuer: options.issuer, audience: options.audience, algorithms: ['ES256'],
|
|
214
|
+
});
|
|
215
|
+
if (payload.aud !== options.audience || typeof payload.exp !== 'number' || !validatePrincipal(payload.sub, 'user'))
|
|
216
|
+
throw new Error('Invalid ID token claims');
|
|
217
|
+
return payload;
|
|
218
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crouter/identity",
|
|
3
|
+
"version": "0.3.377",
|
|
4
|
+
"description": "crouter cloud 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/crouton-labs/crouter.git",
|
|
30
|
+
"directory": "packages/crouter-identity"
|
|
31
|
+
},
|
|
32
|
+
"license": "GPL-3.0-only"
|
|
33
|
+
}
|