@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.
Files changed (3) hide show
  1. package/README.md +25 -1
  2. package/package.json +10 -4
  3. 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.1.0",
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
+ }