@homeflare/cloudflare 0.2.2 → 0.3.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 +72 -0
- package/dist/access-identity.d.ts +57 -0
- package/dist/access-identity.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -3
- package/dist/index.js.map +6 -4
- package/dist/mcp-metadata.d.ts +78 -0
- package/dist/mcp-metadata.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/access-identity.ts +77 -0
- package/src/index.ts +25 -0
- package/src/mcp-metadata.ts +124 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,78 @@ bun add @homeflare/cloudflare
|
|
|
9
9
|
⛔ **Workers only.** This package assumes workerd. For code that must also run on Node or
|
|
10
10
|
in a script, use [`@homeflare/kit`](https://www.npmjs.com/package/@homeflare/kit).
|
|
11
11
|
|
|
12
|
+
## Which auth path?
|
|
13
|
+
|
|
14
|
+
Three, and they are **not alternatives** — pick by where your code runs:
|
|
15
|
+
|
|
16
|
+
| your code | use | why |
|
|
17
|
+
| ------------------------------ | -------------------------- | ---------------------------------------------------------------------------------- |
|
|
18
|
+
| a Worker **behind** Access | `accessIdentity(ctx)` | the edge already enforced the policy and attached the identity — no token handling |
|
|
19
|
+
| an origin with no `ctx.access` | `verifyAccessJwt(request)` | service-to-service, a non-Worker origin, or a Worker reached by service binding |
|
|
20
|
+
| an **MCP server** | `serveMcpMetadata()` | neither of the above lets a client _discover_ how to authenticate |
|
|
21
|
+
|
|
22
|
+
## accessIdentity — Workers behind Access
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { accessIdentity } from '@homeflare/cloudflare';
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
async fetch(request, env, ctx) {
|
|
29
|
+
const who = await accessIdentity(ctx);
|
|
30
|
+
if (who === undefined) return new Response('Access did not run', { status: 401 });
|
|
31
|
+
|
|
32
|
+
return Response.json({ email: who.email, groups: who.groups });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
★ **Prefer this when it applies.** Cloudflare attaches the identity to the execution
|
|
38
|
+
context (shipped 2026-08-14); verifying the assertion yourself re-checks something the
|
|
39
|
+
platform already enforced.
|
|
40
|
+
|
|
41
|
+
⛔ Returns `undefined` rather than throwing, unlike `verifyAccessJwt` — a Worker may
|
|
42
|
+
legitimately serve open routes too, so "no Access here" is a branch you decide, visible at
|
|
43
|
+
the call site.
|
|
44
|
+
|
|
45
|
+
⚠️ **`ctx.access` does not propagate through service bindings or RPC.** A downstream Worker
|
|
46
|
+
reached by binding sees `undefined` even though the caller was authenticated. It needs its
|
|
47
|
+
own Access application, or to be told.
|
|
48
|
+
|
|
49
|
+
⚠️ **Read groups from here, not from a token.** Cloudflare trims the JWT's `custom` claim
|
|
50
|
+
at roughly 1 KB — silently — so group membership read from an assertion can be incomplete.
|
|
51
|
+
That is an authorization bug that only shows up for users in many groups.
|
|
52
|
+
|
|
53
|
+
## serveMcpMetadata — RFC 9728 for MCP servers
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { serveMcpMetadata, unauthorizedResponse } from '@homeflare/cloudflare';
|
|
57
|
+
|
|
58
|
+
const auth = {
|
|
59
|
+
resource: 'https://mcp.example.com/mcp',
|
|
60
|
+
authorizationServer: 'https://team.cloudflareaccess.com',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export default {
|
|
64
|
+
async fetch(request, env, ctx) {
|
|
65
|
+
const discovery = serveMcpMetadata(request, auth);
|
|
66
|
+
if (discovery !== undefined) return discovery;
|
|
67
|
+
|
|
68
|
+
const who = await accessIdentity(ctx);
|
|
69
|
+
if (who === undefined) return unauthorizedResponse(auth);
|
|
70
|
+
// …serve MCP
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
⛔ **Both halves, or neither works.** The MCP spec requires a server to publish Protected
|
|
76
|
+
Resource Metadata _and_ a 401 to carry `WWW-Authenticate` naming it. Publishing the
|
|
77
|
+
document while answering a bare 401 leaves clients that follow the header — the common
|
|
78
|
+
path — with nowhere to go.
|
|
79
|
+
|
|
80
|
+
⚠️ The well-known URL is **derived** from the resource URL, not chosen. That derivation is
|
|
81
|
+
a security property: it stops anyone publishing metadata claiming to describe a resource
|
|
82
|
+
they have no authority over.
|
|
83
|
+
|
|
12
84
|
## verifyAccessJwt
|
|
13
85
|
|
|
14
86
|
```ts
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access identity WITHOUT parsing a JWT — the path Cloudflare added in August 2026.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHEN ACCESS PROTECTS A WORKER, the edge attaches the authenticated identity to the
|
|
5
|
+
* execution context: `ctx.access.getIdentity()` returns email, name and groups with no
|
|
6
|
+
* token handling at all. Verifying the assertion yourself is the OLDER path, and it is
|
|
7
|
+
* still correct for origins that have no `ctx.access` — see `verifyAccessJwt`.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THIS IS NOT A WEAKER CHECK. The edge has already refused the request if it did not
|
|
10
|
+
* pass the Access policy; `ctx.access` exists only on requests that passed. Parsing the
|
|
11
|
+
* JWT again re-verifies something the platform has already enforced.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ `ctx.access` IS NOT PROPAGATED THROUGH SERVICE BINDINGS OR RPC. A downstream Worker
|
|
14
|
+
* reached by binding sees `undefined`, even though the caller was authenticated — so a
|
|
15
|
+
* service-bound Worker that needs identity must be told, or must sit behind its own
|
|
16
|
+
* Access application. Measured from Cloudflare's own documentation, 2026-08-14.
|
|
17
|
+
*
|
|
18
|
+
* ★ GROUPS COME FROM get-identity, NOT FROM THE TOKEN. Cloudflare trims the JWT's `custom`
|
|
19
|
+
* claim at roughly 1 KB, silently, so group membership read from a token can be
|
|
20
|
+
* incomplete — which is an authorization bug that only appears for users in many groups.
|
|
21
|
+
*/
|
|
22
|
+
/** What `ctx.access.getIdentity()` resolves. Narrowed to the fields worth depending on. */
|
|
23
|
+
export interface AccessIdentityInfo {
|
|
24
|
+
readonly email: string | undefined;
|
|
25
|
+
readonly name: string | undefined;
|
|
26
|
+
readonly groups: readonly string[];
|
|
27
|
+
/** Everything else the platform returned, for callers needing a field not named here. */
|
|
28
|
+
readonly raw: Readonly<Record<string, unknown>>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The slice of the execution context this module uses.
|
|
32
|
+
* ⚠️ Declared structurally rather than imported from `@cloudflare/workers-types`: this
|
|
33
|
+
* package should not force a types dependency on a consumer for one optional field.
|
|
34
|
+
*/
|
|
35
|
+
export interface AccessContext {
|
|
36
|
+
readonly access?: {
|
|
37
|
+
readonly aud?: string;
|
|
38
|
+
readonly getIdentity: () => Promise<Record<string, unknown> | null | undefined>;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** True when the request reached this Worker through Cloudflare Access. */
|
|
42
|
+
export declare function hasAccess(ctx: AccessContext): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* The authenticated identity, or `undefined` when the request did not come through Access.
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* const who = await accessIdentity(ctx);
|
|
48
|
+
* if (who === undefined) return new Response('Access did not run', { status: 401 });
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* ⛔ RETURNS undefined RATHER THAN THROWING, deliberately — unlike `verifyAccessJwt`. A
|
|
52
|
+
* Worker may legitimately serve both protected and open routes, so "no Access here" is
|
|
53
|
+
* a branch rather than a failure. The caller decides what an unauthenticated request
|
|
54
|
+
* means, and that decision is visible at the call site.
|
|
55
|
+
*/
|
|
56
|
+
export declare function accessIdentity(ctx: AccessContext): Promise<AccessIdentityInfo | undefined>;
|
|
57
|
+
//# sourceMappingURL=access-identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-identity.d.ts","sourceRoot":"","sources":["../src/access-identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,2FAA2F;AAC3F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,yFAAyF;IACzF,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACjD;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,CAAC,EAAE;QAChB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QACtB,QAAQ,CAAC,WAAW,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;KACjF,CAAC;CACH;AAED,2EAA2E;AAC3E,wBAAgB,SAAS,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAErD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,cAAc,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,kBAAkB,GAAG,SAAS,CAAC,CAehG"}
|
package/dist/index.d.ts
CHANGED
|
@@ -8,7 +8,20 @@
|
|
|
8
8
|
* resolution.
|
|
9
9
|
*/
|
|
10
10
|
export { VERSION } from './version.ts';
|
|
11
|
+
/**
|
|
12
|
+
* ★ THREE AUTH PATHS, AND THEY ARE NOT ALTERNATIVES — pick by where your code runs:
|
|
13
|
+
*
|
|
14
|
+
* accessIdentity(ctx) A Worker BEHIND Access. The edge already enforced the policy
|
|
15
|
+
* and attached the identity; no token handling. Prefer this.
|
|
16
|
+
* verifyAccessJwt(req) An origin with no `ctx.access` — service-to-service, a
|
|
17
|
+
* non-Worker origin, or a Worker reached by service binding
|
|
18
|
+
* (⚠️ `ctx.access` does not propagate through bindings).
|
|
19
|
+
* serveMcpMetadata() An MCP server. Neither of the above helps a client DISCOVER
|
|
20
|
+
* how to authenticate; RFC 9728 is what does.
|
|
21
|
+
*/
|
|
22
|
+
export { accessIdentity, hasAccess, type AccessContext, type AccessIdentityInfo, } from './access-identity.ts';
|
|
11
23
|
export { verifyAccessJwt, type AccessIdentity, type AccessOptions } from './access.ts';
|
|
24
|
+
export { protectedResourceMetadata, serveMcpMetadata, unauthorizedResponse, wellKnownPath, type McpMetadataOptions, type ProtectedResourceMetadata, } from './mcp-metadata.ts';
|
|
12
25
|
export { log, type LogFields, type Logger } from './log.ts';
|
|
13
26
|
export { BREAKER_COOLDOWN_MS, breakered } from './jwks-breaker.ts';
|
|
14
27
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,EAAE,GAAG,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC;;;;;;;;;;GAUG;AACH,OAAO,EACL,cAAc,EACd,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,kBAAkB,GACxB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvF,OAAO,EACL,yBAAyB,EACzB,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,KAAK,kBAAkB,EACvB,KAAK,yBAAyB,GAC/B,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,GAAG,EAAE,KAAK,SAAS,EAAE,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var VERSION = "0.
|
|
2
|
+
var VERSION = "0.3.0";
|
|
3
|
+
// src/access-identity.ts
|
|
4
|
+
function hasAccess(ctx) {
|
|
5
|
+
return ctx.access !== undefined;
|
|
6
|
+
}
|
|
7
|
+
async function accessIdentity(ctx) {
|
|
8
|
+
const access = ctx.access;
|
|
9
|
+
if (access === undefined)
|
|
10
|
+
return;
|
|
11
|
+
const raw = await access.getIdentity() ?? {};
|
|
12
|
+
const groups = raw["groups"];
|
|
13
|
+
return {
|
|
14
|
+
email: typeof raw["email"] === "string" ? raw["email"] : undefined,
|
|
15
|
+
name: typeof raw["name"] === "string" ? raw["name"] : undefined,
|
|
16
|
+
groups: Array.isArray(groups) ? groups.filter((g) => typeof g === "string") : [],
|
|
17
|
+
raw
|
|
18
|
+
};
|
|
19
|
+
}
|
|
3
20
|
// src/access.ts
|
|
4
21
|
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
5
22
|
|
|
@@ -56,6 +73,42 @@ async function verifyAccessJwt(request, options) {
|
|
|
56
73
|
claims: payload
|
|
57
74
|
};
|
|
58
75
|
}
|
|
76
|
+
// src/mcp-metadata.ts
|
|
77
|
+
function wellKnownPath(resource) {
|
|
78
|
+
const path = new URL(resource).pathname.replace(/\/+$/, "");
|
|
79
|
+
return path === "" ? "/.well-known/oauth-protected-resource" : `/.well-known/oauth-protected-resource${path}`;
|
|
80
|
+
}
|
|
81
|
+
function protectedResourceMetadata(options) {
|
|
82
|
+
return {
|
|
83
|
+
resource: options.resource,
|
|
84
|
+
authorization_servers: [options.authorizationServer],
|
|
85
|
+
bearer_methods_supported: ["header"],
|
|
86
|
+
...options.scopes === undefined ? {} : { scopes_supported: options.scopes },
|
|
87
|
+
...options.documentation === undefined ? {} : { resource_documentation: options.documentation }
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function unauthorizedResponse(options) {
|
|
91
|
+
const metadataUrl = new URL(wellKnownPath(options.resource), options.resource).href;
|
|
92
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
|
93
|
+
status: 401,
|
|
94
|
+
headers: {
|
|
95
|
+
"content-type": "application/json",
|
|
96
|
+
"www-authenticate": `Bearer resource_metadata="${metadataUrl}"`
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function serveMcpMetadata(request, options) {
|
|
101
|
+
const url = new URL(request.url);
|
|
102
|
+
if (url.pathname !== wellKnownPath(options.resource))
|
|
103
|
+
return;
|
|
104
|
+
return new Response(JSON.stringify(protectedResourceMetadata(options), null, 2), {
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "application/json",
|
|
107
|
+
"access-control-allow-origin": "*",
|
|
108
|
+
"cache-control": "public, max-age=3600"
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
59
112
|
// src/log.ts
|
|
60
113
|
function emit(level, message, fields) {
|
|
61
114
|
const line = { level, message, ...fields };
|
|
@@ -79,10 +132,16 @@ var log = make({});
|
|
|
79
132
|
export {
|
|
80
133
|
BREAKER_COOLDOWN_MS,
|
|
81
134
|
VERSION,
|
|
135
|
+
accessIdentity,
|
|
82
136
|
breakered,
|
|
137
|
+
hasAccess,
|
|
83
138
|
log,
|
|
84
|
-
|
|
139
|
+
protectedResourceMetadata,
|
|
140
|
+
serveMcpMetadata,
|
|
141
|
+
unauthorizedResponse,
|
|
142
|
+
verifyAccessJwt,
|
|
143
|
+
wellKnownPath
|
|
85
144
|
};
|
|
86
145
|
|
|
87
|
-
//# debugId=
|
|
146
|
+
//# debugId=4517D37BD676289464756E2164756E21
|
|
88
147
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/version.ts", "../src/access.ts", "../src/jwks-breaker.ts", "../src/log.ts"],
|
|
3
|
+
"sources": ["../src/version.ts", "../src/access-identity.ts", "../src/access.ts", "../src/jwks-breaker.ts", "../src/mcp-metadata.ts", "../src/log.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.
|
|
5
|
+
"// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.3.0';\n",
|
|
6
|
+
"/**\n * Access identity WITHOUT parsing a JWT — the path Cloudflare added in August 2026.\n *\n * ★ WHEN ACCESS PROTECTS A WORKER, the edge attaches the authenticated identity to the\n * execution context: `ctx.access.getIdentity()` returns email, name and groups with no\n * token handling at all. Verifying the assertion yourself is the OLDER path, and it is\n * still correct for origins that have no `ctx.access` — see `verifyAccessJwt`.\n *\n * ⛔ THIS IS NOT A WEAKER CHECK. The edge has already refused the request if it did not\n * pass the Access policy; `ctx.access` exists only on requests that passed. Parsing the\n * JWT again re-verifies something the platform has already enforced.\n *\n * ⚠️ `ctx.access` IS NOT PROPAGATED THROUGH SERVICE BINDINGS OR RPC. A downstream Worker\n * reached by binding sees `undefined`, even though the caller was authenticated — so a\n * service-bound Worker that needs identity must be told, or must sit behind its own\n * Access application. Measured from Cloudflare's own documentation, 2026-08-14.\n *\n * ★ GROUPS COME FROM get-identity, NOT FROM THE TOKEN. Cloudflare trims the JWT's `custom`\n * claim at roughly 1 KB, silently, so group membership read from a token can be\n * incomplete — which is an authorization bug that only appears for users in many groups.\n */\n\n/** What `ctx.access.getIdentity()` resolves. Narrowed to the fields worth depending on. */\nexport interface AccessIdentityInfo {\n readonly email: string | undefined;\n readonly name: string | undefined;\n readonly groups: readonly string[];\n /** Everything else the platform returned, for callers needing a field not named here. */\n readonly raw: Readonly<Record<string, unknown>>;\n}\n\n/**\n * The slice of the execution context this module uses.\n * ⚠️ Declared structurally rather than imported from `@cloudflare/workers-types`: this\n * package should not force a types dependency on a consumer for one optional field.\n */\nexport interface AccessContext {\n readonly access?: {\n readonly aud?: string;\n readonly getIdentity: () => Promise<Record<string, unknown> | null | undefined>;\n };\n}\n\n/** True when the request reached this Worker through Cloudflare Access. */\nexport function hasAccess(ctx: AccessContext): boolean {\n return ctx.access !== undefined;\n}\n\n/**\n * The authenticated identity, or `undefined` when the request did not come through Access.\n *\n * ```ts\n * const who = await accessIdentity(ctx);\n * if (who === undefined) return new Response('Access did not run', { status: 401 });\n * ```\n *\n * ⛔ RETURNS undefined RATHER THAN THROWING, deliberately — unlike `verifyAccessJwt`. A\n * Worker may legitimately serve both protected and open routes, so \"no Access here\" is\n * a branch rather than a failure. The caller decides what an unauthenticated request\n * means, and that decision is visible at the call site.\n */\nexport async function accessIdentity(ctx: AccessContext): Promise<AccessIdentityInfo | undefined> {\n const access = ctx.access;\n if (access === undefined) return undefined;\n\n const raw = (await access.getIdentity()) ?? {};\n const groups = raw['groups'];\n\n return {\n email: typeof raw['email'] === 'string' ? raw['email'] : undefined,\n name: typeof raw['name'] === 'string' ? raw['name'] : undefined,\n // ⚠️ Always an array, never undefined: a caller writing `groups.includes(…)` on a\n // missing field would throw at exactly the moment authorization is being decided.\n groups: Array.isArray(groups) ? groups.filter((g): g is string => typeof g === 'string') : [],\n raw,\n };\n}\n",
|
|
6
7
|
"/**\n * Cloudflare Access JWT verification.\n *\n * ★ WHY jose AND NOT A HAND-ROLLED VERIFY. Signature verification is the one place where\n * a subtle bug is silent and total: a check that never fails looks identical to a\n * check that always passes. jose handles JWKS fetching, `kid` matching and key\n * rotation, and it is widely deployed. This file is the Access-specific\n * wiring around it, nothing more.\n *\n * ⚠️ THE TRAP THIS CLOSES. Cloudflare's own example does `jwtVerify(...)` inline in the\n * handler, which builds a NEW remote JWKS on every request — an outbound fetch per\n * request, and a thundering herd on the certs endpoint under load. The cache below is\n * keyed by team domain and lives for the isolate's lifetime.\n *\n * ⛔ NEVER SKIP `audience`. Any Access app in your account produces a JWT this team\n * domain will happily verify; the AUD tag is the only thing that says the token was\n * minted for THIS app. Verifying issuer alone accepts tokens from a neighbouring app.\n *\n * Docs: developers.cloudflare.com/cloudflare-one/identity/authorization-cookie/validating-json/\n */\nimport { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';\nimport { breakered } from './jwks-breaker.ts';\n\n/** The header Access puts the token in. Lowercase — Headers.get is case-insensitive. */\nconst ACCESS_HEADER = 'cf-access-jwt-assertion';\n\nexport interface AccessOptions {\n /** `https://<team>.cloudflareaccess.com` — the issuer, and the JWKS host. */\n readonly teamDomain: string;\n /** The application's AUD tag, from Zero Trust → Access → Applications. */\n readonly audience: string;\n}\n\n/** The subset of Access claims worth depending on. */\nexport interface AccessIdentity {\n readonly email: string | undefined;\n readonly sub: string | undefined;\n /** Everything else, for callers that need a claim this interface does not name. */\n readonly claims: Readonly<Record<string, unknown>>;\n}\n\n/**\n * One JWKS per team domain, reused for the isolate's life.\n * ⚠️ Module scope is per-isolate on workerd, not per-request and not global. That is\n * exactly the right lifetime here: long enough to amortise the fetch, short enough\n * that a rotated key is picked up when the isolate recycles. jose also refreshes on\n * an unknown `kid`.\n */\nconst jwks = new Map<string, ReturnType<typeof createRemoteJWKSet>>();\n\nfunction keysFor(teamDomain: string): ReturnType<typeof createRemoteJWKSet> {\n const existing = jwks.get(teamDomain);\n if (existing !== undefined) return existing;\n\n // ⛔ THE BREAKER IS NOT OPTIONAL. jose refetches on EVERY call while an endpoint is\n // down — measured on 6.2.12: 5 verifications against a 404ing certs endpoint made 5\n // outbound fetches, and no combination of cacheMaxAge or cooldownDuration changes it.\n // Without this, a Cloudflare Access outage turns every inbound request into an\n // outbound one. See jwks-breaker.ts.\n const created = createRemoteJWKSet(new URL(`${teamDomain}/cdn-cgi/access/certs`), {\n [customFetch]: breakered(),\n });\n jwks.set(teamDomain, created);\n return created;\n}\n\n/**\n * Verify the Access JWT on `request`, returning the identity it asserts.\n *\n * ⛔ THROWS ON EVERY FAILURE — missing header, bad signature, wrong audience, expired.\n * ★ Deliberately not a boolean or a null: an `if (!ok)` that a caller forgets to write\n * is an open door, whereas an unhandled throw is a 500 and a log line. Callers that\n * want a soft path catch it explicitly.\n */\nexport async function verifyAccessJwt(\n request: Request,\n options: AccessOptions,\n): Promise<AccessIdentity> {\n const token = request.headers.get(ACCESS_HEADER);\n if (token === null) throw new Error('access: no Cf-Access-Jwt-Assertion header');\n\n const { payload } = await jwtVerify(token, keysFor(options.teamDomain), {\n issuer: options.teamDomain,\n audience: options.audience,\n });\n\n return {\n email: typeof payload['email'] === 'string' ? payload['email'] : undefined,\n sub: payload.sub,\n claims: payload,\n };\n}\n",
|
|
7
8
|
"/**\n * A circuit breaker for JWKS fetches, installed at jose's own `customFetch` seam.\n *\n * ⛔ THE AMPLIFICATION THIS CLOSES, MEASURED ON jose 6.2.12 — the version this package\n * pins, not an old one:\n *\n * 5 verifications of a well-formed token, certs endpoint 404ing → 5 outbound fetches\n *\n * jose assigns `jwksTimestamp` only inside the `.then()` of a SUCCESSFUL fetch\n * (`dist/webapi/jwks/remote.js`, line 58). While an endpoint is down, `local` stays\n * undefined, so `if (!local || !isFreshFor(jwksTimestamp, cacheMaxAge)) await reload()`\n * runs on EVERY call. A down Access endpoint therefore turns every inbound request into\n * an outbound fetch, indefinitely.\n *\n * ★ NOT A REIMPLEMENTATION. `customFetch` is jose's exported, documented extension point;\n * jose keeps doing the caching, cooldown, `kid` matching and verification. This adds the\n * one thing it does not do — remember that a fetch FAILED.\n * ⚠️ Measured first: neither `cacheMaxAge: Infinity` nor `cooldownDuration` nor both\n * together change the count. It is 5 fetches in every configuration, so this is not a\n * matter of configuring jose correctly.\n *\n * ⛔ THE BREAKER STORES PLAIN VALUES ONLY — a timestamp and an Error. NEVER a Response, a\n * body stream, or a promise of one.\n * 🔴 On workerd, a Response and its body belong to the IoContext of the request that\n * created them, and another request touching either throws:\n * Cannot perform I/O on behalf of a different request.\n * Caching a Response across requests would therefore turn a VALID token into a flat\n * 401 for every caller but the first — an outage strictly worse than the amplification\n * it was meant to fix. Timestamps and Errors cross requests legally; I/O objects do not.\n */\n\nimport type { FetchImplementation } from 'jose';\n\n/** How long a URL stays latched open after a failed probe. */\nexport const BREAKER_COOLDOWN_MS = 30_000;\n\ntype Failure = { readonly at: number; readonly error: Error };\n\n/**\n * ⚠️ Module scope is PER ISOLATE on workerd, which is the right lifetime: long enough to\n * stop the storm, short enough that a recycled isolate retries a recovered endpoint.\n */\nconst failures = new Map<string, Failure>();\n\n/** Visible for testing. ⛔ Not exported from the package — resetting it in production\n * would discard exactly the memory this exists to keep. */\nexport function resetBreaker(): void {\n failures.clear();\n}\n\n/**\n * Wrap a fetch so a failing URL is not retried for `cooldownMs`.\n *\n * ★ FAILURE IS COUNTED AT THE SEAM, not inferred from an error class further up. jose\n * throws `JWKSNoMatchingKey` as a NORMAL answer from a perfectly healthy endpoint — a\n * token signed with a rotated key does that — so treating it as a fault would latch a\n * working endpoint closed and reject every valid token for 30 seconds.\n */\nexport function breakered(\n inner: FetchImplementation = (url, options) => fetch(url, options),\n cooldownMs: number = BREAKER_COOLDOWN_MS,\n): FetchImplementation {\n // ⚠️ jose's OWN FetchImplementation, not `typeof fetch`. It is narrower — a string URL\n // and a fixed options shape — and using the global type instead fails to typecheck on\n // `preconnect`, a property of the platform fetch that no seam implementation has.\n return async (url, options) => {\n const open = failures.get(url);\n\n if (open !== undefined && Date.now() - open.at < cooldownMs) {\n // ⚠️ Rethrow the ORIGINAL error, not a generic one: the first failure says whether\n // this was a 404, a TLS problem or a timeout, and that is what an operator needs.\n // A \"circuit open\" message alone hides the actual fault.\n throw open.error;\n }\n\n try {\n const response = await inner(url, options);\n\n // ⛔ A non-200 is a failure here. jose requires 200 with a JSON body; a 404 or a 502\n // is exactly the down-endpoint case, and it does NOT throw on its own.\n if (!response.ok) {\n const error = new Error(`JWKS endpoint answered ${String(response.status)}: ${url}`);\n failures.set(url, { at: Date.now(), error });\n throw error;\n }\n\n failures.delete(url);\n return response;\n } catch (cause) {\n const error = cause instanceof Error ? cause : new Error(String(cause));\n // `set` is idempotent here — a second failure simply restarts the cooldown.\n failures.set(url, { at: Date.now(), error });\n throw error;\n }\n };\n}\n",
|
|
9
|
+
"/**\n * RFC 9728 discovery for an MCP server — the third auth path, and the one a client\n * actually needs to find its way in.\n *\n * ★ THE MCP SPEC REQUIRES THIS. A server MUST publish OAuth 2.0 Protected Resource\n * Metadata, and a client MUST use it to discover the authorization server. Without it a\n * client cannot begin the flow, and has to be told endpoints out of band — which is\n * exactly the gap that leaves real servers (Atlassian's, measured 2026) undiscoverable\n * by spec-compliant clients.\n *\n * ⛔ TWO HALVES, AND ONE ALONE DOES NOT WORK:\n * 1. `GET /.well-known/oauth-protected-resource` returns the metadata document.\n * 2. A 401 carries `WWW-Authenticate` naming that document's URL.\n * A client may start from either. Publishing the document but answering a bare 401 leaves\n * clients that follow the header — the common path — with nowhere to go.\n *\n * ⚠️ THE WELL-KNOWN URL IS DERIVED FROM THE RESOURCE URL, NOT CHOSEN. That derivation is a\n * security property, not a convention: it stops an attacker publishing metadata that\n * claims to describe a resource it has no authority over.\n *\n * ★ NO I/O AT MODULE SCOPE. Everything here is pure, because a Worker cannot make network\n * calls during module initialization — a metadata helper that fetched at import would\n * fail on the edge and nowhere else.\n */\n\n/** The subset of RFC 9728 an MCP server needs. Extra fields pass through untouched. */\nexport interface ProtectedResourceMetadata {\n /** The resource's own canonical URL. ⛔ Must match what clients call, exactly. */\n readonly resource: string;\n /** Authorization servers that issue tokens for it. */\n readonly authorization_servers: readonly string[];\n readonly scopes_supported?: readonly string[];\n readonly bearer_methods_supported?: readonly string[];\n readonly resource_documentation?: string;\n}\n\nexport interface McpMetadataOptions {\n /** The MCP endpoint clients call, e.g. `https://mcp.example.com/mcp`. */\n readonly resource: string;\n /**\n * The issuer. For Cloudflare's managed OAuth this is your team domain,\n * `https://<team>.cloudflareaccess.com`.\n */\n readonly authorizationServer: string;\n readonly scopes?: readonly string[];\n readonly documentation?: string;\n}\n\n/**\n * The well-known path for a resource URL, derived per RFC 9728.\n *\n * ⚠️ A RESOURCE WITH A PATH KEEPS IT, AFTER the well-known segment:\n * https://example.com/mcp → /.well-known/oauth-protected-resource/mcp\n * Serving it at the root instead is the mistake that makes a multi-resource server\n * advertise one resource's metadata for all of them.\n */\nexport function wellKnownPath(resource: string): string {\n const path = new URL(resource).pathname.replace(/\\/+$/, '');\n return path === ''\n ? '/.well-known/oauth-protected-resource'\n : `/.well-known/oauth-protected-resource${path}`;\n}\n\n/** Build the metadata document. Pure — no I/O, safe at module scope. */\nexport function protectedResourceMetadata(options: McpMetadataOptions): ProtectedResourceMetadata {\n return {\n resource: options.resource,\n authorization_servers: [options.authorizationServer],\n // ⚠️ `header` only. A bearer token in a query string lands in access logs and in\n // Referer, and RFC 6750 discourages it for exactly that reason.\n bearer_methods_supported: ['header'],\n ...(options.scopes === undefined ? {} : { scopes_supported: options.scopes }),\n ...(options.documentation === undefined\n ? {}\n : { resource_documentation: options.documentation }),\n };\n}\n\n/**\n * The 401 an unauthenticated MCP request must receive.\n *\n * ⛔ THE `resource_metadata` PARAMETER IS THE WHOLE POINT. A bare `WWW-Authenticate:\n * Bearer` tells a client it needs a token but not where to get one, so discovery dead-ends\n * and the client falls back to hardcoded endpoints — or fails.\n */\nexport function unauthorizedResponse(options: McpMetadataOptions): Response {\n const metadataUrl = new URL(wellKnownPath(options.resource), options.resource).href;\n\n return new Response(JSON.stringify({ error: 'unauthorized' }), {\n status: 401,\n headers: {\n 'content-type': 'application/json',\n 'www-authenticate': `Bearer resource_metadata=\"${metadataUrl}\"`,\n },\n });\n}\n\n/**\n * Serve the metadata document when the request is for it, otherwise `undefined`.\n *\n * ```ts\n * const discovery = serveMcpMetadata(request, { resource, authorizationServer });\n * if (discovery !== undefined) return discovery;\n * ```\n *\n * ⚠️ CORS IS OPEN HERE ON PURPOSE. Discovery metadata is public by definition — a client\n * must read it BEFORE it has a token, often from a browser origin the server has never\n * seen. Locking it down breaks the flow it exists to start.\n */\nexport function serveMcpMetadata(\n request: Request,\n options: McpMetadataOptions,\n): Response | undefined {\n const url = new URL(request.url);\n if (url.pathname !== wellKnownPath(options.resource)) return undefined;\n\n return new Response(JSON.stringify(protectedResourceMetadata(options), null, 2), {\n headers: {\n 'content-type': 'application/json',\n 'access-control-allow-origin': '*',\n 'cache-control': 'public, max-age=3600',\n },\n });\n}\n",
|
|
8
10
|
"/**\n * Structured logging for Workers.\n *\n * ★ WHY NO LOGGING LIBRARY. Cloudflare Workers Logs parses `console.log` output as JSON\n * natively and indexes every field, with unlimited cardinality — so a JSON object on\n * console IS the structured logging integration. pino is Node-shaped (streams, worker\n * threads) and needs a wrapper on workerd; `pino-pretty` does not work with wrangler\n * at all (workers-sdk#6841). Adding a dependency here would buy formatting we do not\n * want and cost bundle size on a hot path.\n * Docs: developers.cloudflare.com/workers/observability/logs/workers-logs/\n *\n * ⛔ NEVER LOG A CREDENTIAL. There is no redaction layer here and deliberately so — a\n * redactor implies it is safe to pass secrets in, and then quietly misses one.\n */\n\n/** Anything JSON-serialisable. Workers Logs indexes each key as its own field. */\nexport type LogFields = Readonly<Record<string, unknown>>;\n\nexport interface Logger {\n readonly debug: (message: string, fields?: LogFields) => void;\n readonly info: (message: string, fields?: LogFields) => void;\n readonly warn: (message: string, fields?: LogFields) => void;\n readonly error: (message: string, fields?: LogFields) => void;\n /** Returns a logger that merges `bound` into every subsequent line. */\n readonly with: (bound: LogFields) => Logger;\n}\n\ntype Level = 'debug' | 'info' | 'warn' | 'error';\n\nfunction emit(level: Level, message: string, fields: LogFields): void {\n // ⚠️ ONE OBJECT, NOT (message, fields). console.log with several arguments is\n // stringified and concatenated by the runtime, which destroys the structure Workers\n // Logs would otherwise index — the symptom is a `message` field containing your\n // whole payload as text, unqueryable.\n const line = { level, message, ...fields };\n\n if (level === 'error') console.error(line);\n else if (level === 'warn') console.warn(line);\n else console.log(line);\n}\n\nfunction make(bound: LogFields): Logger {\n return {\n debug: (message, fields) => emit('debug', message, { ...bound, ...fields }),\n info: (message, fields) => emit('info', message, { ...bound, ...fields }),\n warn: (message, fields) => emit('warn', message, { ...bound, ...fields }),\n error: (message, fields) => emit('error', message, { ...bound, ...fields }),\n with: (extra) => make({ ...bound, ...extra }),\n };\n}\n\n/**\n * The root logger. Bind request-scoped context rather than passing it every call:\n *\n * const requestLog = log.with({ requestId, path: url.pathname });\n * requestLog.info('handled', { status, ms });\n */\nexport const log: Logger = make({});\n"
|
|
9
11
|
],
|
|
10
|
-
"mappings": ";AACO,IAAM,UAAkB;;
|
|
11
|
-
"debugId": "
|
|
12
|
+
"mappings": "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",
|
|
13
|
+
"debugId": "4517D37BD676289464756E2164756E21",
|
|
12
14
|
"names": []
|
|
13
15
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9728 discovery for an MCP server — the third auth path, and the one a client
|
|
3
|
+
* actually needs to find its way in.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE MCP SPEC REQUIRES THIS. A server MUST publish OAuth 2.0 Protected Resource
|
|
6
|
+
* Metadata, and a client MUST use it to discover the authorization server. Without it a
|
|
7
|
+
* client cannot begin the flow, and has to be told endpoints out of band — which is
|
|
8
|
+
* exactly the gap that leaves real servers (Atlassian's, measured 2026) undiscoverable
|
|
9
|
+
* by spec-compliant clients.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ TWO HALVES, AND ONE ALONE DOES NOT WORK:
|
|
12
|
+
* 1. `GET /.well-known/oauth-protected-resource` returns the metadata document.
|
|
13
|
+
* 2. A 401 carries `WWW-Authenticate` naming that document's URL.
|
|
14
|
+
* A client may start from either. Publishing the document but answering a bare 401 leaves
|
|
15
|
+
* clients that follow the header — the common path — with nowhere to go.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ THE WELL-KNOWN URL IS DERIVED FROM THE RESOURCE URL, NOT CHOSEN. That derivation is a
|
|
18
|
+
* security property, not a convention: it stops an attacker publishing metadata that
|
|
19
|
+
* claims to describe a resource it has no authority over.
|
|
20
|
+
*
|
|
21
|
+
* ★ NO I/O AT MODULE SCOPE. Everything here is pure, because a Worker cannot make network
|
|
22
|
+
* calls during module initialization — a metadata helper that fetched at import would
|
|
23
|
+
* fail on the edge and nowhere else.
|
|
24
|
+
*/
|
|
25
|
+
/** The subset of RFC 9728 an MCP server needs. Extra fields pass through untouched. */
|
|
26
|
+
export interface ProtectedResourceMetadata {
|
|
27
|
+
/** The resource's own canonical URL. ⛔ Must match what clients call, exactly. */
|
|
28
|
+
readonly resource: string;
|
|
29
|
+
/** Authorization servers that issue tokens for it. */
|
|
30
|
+
readonly authorization_servers: readonly string[];
|
|
31
|
+
readonly scopes_supported?: readonly string[];
|
|
32
|
+
readonly bearer_methods_supported?: readonly string[];
|
|
33
|
+
readonly resource_documentation?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface McpMetadataOptions {
|
|
36
|
+
/** The MCP endpoint clients call, e.g. `https://mcp.example.com/mcp`. */
|
|
37
|
+
readonly resource: string;
|
|
38
|
+
/**
|
|
39
|
+
* The issuer. For Cloudflare's managed OAuth this is your team domain,
|
|
40
|
+
* `https://<team>.cloudflareaccess.com`.
|
|
41
|
+
*/
|
|
42
|
+
readonly authorizationServer: string;
|
|
43
|
+
readonly scopes?: readonly string[];
|
|
44
|
+
readonly documentation?: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The well-known path for a resource URL, derived per RFC 9728.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ A RESOURCE WITH A PATH KEEPS IT, AFTER the well-known segment:
|
|
50
|
+
* https://example.com/mcp → /.well-known/oauth-protected-resource/mcp
|
|
51
|
+
* Serving it at the root instead is the mistake that makes a multi-resource server
|
|
52
|
+
* advertise one resource's metadata for all of them.
|
|
53
|
+
*/
|
|
54
|
+
export declare function wellKnownPath(resource: string): string;
|
|
55
|
+
/** Build the metadata document. Pure — no I/O, safe at module scope. */
|
|
56
|
+
export declare function protectedResourceMetadata(options: McpMetadataOptions): ProtectedResourceMetadata;
|
|
57
|
+
/**
|
|
58
|
+
* The 401 an unauthenticated MCP request must receive.
|
|
59
|
+
*
|
|
60
|
+
* ⛔ THE `resource_metadata` PARAMETER IS THE WHOLE POINT. A bare `WWW-Authenticate:
|
|
61
|
+
* Bearer` tells a client it needs a token but not where to get one, so discovery dead-ends
|
|
62
|
+
* and the client falls back to hardcoded endpoints — or fails.
|
|
63
|
+
*/
|
|
64
|
+
export declare function unauthorizedResponse(options: McpMetadataOptions): Response;
|
|
65
|
+
/**
|
|
66
|
+
* Serve the metadata document when the request is for it, otherwise `undefined`.
|
|
67
|
+
*
|
|
68
|
+
* ```ts
|
|
69
|
+
* const discovery = serveMcpMetadata(request, { resource, authorizationServer });
|
|
70
|
+
* if (discovery !== undefined) return discovery;
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* ⚠️ CORS IS OPEN HERE ON PURPOSE. Discovery metadata is public by definition — a client
|
|
74
|
+
* must read it BEFORE it has a token, often from a browser origin the server has never
|
|
75
|
+
* seen. Locking it down breaks the flow it exists to start.
|
|
76
|
+
*/
|
|
77
|
+
export declare function serveMcpMetadata(request: Request, options: McpMetadataOptions): Response | undefined;
|
|
78
|
+
//# sourceMappingURL=mcp-metadata.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-metadata.d.ts","sourceRoot":"","sources":["../src/mcp-metadata.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,uFAAuF;AACvF,MAAM,WAAW,yBAAyB;IACxC,iFAAiF;IACjF,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,qBAAqB,EAAE,SAAS,MAAM,EAAE,CAAC;IAClD,QAAQ,CAAC,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC9C,QAAQ,CAAC,wBAAwB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtD,QAAQ,CAAC,sBAAsB,CAAC,EAAE,MAAM,CAAC;CAC1C;AAED,MAAM,WAAW,kBAAkB;IACjC,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,mBAAmB,EAAE,MAAM,CAAC;IACrC,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;CACjC;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKtD;AAED,wEAAwE;AACxE,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,kBAAkB,GAAG,yBAAyB,CAYhG;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,kBAAkB,GAAG,QAAQ,CAU1E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,kBAAkB,GAC1B,QAAQ,GAAG,SAAS,CAWtB"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/cloudflare",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cloudflare Workers helpers: Access
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Cloudflare Workers helpers: Access identity, MCP OAuth metadata, and structured logging.",
|
|
5
5
|
"homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/cloudflare#readme",
|
|
6
6
|
"bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
|
|
7
7
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"access": "public"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@homeflare/kit": "0.2.
|
|
33
|
+
"@homeflare/kit": "0.2.3",
|
|
34
34
|
"jose": "6.2.12"
|
|
35
35
|
}
|
|
36
36
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access identity WITHOUT parsing a JWT — the path Cloudflare added in August 2026.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHEN ACCESS PROTECTS A WORKER, the edge attaches the authenticated identity to the
|
|
5
|
+
* execution context: `ctx.access.getIdentity()` returns email, name and groups with no
|
|
6
|
+
* token handling at all. Verifying the assertion yourself is the OLDER path, and it is
|
|
7
|
+
* still correct for origins that have no `ctx.access` — see `verifyAccessJwt`.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THIS IS NOT A WEAKER CHECK. The edge has already refused the request if it did not
|
|
10
|
+
* pass the Access policy; `ctx.access` exists only on requests that passed. Parsing the
|
|
11
|
+
* JWT again re-verifies something the platform has already enforced.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ `ctx.access` IS NOT PROPAGATED THROUGH SERVICE BINDINGS OR RPC. A downstream Worker
|
|
14
|
+
* reached by binding sees `undefined`, even though the caller was authenticated — so a
|
|
15
|
+
* service-bound Worker that needs identity must be told, or must sit behind its own
|
|
16
|
+
* Access application. Measured from Cloudflare's own documentation, 2026-08-14.
|
|
17
|
+
*
|
|
18
|
+
* ★ GROUPS COME FROM get-identity, NOT FROM THE TOKEN. Cloudflare trims the JWT's `custom`
|
|
19
|
+
* claim at roughly 1 KB, silently, so group membership read from a token can be
|
|
20
|
+
* incomplete — which is an authorization bug that only appears for users in many groups.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What `ctx.access.getIdentity()` resolves. Narrowed to the fields worth depending on. */
|
|
24
|
+
export interface AccessIdentityInfo {
|
|
25
|
+
readonly email: string | undefined;
|
|
26
|
+
readonly name: string | undefined;
|
|
27
|
+
readonly groups: readonly string[];
|
|
28
|
+
/** Everything else the platform returned, for callers needing a field not named here. */
|
|
29
|
+
readonly raw: Readonly<Record<string, unknown>>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The slice of the execution context this module uses.
|
|
34
|
+
* ⚠️ Declared structurally rather than imported from `@cloudflare/workers-types`: this
|
|
35
|
+
* package should not force a types dependency on a consumer for one optional field.
|
|
36
|
+
*/
|
|
37
|
+
export interface AccessContext {
|
|
38
|
+
readonly access?: {
|
|
39
|
+
readonly aud?: string;
|
|
40
|
+
readonly getIdentity: () => Promise<Record<string, unknown> | null | undefined>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True when the request reached this Worker through Cloudflare Access. */
|
|
45
|
+
export function hasAccess(ctx: AccessContext): boolean {
|
|
46
|
+
return ctx.access !== undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The authenticated identity, or `undefined` when the request did not come through Access.
|
|
51
|
+
*
|
|
52
|
+
* ```ts
|
|
53
|
+
* const who = await accessIdentity(ctx);
|
|
54
|
+
* if (who === undefined) return new Response('Access did not run', { status: 401 });
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* ⛔ RETURNS undefined RATHER THAN THROWING, deliberately — unlike `verifyAccessJwt`. A
|
|
58
|
+
* Worker may legitimately serve both protected and open routes, so "no Access here" is
|
|
59
|
+
* a branch rather than a failure. The caller decides what an unauthenticated request
|
|
60
|
+
* means, and that decision is visible at the call site.
|
|
61
|
+
*/
|
|
62
|
+
export async function accessIdentity(ctx: AccessContext): Promise<AccessIdentityInfo | undefined> {
|
|
63
|
+
const access = ctx.access;
|
|
64
|
+
if (access === undefined) return undefined;
|
|
65
|
+
|
|
66
|
+
const raw = (await access.getIdentity()) ?? {};
|
|
67
|
+
const groups = raw['groups'];
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
email: typeof raw['email'] === 'string' ? raw['email'] : undefined,
|
|
71
|
+
name: typeof raw['name'] === 'string' ? raw['name'] : undefined,
|
|
72
|
+
// ⚠️ Always an array, never undefined: a caller writing `groups.includes(…)` on a
|
|
73
|
+
// missing field would throw at exactly the moment authorization is being decided.
|
|
74
|
+
groups: Array.isArray(groups) ? groups.filter((g): g is string => typeof g === 'string') : [],
|
|
75
|
+
raw,
|
|
76
|
+
};
|
|
77
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,31 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export { VERSION } from './version.ts';
|
|
12
|
+
/**
|
|
13
|
+
* ★ THREE AUTH PATHS, AND THEY ARE NOT ALTERNATIVES — pick by where your code runs:
|
|
14
|
+
*
|
|
15
|
+
* accessIdentity(ctx) A Worker BEHIND Access. The edge already enforced the policy
|
|
16
|
+
* and attached the identity; no token handling. Prefer this.
|
|
17
|
+
* verifyAccessJwt(req) An origin with no `ctx.access` — service-to-service, a
|
|
18
|
+
* non-Worker origin, or a Worker reached by service binding
|
|
19
|
+
* (⚠️ `ctx.access` does not propagate through bindings).
|
|
20
|
+
* serveMcpMetadata() An MCP server. Neither of the above helps a client DISCOVER
|
|
21
|
+
* how to authenticate; RFC 9728 is what does.
|
|
22
|
+
*/
|
|
23
|
+
export {
|
|
24
|
+
accessIdentity,
|
|
25
|
+
hasAccess,
|
|
26
|
+
type AccessContext,
|
|
27
|
+
type AccessIdentityInfo,
|
|
28
|
+
} from './access-identity.ts';
|
|
12
29
|
export { verifyAccessJwt, type AccessIdentity, type AccessOptions } from './access.ts';
|
|
30
|
+
export {
|
|
31
|
+
protectedResourceMetadata,
|
|
32
|
+
serveMcpMetadata,
|
|
33
|
+
unauthorizedResponse,
|
|
34
|
+
wellKnownPath,
|
|
35
|
+
type McpMetadataOptions,
|
|
36
|
+
type ProtectedResourceMetadata,
|
|
37
|
+
} from './mcp-metadata.ts';
|
|
13
38
|
export { log, type LogFields, type Logger } from './log.ts';
|
|
14
39
|
export { BREAKER_COOLDOWN_MS, breakered } from './jwks-breaker.ts';
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 9728 discovery for an MCP server — the third auth path, and the one a client
|
|
3
|
+
* actually needs to find its way in.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE MCP SPEC REQUIRES THIS. A server MUST publish OAuth 2.0 Protected Resource
|
|
6
|
+
* Metadata, and a client MUST use it to discover the authorization server. Without it a
|
|
7
|
+
* client cannot begin the flow, and has to be told endpoints out of band — which is
|
|
8
|
+
* exactly the gap that leaves real servers (Atlassian's, measured 2026) undiscoverable
|
|
9
|
+
* by spec-compliant clients.
|
|
10
|
+
*
|
|
11
|
+
* ⛔ TWO HALVES, AND ONE ALONE DOES NOT WORK:
|
|
12
|
+
* 1. `GET /.well-known/oauth-protected-resource` returns the metadata document.
|
|
13
|
+
* 2. A 401 carries `WWW-Authenticate` naming that document's URL.
|
|
14
|
+
* A client may start from either. Publishing the document but answering a bare 401 leaves
|
|
15
|
+
* clients that follow the header — the common path — with nowhere to go.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ THE WELL-KNOWN URL IS DERIVED FROM THE RESOURCE URL, NOT CHOSEN. That derivation is a
|
|
18
|
+
* security property, not a convention: it stops an attacker publishing metadata that
|
|
19
|
+
* claims to describe a resource it has no authority over.
|
|
20
|
+
*
|
|
21
|
+
* ★ NO I/O AT MODULE SCOPE. Everything here is pure, because a Worker cannot make network
|
|
22
|
+
* calls during module initialization — a metadata helper that fetched at import would
|
|
23
|
+
* fail on the edge and nowhere else.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** The subset of RFC 9728 an MCP server needs. Extra fields pass through untouched. */
|
|
27
|
+
export interface ProtectedResourceMetadata {
|
|
28
|
+
/** The resource's own canonical URL. ⛔ Must match what clients call, exactly. */
|
|
29
|
+
readonly resource: string;
|
|
30
|
+
/** Authorization servers that issue tokens for it. */
|
|
31
|
+
readonly authorization_servers: readonly string[];
|
|
32
|
+
readonly scopes_supported?: readonly string[];
|
|
33
|
+
readonly bearer_methods_supported?: readonly string[];
|
|
34
|
+
readonly resource_documentation?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface McpMetadataOptions {
|
|
38
|
+
/** The MCP endpoint clients call, e.g. `https://mcp.example.com/mcp`. */
|
|
39
|
+
readonly resource: string;
|
|
40
|
+
/**
|
|
41
|
+
* The issuer. For Cloudflare's managed OAuth this is your team domain,
|
|
42
|
+
* `https://<team>.cloudflareaccess.com`.
|
|
43
|
+
*/
|
|
44
|
+
readonly authorizationServer: string;
|
|
45
|
+
readonly scopes?: readonly string[];
|
|
46
|
+
readonly documentation?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The well-known path for a resource URL, derived per RFC 9728.
|
|
51
|
+
*
|
|
52
|
+
* ⚠️ A RESOURCE WITH A PATH KEEPS IT, AFTER the well-known segment:
|
|
53
|
+
* https://example.com/mcp → /.well-known/oauth-protected-resource/mcp
|
|
54
|
+
* Serving it at the root instead is the mistake that makes a multi-resource server
|
|
55
|
+
* advertise one resource's metadata for all of them.
|
|
56
|
+
*/
|
|
57
|
+
export function wellKnownPath(resource: string): string {
|
|
58
|
+
const path = new URL(resource).pathname.replace(/\/+$/, '');
|
|
59
|
+
return path === ''
|
|
60
|
+
? '/.well-known/oauth-protected-resource'
|
|
61
|
+
: `/.well-known/oauth-protected-resource${path}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Build the metadata document. Pure — no I/O, safe at module scope. */
|
|
65
|
+
export function protectedResourceMetadata(options: McpMetadataOptions): ProtectedResourceMetadata {
|
|
66
|
+
return {
|
|
67
|
+
resource: options.resource,
|
|
68
|
+
authorization_servers: [options.authorizationServer],
|
|
69
|
+
// ⚠️ `header` only. A bearer token in a query string lands in access logs and in
|
|
70
|
+
// Referer, and RFC 6750 discourages it for exactly that reason.
|
|
71
|
+
bearer_methods_supported: ['header'],
|
|
72
|
+
...(options.scopes === undefined ? {} : { scopes_supported: options.scopes }),
|
|
73
|
+
...(options.documentation === undefined
|
|
74
|
+
? {}
|
|
75
|
+
: { resource_documentation: options.documentation }),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The 401 an unauthenticated MCP request must receive.
|
|
81
|
+
*
|
|
82
|
+
* ⛔ THE `resource_metadata` PARAMETER IS THE WHOLE POINT. A bare `WWW-Authenticate:
|
|
83
|
+
* Bearer` tells a client it needs a token but not where to get one, so discovery dead-ends
|
|
84
|
+
* and the client falls back to hardcoded endpoints — or fails.
|
|
85
|
+
*/
|
|
86
|
+
export function unauthorizedResponse(options: McpMetadataOptions): Response {
|
|
87
|
+
const metadataUrl = new URL(wellKnownPath(options.resource), options.resource).href;
|
|
88
|
+
|
|
89
|
+
return new Response(JSON.stringify({ error: 'unauthorized' }), {
|
|
90
|
+
status: 401,
|
|
91
|
+
headers: {
|
|
92
|
+
'content-type': 'application/json',
|
|
93
|
+
'www-authenticate': `Bearer resource_metadata="${metadataUrl}"`,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Serve the metadata document when the request is for it, otherwise `undefined`.
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* const discovery = serveMcpMetadata(request, { resource, authorizationServer });
|
|
103
|
+
* if (discovery !== undefined) return discovery;
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ CORS IS OPEN HERE ON PURPOSE. Discovery metadata is public by definition — a client
|
|
107
|
+
* must read it BEFORE it has a token, often from a browser origin the server has never
|
|
108
|
+
* seen. Locking it down breaks the flow it exists to start.
|
|
109
|
+
*/
|
|
110
|
+
export function serveMcpMetadata(
|
|
111
|
+
request: Request,
|
|
112
|
+
options: McpMetadataOptions,
|
|
113
|
+
): Response | undefined {
|
|
114
|
+
const url = new URL(request.url);
|
|
115
|
+
if (url.pathname !== wellKnownPath(options.resource)) return undefined;
|
|
116
|
+
|
|
117
|
+
return new Response(JSON.stringify(protectedResourceMetadata(options), null, 2), {
|
|
118
|
+
headers: {
|
|
119
|
+
'content-type': 'application/json',
|
|
120
|
+
'access-control-allow-origin': '*',
|
|
121
|
+
'cache-control': 'public, max-age=3600',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.
|
|
2
|
-
export const VERSION: string = '0.
|
|
2
|
+
export const VERSION: string = '0.3.0';
|