@ciromaciel/contract 1.0.5 → 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 +42 -1
- package/package.json +11 -4
- package/src/token.js +146 -0
- package/src/webhooks.js +119 -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
|
|
|
@@ -92,6 +93,46 @@ the prefix, the slugs and the wording of the notice; the cache, the fail-open,
|
|
|
92
93
|
the product-prefix check and the choice between `PLAN_LIMIT` and
|
|
93
94
|
`SUBSCRIPTION_REQUIRED` are the same reasoning everywhere.
|
|
94
95
|
|
|
96
|
+
### `@ciromaciel/contract/webhooks`
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import { webhookHeaders, verifyWebhook, generateSigningSecret } from '@ciromaciel/contract/webhooks'
|
|
100
|
+
|
|
101
|
+
const headers = await webhookHeaders(secret, { id: event.id, body }) // x-ccm-id, x-ccm-timestamp, x-ccm-signature
|
|
102
|
+
const result = await verifyWebhook([secret, previousSecret], { id, timestamp, signature, body })
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
One protocol for every webhook of the stack: Standard Webhooks with the `x-ccm`
|
|
106
|
+
prefix. The signature is `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`, the
|
|
107
|
+
key is the base64 after `whsec_`, and a timestamp more than five minutes off is
|
|
108
|
+
refused. `verifyWebhook` takes several secrets so a rotation has a grace period,
|
|
109
|
+
and answers with a reason (`stale_timestamp`, `bad_signature`, …) instead of a
|
|
110
|
+
bare boolean. Also exports `signWebhook`, `secretPrefixOf` and
|
|
111
|
+
`SIGNATURE_TOLERANCE_SECONDS`.
|
|
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
|
+
|
|
95
136
|
## Links
|
|
96
137
|
|
|
97
138
|
- [Website](https://ciromaciel.click)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ciromaciel/contract",
|
|
3
|
-
"version": "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.",
|
|
@@ -13,21 +13,28 @@
|
|
|
13
13
|
".": "./src/index.js",
|
|
14
14
|
"./codes": "./src/codes.js",
|
|
15
15
|
"./schemas": "./src/schemas.js",
|
|
16
|
-
"./plan": "./src/plan/index.js"
|
|
16
|
+
"./plan": "./src/plan/index.js",
|
|
17
|
+
"./webhooks": "./src/webhooks.js",
|
|
18
|
+
"./token": "./src/token.js"
|
|
17
19
|
},
|
|
18
20
|
"scripts": {
|
|
19
21
|
"test": "bun test tests/"
|
|
20
22
|
},
|
|
21
23
|
"peerDependencies": {
|
|
22
|
-
"zod": "^4.0.0"
|
|
24
|
+
"zod": "^4.0.0",
|
|
25
|
+
"jose": "^5.0.0 || ^6.0.0"
|
|
23
26
|
},
|
|
24
27
|
"peerDependenciesMeta": {
|
|
25
28
|
"zod": {
|
|
26
29
|
"optional": true
|
|
30
|
+
},
|
|
31
|
+
"jose": {
|
|
32
|
+
"optional": true
|
|
27
33
|
}
|
|
28
34
|
},
|
|
29
35
|
"devDependencies": {
|
|
30
|
-
"zod": "^4.5.4"
|
|
36
|
+
"zod": "^4.5.4",
|
|
37
|
+
"jose": "^6.2.10"
|
|
31
38
|
},
|
|
32
39
|
"publishConfig": {
|
|
33
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
|
+
}
|
package/src/webhooks.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @ciromaciel/contract/webhooks — one protocol for every webhook of the stack
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// import { webhookHeaders, verifyWebhook } from '@ciromaciel/contract/webhooks'
|
|
6
|
+
//
|
|
7
|
+
// Standard Webhooks (the scheme OpenAI and Anthropic use) with our `x-ccm`
|
|
8
|
+
// prefix. Three headers travel with the body:
|
|
9
|
+
//
|
|
10
|
+
// x-ccm-id: the event's own id (resending it is a no-op)
|
|
11
|
+
// x-ccm-timestamp: unix seconds; more than 5 minutes off is refused
|
|
12
|
+
// x-ccm-signature: `v1,<base64 HMAC-SHA256 of "id.timestamp.body">`,
|
|
13
|
+
// several separated by spaces while a secret rotates
|
|
14
|
+
//
|
|
15
|
+
// The HMAC key is the secret after `whsec_`, base64-decoded, exactly as the
|
|
16
|
+
// Standard Webhooks libraries do, so any of them signs for us once the header
|
|
17
|
+
// names are ours. The id and the timestamp are inside the signed string: a
|
|
18
|
+
// captured request cannot be replayed with a fresh timestamp or passed off as
|
|
19
|
+
// another event.
|
|
20
|
+
//
|
|
21
|
+
// WHY IT LIVES HERE. People wrote it first, to VERIFY what its sources send.
|
|
22
|
+
// Videos is the first product that EMITS in this protocol, twice (to People
|
|
23
|
+
// and to a customer's own endpoint), and Auth, Hoster and Payments will emit
|
|
24
|
+
// when they gain webhooks. One file, or four copies that drift — the plan gate
|
|
25
|
+
// already paid for that lesson.
|
|
26
|
+
//
|
|
27
|
+
// IT RUNS ON BOTH RUNTIMES: only Web Crypto, TextEncoder and btoa/atob.
|
|
28
|
+
|
|
29
|
+
/** How far a signature's timestamp may drift from now, in seconds. */
|
|
30
|
+
export const SIGNATURE_TOLERANCE_SECONDS = 300
|
|
31
|
+
|
|
32
|
+
const encoder = new TextEncoder()
|
|
33
|
+
const toBase64 = bytes => btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
|
34
|
+
const fromBase64 = value => Uint8Array.from(atob(value), c => c.charCodeAt(0))
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A new signing secret: `whsec_` and 32 random bytes in base64, the Standard
|
|
38
|
+
* Webhooks shape. Whoever finds it in a config knows at a glance what it is.
|
|
39
|
+
*/
|
|
40
|
+
export function generateSigningSecret() {
|
|
41
|
+
return `whsec_${toBase64(crypto.getRandomValues(new Uint8Array(32)))}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The part of a secret that may be shown again: enough to recognise it. */
|
|
45
|
+
export const secretPrefixOf = secret => String(secret).slice(0, 12)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The HMAC key of a secret: the base64 after `whsec_`. A secret that is not
|
|
49
|
+
* base64 (hex from before the switch is valid base64 too) falls back to its
|
|
50
|
+
* raw bytes, so both sides still derive the same key.
|
|
51
|
+
*/
|
|
52
|
+
function keyBytes(secret) {
|
|
53
|
+
try {
|
|
54
|
+
return fromBase64(String(secret).replace(/^whsec_/, ''))
|
|
55
|
+
} catch {
|
|
56
|
+
return encoder.encode(String(secret))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function hmacBase64(secret, payload) {
|
|
61
|
+
const key = await crypto.subtle.importKey('raw', keyBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
62
|
+
return toBase64(await crypto.subtle.sign('HMAC', key, encoder.encode(payload)))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `x-ccm-signature` value for one delivery. */
|
|
66
|
+
export async function signWebhook(secret, { id, timestamp, body }) {
|
|
67
|
+
return `v1,${await hmacBase64(secret, `${id}.${timestamp}.${body}`)}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The three headers of one delivery, ready to send.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} secret `whsec_…`
|
|
74
|
+
* @param {{id: string, body: string, timestamp?: number}} delivery
|
|
75
|
+
* @returns {Promise<Record<string, string>>}
|
|
76
|
+
*/
|
|
77
|
+
export async function webhookHeaders(secret, { id, body, timestamp = Math.floor(Date.now() / 1000) }) {
|
|
78
|
+
return { 'x-ccm-id': String(id), 'x-ccm-timestamp': String(timestamp), 'x-ccm-signature': await signWebhook(secret, { id, timestamp, body }) }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compares without leaking, through timing, how many leading characters matched. */
|
|
82
|
+
function timingSafeEqual(a, b) {
|
|
83
|
+
if (a.length !== b.length) return false
|
|
84
|
+
let diff = 0
|
|
85
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
86
|
+
return diff === 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Is this body really from a holder of one of `secrets`? More than one while a
|
|
91
|
+
* rotation's grace lasts: the new secret and the one it replaced.
|
|
92
|
+
*
|
|
93
|
+
* Returns a reason instead of a boolean so the refusal can say what to fix;
|
|
94
|
+
* the reasons never echo the expected signature.
|
|
95
|
+
*
|
|
96
|
+
* @param {string[]} secrets
|
|
97
|
+
* @param {{id: string|null, timestamp: string|null, signature: string|null, body: string}} delivery
|
|
98
|
+
* @param {number} [now] unix seconds
|
|
99
|
+
* @returns {Promise<{ok: true} | {ok: false, reason: 'missing_headers'|'malformed_timestamp'|'stale_timestamp'|'malformed_signature'|'bad_signature'}>}
|
|
100
|
+
*/
|
|
101
|
+
export async function verifyWebhook(secrets, { id, timestamp, signature, body }, now = Math.floor(Date.now() / 1000)) {
|
|
102
|
+
if (!id || !timestamp || !signature) return { ok: false, reason: 'missing_headers' }
|
|
103
|
+
const seconds = Number(timestamp)
|
|
104
|
+
if (!/^\d+$/.test(String(timestamp)) || !Number.isFinite(seconds)) return { ok: false, reason: 'malformed_timestamp' }
|
|
105
|
+
if (Math.abs(now - seconds) > SIGNATURE_TOLERANCE_SECONDS) return { ok: false, reason: 'stale_timestamp' }
|
|
106
|
+
|
|
107
|
+
const offered = String(signature)
|
|
108
|
+
.split(' ')
|
|
109
|
+
.map(part => part.trim())
|
|
110
|
+
.filter(part => part.startsWith('v1,'))
|
|
111
|
+
.map(part => part.slice(3))
|
|
112
|
+
if (!offered.length) return { ok: false, reason: 'malformed_signature' }
|
|
113
|
+
|
|
114
|
+
for (const secret of secrets) {
|
|
115
|
+
const expected = await hmacBase64(secret, `${id}.${seconds}.${body}`)
|
|
116
|
+
if (offered.some(value => timingSafeEqual(expected, value))) return { ok: true }
|
|
117
|
+
}
|
|
118
|
+
return { ok: false, reason: 'bad_signature' }
|
|
119
|
+
}
|