@ciromaciel/contract 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/package.json +10 -4
- package/src/token.js +146 -0
package/README.md
CHANGED
|
@@ -13,7 +13,8 @@ described below.
|
|
|
13
13
|
bun add @ciromaciel/contract
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
`zod` is an **optional** peer, needed only by the `/schemas` entrypoint
|
|
16
|
+
`zod` is an **optional** peer, needed only by the `/schemas` entrypoint, and
|
|
17
|
+
`jose` only by `/token`.
|
|
17
18
|
|
|
18
19
|
## Quick start
|
|
19
20
|
|
|
@@ -109,6 +110,29 @@ and answers with a reason (`stale_timestamp`, `bad_signature`, …) instead of a
|
|
|
109
110
|
bare boolean. Also exports `signWebhook`, `secretPrefixOf` and
|
|
110
111
|
`SIGNATURE_TOLERANCE_SECONDS`.
|
|
111
112
|
|
|
113
|
+
### `@ciromaciel/contract/token`
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { createTokenVerifier } from '@ciromaciel/contract/token'
|
|
117
|
+
|
|
118
|
+
const tokens = createTokenVerifier({ authUrl: 'https://auth.worker.myinfrastructure.click' })
|
|
119
|
+
|
|
120
|
+
await tokens.verifySession(bearer) // REST routes
|
|
121
|
+
await tokens.verifyAccessToken(bearer, { audience: 'https://hoster.worker.myinfrastructure.click/mcp' }) // MCP server
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The Auth signs sessions and OAuth access tokens with the same key and issuer;
|
|
125
|
+
only the claims tell them apart. `verifySession` accepts a person's session and
|
|
126
|
+
refuses an access token (`access_token_not_accepted`): a token consented for one
|
|
127
|
+
resource and one scope must never act as the whole account on a REST route.
|
|
128
|
+
`verifyAccessToken` accepts only an access token whose `aud` is this server
|
|
129
|
+
(`wrong_audience` otherwise, RFC 8707) and returns its `scopes`.
|
|
130
|
+
|
|
131
|
+
Both return `{ ok: true, subject, email, name, scopes, claims }` or
|
|
132
|
+
`{ ok: false, reason }`, never throw on a bad credential, and accept RS256 with
|
|
133
|
+
the `riligar-auth` issuer only. `jose` (5 or 6) is an **optional** peer, needed
|
|
134
|
+
only by this entrypoint.
|
|
135
|
+
|
|
112
136
|
## Links
|
|
113
137
|
|
|
114
138
|
- [Website](https://ciromaciel.click)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciromaciel/contract",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "A single response envelope, a closed catalogue of error codes, and helpers that make answering outside it impossible.",
|
|
@@ -14,21 +14,27 @@
|
|
|
14
14
|
"./codes": "./src/codes.js",
|
|
15
15
|
"./schemas": "./src/schemas.js",
|
|
16
16
|
"./plan": "./src/plan/index.js",
|
|
17
|
-
"./webhooks": "./src/webhooks.js"
|
|
17
|
+
"./webhooks": "./src/webhooks.js",
|
|
18
|
+
"./token": "./src/token.js"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
21
|
"test": "bun test tests/"
|
|
21
22
|
},
|
|
22
23
|
"peerDependencies": {
|
|
23
|
-
"zod": "^4.0.0"
|
|
24
|
+
"zod": "^4.0.0",
|
|
25
|
+
"jose": "^5.0.0 || ^6.0.0"
|
|
24
26
|
},
|
|
25
27
|
"peerDependenciesMeta": {
|
|
26
28
|
"zod": {
|
|
27
29
|
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"jose": {
|
|
32
|
+
"optional": true
|
|
28
33
|
}
|
|
29
34
|
},
|
|
30
35
|
"devDependencies": {
|
|
31
|
-
"zod": "^4.5.4"
|
|
36
|
+
"zod": "^4.5.4",
|
|
37
|
+
"jose": "^6.2.10"
|
|
32
38
|
},
|
|
33
39
|
"publishConfig": {
|
|
34
40
|
"access": "public"
|
package/src/token.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @ciromaciel/contract/token — one way to read an Infrastructure Auth token
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// import { createTokenVerifier } from '@ciromaciel/contract/token'
|
|
6
|
+
//
|
|
7
|
+
// const tokens = createTokenVerifier({ authUrl: 'https://auth.worker.myinfrastructure.click' })
|
|
8
|
+
//
|
|
9
|
+
// // REST routes: a person's session, and nothing else.
|
|
10
|
+
// const session = await tokens.verifySession(bearer)
|
|
11
|
+
//
|
|
12
|
+
// // The MCP server: an OAuth access token issued for THIS resource.
|
|
13
|
+
// const access = await tokens.verifyAccessToken(bearer, { audience: 'https://hoster.worker.myinfrastructure.click/mcp' })
|
|
14
|
+
//
|
|
15
|
+
// The Auth signs two kinds of token with the same key and the same issuer:
|
|
16
|
+
//
|
|
17
|
+
// session `{ sub, email, name, role }`, no `aud`. Held by the person
|
|
18
|
+
// (dashboard, CLI login, the e-mailed code flow).
|
|
19
|
+
// access token `{ sub, aud, scope, client_id, token_type: 'access_token' }`.
|
|
20
|
+
// Held by a third-party OAuth client, limited to one resource
|
|
21
|
+
// (`aud`, RFC 8707) and to what the person consented to
|
|
22
|
+
// (`scope`).
|
|
23
|
+
//
|
|
24
|
+
// Signature and issuer cannot tell them apart; only the claims can. Each
|
|
25
|
+
// product used to verify tokens with its own copy of this code — twelve
|
|
26
|
+
// copies — and the REST ones checked signature and issuer only. So an access
|
|
27
|
+
// token consented as "read" for one product's MCP server worked as a full
|
|
28
|
+
// session on the REST API of every product. This module is the one place that
|
|
29
|
+
// knows the difference, so it can only be right or wrong once.
|
|
30
|
+
//
|
|
31
|
+
// Results are values, never exceptions: `{ ok: true, ... }` or
|
|
32
|
+
// `{ ok: false, reason }`. A wrong credential is an answer the caller turns
|
|
33
|
+
// into 401 or 403, not a failure. `reason` is for logs and for choosing the
|
|
34
|
+
// status; the text shown to the caller stays generic.
|
|
35
|
+
|
|
36
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The `iss` the Auth writes into every token. It is a fixed name, not the
|
|
40
|
+
* Auth URL: passing the URL makes every verification fail.
|
|
41
|
+
*/
|
|
42
|
+
export const AUTH_TOKEN_ISSUER = 'riligar-auth'
|
|
43
|
+
|
|
44
|
+
/** The only algorithm accepted. HS256 would let anyone holding a shared secret forge tokens. */
|
|
45
|
+
const ALGORITHMS = ['RS256']
|
|
46
|
+
|
|
47
|
+
/** Why a token was refused. */
|
|
48
|
+
export const TOKEN_REFUSAL = {
|
|
49
|
+
/** Bad signature, unknown key, expired, wrong issuer or no subject. */
|
|
50
|
+
INVALID: 'invalid_token',
|
|
51
|
+
/** An OAuth access token presented where only a session is accepted. */
|
|
52
|
+
ACCESS_TOKEN_NOT_ACCEPTED: 'access_token_not_accepted',
|
|
53
|
+
/** An access token issued for another resource. */
|
|
54
|
+
WRONG_AUDIENCE: 'wrong_audience',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// One remote key set per JWKS URL, for the life of the process (or isolate).
|
|
58
|
+
// `jose` caches the keys and refetches on an unknown `kid`, which is what keeps
|
|
59
|
+
// a key rotation from turning into a wave of 401s.
|
|
60
|
+
const remoteKeySets = new Map()
|
|
61
|
+
|
|
62
|
+
function remoteKeySet(authUrl) {
|
|
63
|
+
const url = `${String(authUrl).replace(/\/+$/, '')}/.well-known/jwks.json`
|
|
64
|
+
if (!remoteKeySets.has(url)) remoteKeySets.set(url, createRemoteJWKSet(new URL(url)))
|
|
65
|
+
return remoteKeySets.get(url)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const audiencesOf = claims => (Array.isArray(claims.aud) ? claims.aud : [claims.aud]).filter(Boolean)
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Is this an OAuth access token?
|
|
72
|
+
*
|
|
73
|
+
* Either mark is enough: the Auth writes both, and a token with an audience
|
|
74
|
+
* was issued for one resource, whatever else it says.
|
|
75
|
+
*/
|
|
76
|
+
export const isAccessToken = claims => audiencesOf(claims).length > 0 || claims.token_type === 'access_token'
|
|
77
|
+
|
|
78
|
+
const scopesOf = claims =>
|
|
79
|
+
String(claims.scope ?? '')
|
|
80
|
+
.split(' ')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
|
|
83
|
+
function identity(claims) {
|
|
84
|
+
return { subject: claims.sub, email: claims.email, name: claims.name, scopes: scopesOf(claims), claims }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Builds the verifier for one Auth.
|
|
89
|
+
*
|
|
90
|
+
* @param {object} options
|
|
91
|
+
* @param {string} [options.authUrl] The Auth base URL; its JWKS is fetched from `/.well-known/jwks.json`.
|
|
92
|
+
* @param {string} [options.issuer] The expected `iss`. Defaults to {@link AUTH_TOKEN_ISSUER}.
|
|
93
|
+
* @param {Function} [options.keySet] A `jose` key set to use instead of the remote JWKS (tests).
|
|
94
|
+
*/
|
|
95
|
+
export function createTokenVerifier({ authUrl, issuer = AUTH_TOKEN_ISSUER, keySet } = {}) {
|
|
96
|
+
if (!keySet && !authUrl) throw new Error('createTokenVerifier: authUrl is required')
|
|
97
|
+
|
|
98
|
+
async function verified(token) {
|
|
99
|
+
if (!token || typeof token !== 'string') return null
|
|
100
|
+
try {
|
|
101
|
+
const { payload } = await jwtVerify(token, keySet ?? remoteKeySet(authUrl), { algorithms: ALGORITHMS, issuer })
|
|
102
|
+
return payload.sub ? payload : null
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
/**
|
|
110
|
+
* A person's session — what REST routes accept.
|
|
111
|
+
*
|
|
112
|
+
* An OAuth access token is refused here even though its signature is
|
|
113
|
+
* valid: it was consented for one resource and one scope, and a REST
|
|
114
|
+
* route would treat it as the person's whole account.
|
|
115
|
+
*
|
|
116
|
+
* @returns {Promise<{ok: true, subject: string, email?: string, name?: string, scopes: string[], claims: object} | {ok: false, reason: string}>}
|
|
117
|
+
*/
|
|
118
|
+
async verifySession(token) {
|
|
119
|
+
const claims = await verified(token)
|
|
120
|
+
if (!claims) return { ok: false, reason: TOKEN_REFUSAL.INVALID }
|
|
121
|
+
if (isAccessToken(claims)) return { ok: false, reason: TOKEN_REFUSAL.ACCESS_TOKEN_NOT_ACCEPTED }
|
|
122
|
+
return { ok: true, ...identity(claims) }
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* An OAuth access token issued for `audience` — what an MCP server accepts.
|
|
127
|
+
*
|
|
128
|
+
* `audience` is the resource this server answers as (RFC 8707); a list
|
|
129
|
+
* is accepted when one server answers under more than one identifier.
|
|
130
|
+
* A session has no audience, so it is refused here as `wrong_audience`.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} token
|
|
133
|
+
* @param {{ audience: string | string[] }} options
|
|
134
|
+
* @returns {Promise<{ok: true, subject: string, email?: string, name?: string, scopes: string[], claims: object} | {ok: false, reason: string}>}
|
|
135
|
+
*/
|
|
136
|
+
async verifyAccessToken(token, { audience } = {}) {
|
|
137
|
+
const expected = (Array.isArray(audience) ? audience : [audience]).filter(Boolean)
|
|
138
|
+
if (expected.length === 0) throw new Error('verifyAccessToken: audience is required')
|
|
139
|
+
|
|
140
|
+
const claims = await verified(token)
|
|
141
|
+
if (!claims) return { ok: false, reason: TOKEN_REFUSAL.INVALID }
|
|
142
|
+
if (!audiencesOf(claims).some(aud => expected.includes(aud))) return { ok: false, reason: TOKEN_REFUSAL.WRONG_AUDIENCE }
|
|
143
|
+
return { ok: true, ...identity(claims) }
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|