@tpsdev-ai/flair 0.32.0 → 0.34.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.
@@ -0,0 +1,242 @@
1
+ /**
2
+ * oauth-discovery.ts — the ONE builder for every OAuth discovery document
3
+ * flair serves, the paths they are served at, and the request handlers that
4
+ * serve them. Deliberately free of any `harper` import so all of it is
5
+ * directly unit-testable; the Harper route registration lives next door in
6
+ * resources/oauth-wellknown.ts.
7
+ *
8
+ * Three surfaces render these documents:
9
+ *
10
+ * - `GET /OAuthMetadata` (resources/OAuth.ts, historical path)
11
+ * - `GET /.well-known/oauth-authorization-server` (RFC 8414)
12
+ * - `GET /.well-known/oauth-protected-resource` (RFC 9728)
13
+ *
14
+ * The first two return the SAME object from the SAME function — `/OAuthMetadata`
15
+ * is an ALIAS, not a second implementation. Two endpoints that can drift apart
16
+ * is the defect this module exists to make impossible: a field added to the
17
+ * authorization-server document appears at both paths or at neither, and there
18
+ * is no code path that can produce one without the other.
19
+ *
20
+ * ── Issuer derivation is NOT redefined here ─────────────────────────────────
21
+ * `oauthPublicBaseUrl()` is the expression `OAuthMetadata.get()` already used,
22
+ * moved verbatim: `FLAIR_PUBLIC_URL`, else the loopback bind address. Operators
23
+ * MUST set `FLAIR_PUBLIC_URL` on any non-loopback deployment (docs/deploying-on-
24
+ * fabric.md, resources/AdminInstance.ts) or every URL in every one of these
25
+ * documents points at the CLIENT's own localhost. That requirement, and the
26
+ * `loadEnv` declaration that makes a component `.env` actually reach
27
+ * `process.env`, shipped in flair#1005 — deliberately untouched here.
28
+ */
29
+ import { mcpOAuthEnabled } from "./mcp-oauth-flag.js";
30
+ import { dcrEnabled } from "./dcr-gate.js";
31
+ /** RFC 9728 §3.1 — Protected Resource Metadata well-known path. */
32
+ export const PRM_PATH = "/.well-known/oauth-protected-resource";
33
+ /** RFC 8414 §3 — Authorization Server Metadata well-known path. */
34
+ export const AS_METADATA_PATH = "/.well-known/oauth-authorization-server";
35
+ /**
36
+ * The public origin every URL in every discovery document derives from.
37
+ *
38
+ * Verbatim the expression `OAuthMetadata.get()` carried before this module
39
+ * existed — moved, not changed. See the header note on flair#1005.
40
+ */
41
+ export function oauthPublicBaseUrl() {
42
+ return process.env.FLAIR_PUBLIC_URL || `http://127.0.0.1:${process.env.HTTP_PORT || 19926}`;
43
+ }
44
+ /**
45
+ * The RFC 8707 resource identifier of flair's MCP surface: `<base>/mcp`.
46
+ *
47
+ * Matches `mcpResource()` in resources/mcp-oauth-flag.ts by construction —
48
+ * that is the URI `withMCPAuth` audience-binds tokens to when the Model-2
49
+ * surface is enabled, so the protected-resource document must name the same
50
+ * string or a client would audience-bind its token to something `/mcp` rejects.
51
+ *
52
+ * It names `/mcp` even when `FLAIR_MCP_OAUTH` is off and `/mcp` therefore
53
+ * 404s. That is a coherent state, not a lie: RFC 9728 metadata describes how a
54
+ * resource WOULD be authorized; a client that discovers the authorization
55
+ * server and then finds no `/mcp` has learned something true. The alternative —
56
+ * naming the origin itself as the protected resource — would be the actual
57
+ * lie, because no flair REST resource accepts a bearer token (see
58
+ * resources/oauth-wellknown.ts's header for that measurement).
59
+ */
60
+ export function mcpResourceUri(baseUrl = oauthPublicBaseUrl()) {
61
+ return `${baseUrl.replace(/\/+$/, "")}/mcp`;
62
+ }
63
+ /**
64
+ * RFC 8414 Authorization Server Metadata — flair's own OAuth 2.1 AS
65
+ * (resources/OAuth.ts): `/OAuthAuthorize`, `/OAuthToken`, `/OAuthRegister`,
66
+ * `/OAuthRevoke`.
67
+ *
68
+ * Served at BOTH `/.well-known/oauth-authorization-server` and `/OAuthMetadata`.
69
+ *
70
+ * `registration_endpoint` is present ONLY while dynamic client registration is
71
+ * actually enabled (resources/dcr-gate.ts). RFC 8414 s2 makes the field
72
+ * OPTIONAL, and advertising an endpoint that refuses every request is a
73
+ * discovery document that misdirects: a client would follow it, be refused, and
74
+ * have learned nothing it can act on. Omitting it tells the truth — this server
75
+ * does not do dynamic registration — which is a state a spec-compliant client
76
+ * already knows how to handle.
77
+ */
78
+ export function buildAuthorizationServerMetadata(baseUrl = oauthPublicBaseUrl()) {
79
+ return {
80
+ issuer: baseUrl,
81
+ authorization_endpoint: `${baseUrl}/OAuthAuthorize`,
82
+ token_endpoint: `${baseUrl}/OAuthToken`,
83
+ ...(dcrEnabled() ? { registration_endpoint: `${baseUrl}/OAuthRegister` } : {}),
84
+ revocation_endpoint: `${baseUrl}/OAuthRevoke`,
85
+ response_types_supported: ["code"],
86
+ grant_types_supported: [
87
+ "authorization_code",
88
+ "refresh_token",
89
+ "urn:ietf:params:oauth:grant-type:jwt-bearer",
90
+ ],
91
+ token_endpoint_auth_methods_supported: ["none", "client_secret_basic"],
92
+ code_challenge_methods_supported: ["S256"],
93
+ scopes_supported: [
94
+ "memory:read", "memory:write", "memory:admin",
95
+ "principal:read", "principal:admin",
96
+ "connector:read", "connector:admin",
97
+ ],
98
+ extensions_supported: [
99
+ "io.modelcontextprotocol/enterprise-managed-authorization",
100
+ ],
101
+ };
102
+ }
103
+ /**
104
+ * RFC 9728 Protected Resource Metadata for flair's MCP surface.
105
+ *
106
+ * `authorization_servers` names the SAME `issuer` string the authorization-
107
+ * server document publishes — both come from `oauthPublicBaseUrl()`, so a
108
+ * client that follows `authorization_servers[0]` +
109
+ * `/.well-known/oauth-authorization-server` lands on a document whose `issuer`
110
+ * matches what sent it there. That loop closing is asserted end-to-end in
111
+ * test/integration/oauth-wellknown-e2e.test.ts.
112
+ *
113
+ * `scopes_supported` is deliberately the same list the AS advertises rather
114
+ * than a second, narrower one — a resource advertising scopes the AS cannot
115
+ * issue would send clients into a guaranteed `invalid_scope`.
116
+ */
117
+ export function buildProtectedResourceMetadata(baseUrl = oauthPublicBaseUrl()) {
118
+ const as = buildAuthorizationServerMetadata(baseUrl);
119
+ return {
120
+ resource: mcpResourceUri(baseUrl),
121
+ authorization_servers: [as.issuer],
122
+ // RFC 9728 §2 — the only presentation method `withMCPAuth` accepts is the
123
+ // Authorization header; it never reads a form or query-parameter token.
124
+ bearer_methods_supported: ["header"],
125
+ scopes_supported: as.scopes_supported,
126
+ };
127
+ }
128
+ // ─── Path screening ──────────────────────────────────────────────────────────
129
+ //
130
+ // Harper's `server.http({ urlPath })` matching is prefix-based and passes the
131
+ // path RELATIVE to the mount, so every sub-path of a mount reaches its handler
132
+ // and has to be screened here — otherwise
133
+ // `/.well-known/oauth-protected-resource/anything` would answer with flair's
134
+ // document.
135
+ /**
136
+ * Does a request address the protected-resource document?
137
+ *
138
+ * Accepted:
139
+ * - `/` the bare well-known path
140
+ * - `/mcp` RFC 9728 §3.1 path-insertion for the resource `<base>/mcp`. MCP
141
+ * clients (Claude.ai among them) build the PRM URL by inserting
142
+ * the resource's path component and fetch THIS form, so without
143
+ * it the discovery loop 404s on the only URL a real client asks for.
144
+ * - the absolute forms of both, for Harper builds that pass an unstripped path.
145
+ *
146
+ * Exact-after-normalization, never a prefix test — `/mcp-evil` must not match
147
+ * `/mcp`.
148
+ */
149
+ export function prmPathMatches(relativePath, baseUrl = oauthPublicBaseUrl()) {
150
+ const path = normalizePath(relativePath);
151
+ if (path === "/" || path === PRM_PATH)
152
+ return true;
153
+ const resourcePath = resourcePathOf(baseUrl);
154
+ if (!resourcePath)
155
+ return false;
156
+ return path === resourcePath || path === PRM_PATH + resourcePath;
157
+ }
158
+ /**
159
+ * Same screen for the authorization-server document. RFC 8414 §3.1 path-
160
+ * insertion applies to an issuer that CARRIES a path component; flair's issuer
161
+ * is always an origin, so only the bare path is valid here and every sub-path
162
+ * is a 404.
163
+ */
164
+ export function asMetadataPathMatches(relativePath) {
165
+ const path = normalizePath(relativePath);
166
+ return path === "/" || path === AS_METADATA_PATH;
167
+ }
168
+ /** Strip a single trailing slash so `/mcp/` and `/mcp` screen identically. */
169
+ function normalizePath(path) {
170
+ if (!path)
171
+ return "/";
172
+ return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
173
+ }
174
+ /** The path component of the protected resource URI (`/mcp`), or "" if unparseable. */
175
+ function resourcePathOf(baseUrl) {
176
+ try {
177
+ const { pathname } = new URL(mcpResourceUri(baseUrl));
178
+ return pathname && pathname !== "/" ? pathname.replace(/\/+$/, "") : "";
179
+ }
180
+ catch {
181
+ return "";
182
+ }
183
+ }
184
+ // ─── Handlers ────────────────────────────────────────────────────────────────
185
+ function discoveryResponse(body) {
186
+ return new Response(JSON.stringify(body), {
187
+ status: 200,
188
+ headers: {
189
+ "content-type": "application/json",
190
+ // Discovery documents are unauthenticated, non-secret, and fetched
191
+ // cross-origin by browser-based MCP clients and inspectors. Simple `*` is
192
+ // sufficient because no credentials are ever involved. Matches what
193
+ // @harperfast/oauth sets on the same documents.
194
+ "access-control-allow-origin": "*",
195
+ "access-control-allow-methods": "GET, OPTIONS",
196
+ },
197
+ });
198
+ }
199
+ function notFound() {
200
+ return new Response(JSON.stringify({ error: "not_found" }), {
201
+ status: 404,
202
+ headers: { "content-type": "application/json" },
203
+ });
204
+ }
205
+ /** The mount-relative path Harper hands the handler, query string removed. */
206
+ export function relativePathOf(request) {
207
+ const raw = request?.pathname ?? request?.url ?? "/";
208
+ const q = raw.indexOf("?");
209
+ return q >= 0 ? raw.slice(0, q) : raw;
210
+ }
211
+ /**
212
+ * Build the handler for one well-known document.
213
+ *
214
+ * Three behaviours, in order:
215
+ *
216
+ * 1. A path under the mount that is NOT one of the accepted discovery forms
217
+ * gets a 404 from HERE — it is never passed to `next`. A urlPath mount has
218
+ * its OWN dispatch chain that contains neither flair's auth-middleware nor
219
+ * Harper's `authentication`, and `next` strips the mount prefix, so passing
220
+ * an arbitrary sub-path onward is how a discovery mount turns into a path-
221
+ * confusion hole. Screen, then answer; never forward.
222
+ * 2. `FLAIR_MCP_OAUTH` on → fall through for the accepted forms, so
223
+ * @harperfast/oauth's own well-known handlers (identical urlPath, same
224
+ * dispatch group) answer for the surface they actually guard. See
225
+ * resources/oauth-wellknown.ts's header for why flair's AS must not be
226
+ * advertised in that state.
227
+ * 3. Otherwise serve the document.
228
+ *
229
+ * Non-GET/HEAD is a 404 too: a POST to a discovery path is not a discovery
230
+ * request and must not be answered with a 200 body.
231
+ */
232
+ export function makeWellKnownHandler(matches, build, isMcpOAuthEnabled = mcpOAuthEnabled) {
233
+ return async (request, next) => {
234
+ const method = String(request?.method ?? "GET").toUpperCase();
235
+ if (!matches(relativePathOf(request)) || (method !== "GET" && method !== "HEAD")) {
236
+ return notFound();
237
+ }
238
+ if (isMcpOAuthEnabled())
239
+ return next(request);
240
+ return discoveryResponse(build());
241
+ };
242
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * oauth-wellknown.ts — mounts flair's OAuth discovery documents at the two
3
+ * well-known paths every spec-compliant client probes (flair#1000 item 2).
4
+ *
5
+ * Before this, flair published a correct authorization-server document at
6
+ * `/OAuthMetadata` — a path nothing in the ecosystem asks for — and 404'd at
7
+ * `/.well-known/oauth-authorization-server` (RFC 8414) and
8
+ * `/.well-known/oauth-protected-resource` (RFC 9728, which the MCP
9
+ * authorization specification makes a MUST). A remote MCP client could not
10
+ * discover flair no matter what else was configured.
11
+ *
12
+ * The documents themselves, and every path-screening decision, live in
13
+ * resources/oauth-discovery.ts — this file is only the Harper wiring.
14
+ *
15
+ * ── Why `server.http({ urlPath })` and not a Resource ───────────────────────
16
+ * Harper's REST layer maps a Resource CLASS NAME to a path segment; no class
17
+ * name produces `/.well-known/oauth-protected-resource`. A urlPath mount is the
18
+ * supported route for paths outside the Resource naming scheme — the same
19
+ * mechanism resources/mcp-oauth.ts uses for `/mcp` and @harperfast/oauth uses
20
+ * for its own well-known documents.
21
+ *
22
+ * A urlPath mount gets its OWN dispatch chain, so neither flair's
23
+ * auth-middleware nor Harper's `authentication` middleware runs for these
24
+ * paths. That is required, not incidental: RFC 8414 §3 and RFC 9728 §3 both
25
+ * require the documents be retrievable WITHOUT authentication, and Harper's
26
+ * auth layer stamps `WWW-Authenticate: Basic` on any 401 it wraps.
27
+ * (`/.well-known/oauth-authorization-server` remains in auth-middleware.ts's
28
+ * public allowlist; that entry is now belt-and-braces — a request for it never
29
+ * reaches the default chain.)
30
+ *
31
+ * ── Deference to @harperfast/oauth when the Model-2 surface is on ───────────
32
+ * `FLAIR_MCP_OAUTH` mounts an OAuth-guarded `/mcp` wrapped in the plugin's
33
+ * `withMCPAuth` (resources/mcp-oauth.ts). Tokens for THAT surface are minted by
34
+ * the PLUGIN's authorization server (`/oauth/mcp/token`, JWTs verified against
35
+ * its JWKS), NOT by flair's own OAuth 2.1 AS, which mints opaque `flair_at_…`
36
+ * strings the guard rejects. Advertising flair's AS as the authorization server
37
+ * for `/mcp` in that state would hand a client a token `/mcp` is guaranteed to
38
+ * refuse.
39
+ *
40
+ * So when the flag is on these handlers serve nothing and fall through, and the
41
+ * plugin's own well-known handlers (registered by its `handleApplication` when
42
+ * an operator declares the `'@harperfast/oauth'` component) answer with the
43
+ * document describing the AS that actually guards the surface. Making this a
44
+ * function of flair's own flag keeps it deterministic: two handlers mounted on
45
+ * an identical urlPath share one dispatch group and run in REGISTRATION order,
46
+ * so without an explicit rule the answer would depend on where an operator
47
+ * happened to put a key in config.yaml.
48
+ *
49
+ * Gap named rather than papered over: flag ON with the plugin component NOT
50
+ * declared serves neither document (404, exactly as before this change). That
51
+ * instance is already non-functional — `/mcp` is guarded by a verifier with no
52
+ * authorization server behind it, so there is no token to discover.
53
+ *
54
+ * ── What this does NOT change: the 401 challenge (flair#1000 item 3) ────────
55
+ * flair's REST surface deliberately keeps the challenge it has, because a
56
+ * Bearer challenge there would be FALSE. A bearer token cannot reach a flair
57
+ * resource at all: Harper's own auth layer claims every `Bearer …` header for
58
+ * itself and validates it as a Harper OPERATION token, so
59
+ * `Authorization: Bearer <anything>` answers 401 `{"error":"invalid token"}`
60
+ * before any code under resources/ runs (measured against a live instance; the
61
+ * strategy switch is in node_modules/harper/dist/security/auth.js). flair's own
62
+ * `/OAuthToken` mints opaque `flair_at_…` values that nothing under resources/
63
+ * ever validates, so there is not even a token that WOULD work.
64
+ *
65
+ * Advertising `WWW-Authenticate: Bearer resource_metadata=…` on `/Memory` would
66
+ * therefore announce, per RFC 7235 §4.1, a scheme usable at that resource, and
67
+ * send every MCP client that believed it around a loop that ends in exactly the
68
+ * same 401 with no recovery — strictly worse than the honest challenge, because
69
+ * it misdirects instead of refusing.
70
+ *
71
+ * It would also mean rewriting headers on EVERY 401 in the instance. Harper's
72
+ * auth layer owns that header for every 401 raised at or below it —
73
+ * `response.headers.set('WWW-Authenticate', 'Basic')`, a set and not an append
74
+ * (security/auth.js). flair's middleware is registered ahead of it (config.yaml
75
+ * orders `jsResource` before `authentication`) so it *could* override on the way
76
+ * out — verified by `/Admin` keeping its own `Basic realm="Flair Admin"` — but
77
+ * only by awaiting and inspecting every response on the hottest path in the
78
+ * chain, for every existing Basic and Ed25519 caller, to publish a scheme none
79
+ * of them can use.
80
+ *
81
+ * The challenge belongs on the one surface that DOES take a bearer token —
82
+ * `/mcp` — and it is already there: with `FLAIR_MCP_OAUTH` on, `withMCPAuth`
83
+ * answers `POST /mcp` with
84
+ * `401 WWW-Authenticate: Bearer resource_metadata="<issuer>/.well-known/oauth-protected-resource/mcp"`.
85
+ * Until this change that URL 404'd, which is the sense in which item 3 was
86
+ * broken end to end: the challenge existed and pointed at nothing. flair now
87
+ * answers that exact path (and @harperfast/oauth answers it when the flag is on
88
+ * and the plugin is declared), so the discovery loop closes.
89
+ */
90
+ import { server } from "harper";
91
+ import { AS_METADATA_PATH, PRM_PATH, asMetadataPathMatches, buildAuthorizationServerMetadata, buildProtectedResourceMetadata, makeWellKnownHandler, prmPathMatches, } from "./oauth-discovery.js";
92
+ import { mcpOAuthEnabled } from "./mcp-oauth-flag.js";
93
+ /**
94
+ * Mount both documents. Called once at module load, and directly from tests.
95
+ * Returns the urlPaths mounted so a caller can assert on them.
96
+ */
97
+ export function registerOAuthWellKnownRoutes(deps = {}) {
98
+ const srv = deps.server ?? server;
99
+ if (typeof srv?.http !== "function")
100
+ return [];
101
+ const enabled = deps.isMcpOAuthEnabled ?? mcpOAuthEnabled;
102
+ srv.http(makeWellKnownHandler(prmPathMatches, buildProtectedResourceMetadata, enabled), { urlPath: PRM_PATH });
103
+ srv.http(makeWellKnownHandler(asMetadataPathMatches, buildAuthorizationServerMetadata, enabled), { urlPath: AS_METADATA_PATH });
104
+ return [PRM_PATH, AS_METADATA_PATH];
105
+ }
106
+ // Registered at module load, like resources/auth-middleware.ts. The opt-out
107
+ // exists only so a unit test can import this module under a partial `harper`
108
+ // mock without registering routes; production never sets it.
109
+ if (process.env.FLAIR_WELLKNOWN_NO_AUTOSTART == null) {
110
+ registerOAuthWellKnownRoutes();
111
+ }