diff --git a/README.md b/README.md index 027c51d..50d9b66 100644 --- a/README.md +++ b/README.md @@ -298,12 +298,75 @@ tokens carry `aud: shared-memory` (or whatever value you chose). or `Confidential` if you prefer to issue a secret to each Claude Code install — both work. Phase 1 expects Public. - **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP` -- **Redirect URIs:** Claude Code prints the exact value when it first - connects to the MCP endpoint. Paste it into Authentik then. +- **Redirect URIs:** more than one, and which ones depends on how people + reach the server — see **Which redirect URIs to register** below. - **Scopes:** `openid`, `profile`, `email` (plus `offline_access` — see **Keeping sessions alive** below) - **Signing Key:** same cert as the Web provider +#### Which redirect URIs to register + +The MCP provider is reached by clients of two different shapes — one running on +your machine, one running inside claude.ai — and they come back from the IdP at +**different** redirect URIs. Register every one you intend to use, before the +first connection attempt: + +- **Claude Code CLI, including this repo's plugin** — the CLI catches the + callback on a loopback listener, so the URI is + `http://localhost:/callback`, where `` is whatever + `--callback-port` (or the plugin's `callbackPort`) is set to. Set the entry's + matching mode to **Regex** so any port works without re-registering: + + ``` + ^http://(127\.0\.0\.1|localhost):\d+(/.*)?$ + ``` + + **The port is not optional.** Authentik rejects a portless + `http://localhost/callback`, so an entry copied from the Entra ID walkthrough + — where the port component is ignored on purpose, see + [docs/oidc-entra-id.md](docs/oidc-entra-id.md) — matches nothing the CLI ever + sends. + +- **A claude.ai custom connector** — the server added through claude.ai's web + UI rather than installed locally. claude.ai brokers the OAuth flow, so the + browser never returns to your machine and the loopback entries above are + irrelevant. Register the exact string: + + ``` + https://claude.ai/api/mcp/auth_callback + ``` + +- **The manual-paste fallback** (*C. Manual-paste fallback* below) — that + callback is hosted by *this* server, not by the client: + + ``` + https://memory.example.com/auth/cli-callback + ``` + +> **A missing entry shows up as Authentik's "Redirect URI Error" page** — *"The +> request fails due to a missing, invalid, or mismatching redirection URI +> (redirect_uri)"* — served **after** the client sends you to Authentik but +> **before** any login or consent screen. Nothing in the client says which URI +> was rejected, so it reads as "the connector is broken" when the provider +> simply has no entry matching what was sent. Adding the server as a claude.ai +> connector without the `https://claude.ai/api/mcp/auth_callback` entry is the +> common way to land here. +> +> **Nothing registers these for you — on a FOSS instance.** Authentik *does* +> implement RFC 7591 Dynamic Client Registration +> ([goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751), +> closed July 2026), but gated behind an **enterprise** licence; a maintainer +> has since said it will move to the open-source build. Until it does, a FOSS +> instance advertises no `registration_endpoint` at all. Check yours: +> +> ``` +> curl -s "${OIDC_ISSUER_MCP}.well-known/openid-configuration" | jq .registration_endpoint +> ``` +> +> `null` means no client — CLI or claude.ai — can add its own redirect URI, so +> every URI above is typed into the provider by hand. (Same gap as **Why no +> zero-config plugin yet** below.) + #### Keeping sessions alive (`offline_access`) Without this, a connected MCP client gets an access token and **no refresh @@ -413,6 +476,13 @@ prompt, never reaching the app. Three paths, in order of preference: +> **Connecting from claude.ai instead?** A server added there as a *custom +> connector* needs nothing on your machine, but its OAuth callback is +> `https://claude.ai/api/mcp/auth_callback`, not a loopback URI. Register it on +> the MCP provider first (**Which redirect URIs to register** above) or the +> connector stops at Authentik's *Redirect URI Error* page before you ever see +> a login prompt. + ### A. Plugin (recommended — one command, no flags to remember) This repo doubles as a Claude Code plugin marketplace. `plugin/.mcp.json` ships a @@ -501,8 +571,8 @@ What happens: `--callback-port` is required because your IdP only accepts pre-registered redirect URIs. Pick any free port; just make sure the matching URI is in your MCP client's **Redirect URIs** list. Authentik users with the regex -pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`) -can use any port without re-registering. +entry from the setup step (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`) can +use any port without re-registering. ### C. Manual-paste fallback (when loopback isn't reachable) @@ -725,6 +795,14 @@ reopen a closed question. scope mapping; on EntraID it's the API "Application ID URI"; on Keycloak it's a client-scope audience mapper. See **Setting the `aud` claim** above for the Authentik recipe; other IdPs need the equivalent in their UI. +- **Authentik shows "Redirect URI Error — The request fails due to a missing, + invalid, or mismatching redirection URI (redirect_uri)"** — the redirect URI + the client sent is not registered on the `shared-memory-mcp` provider. From a + claude.ai custom connector the missing entry is + `https://claude.ai/api/mcp/auth_callback`; from the CLI it's the loopback URI + for your `--callback-port`, and a portless `http://localhost/callback` entry + will not match it. Authentik has no Dynamic Client Registration, so no client + can add the URI itself — see **Which redirect URIs to register** above. - **Auth.js callback fails with `OAUTH_CALLBACK_ERROR`** — your `PUBLIC_URL` doesn't match the redirect URI your IdP is configured with. They must be exactly equal, scheme and trailing slash included. diff --git a/apps/web/app/.well-known/oauth-protected-resource/[...path]/route.test.ts b/apps/web/app/.well-known/oauth-protected-resource/[...path]/route.test.ts new file mode 100644 index 0000000..3748743 --- /dev/null +++ b/apps/web/app/.well-known/oauth-protected-resource/[...path]/route.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +/** + * RFC 9728 §3.1 puts the metadata for `https://host/api/mcp` at + * `https://host/.well-known/oauth-protected-resource/api/mcp`. MCP clients + * that derive that URL from the endpoint URL — instead of following the + * `resource_metadata` parameter on our 401 — used to receive the Next.js 404 + * HTML page here, so discovery died on a JSON parse error. + * + * Two things therefore have to hold, and both are easy to break silently: + * the document must name the SUFFIXED resource (§3.3 has the client reject a + * document whose `resource` isn't the identifier it asked about), and it must + * stay byte-for-byte in step with the root document's scope list, because a + * scope missing from whichever document a given client reads is a scope that + * client will never request. + */ + +type Metadata = { resource: string; scopes_supported: string[] }; + +async function fetchSuffixed( + segments: string[], +): Promise<{ status: number; body: Metadata }> { + vi.resetModules(); + const { GET } = await import( + "@/app/.well-known/oauth-protected-resource/[...path]/route" + ); + const res = await GET(new Request("http://localhost/ignored"), { + params: Promise.resolve({ path: segments }), + }); + return { status: res.status, body: (await res.json()) as Metadata }; +} + +async function fetchRoot(): Promise { + vi.resetModules(); + const { GET } = await import("@/app/.well-known/oauth-protected-resource/route"); + return (await GET().json()) as Metadata; +} + +afterEach(() => { + delete process.env.OIDC_OFFLINE_ACCESS; + delete process.env.OIDC_AUDIENCE_SCOPE; +}); + +describe("path-suffixed oauth-protected-resource metadata", () => { + test("serves the MCP endpoint's document with the suffixed resource identifier", async () => { + const { status, body } = await fetchSuffixed(["api", "mcp"]); + + expect(status).toBe(200); + // §3.3: a strict client compares this against the identifier it asked + // about, so the bare origin would get the whole document rejected. + expect(body.resource).toBe("http://localhost:3000/api/mcp"); + }); + + test("404s for a path this app does not serve, rather than advertising it", async () => { + // The allowlist exists so we never claim that arbitrary paths are + // OAuth-protected resources of this deployment. + const { status } = await fetchSuffixed(["api", "not-mcp"]); + + expect(status).toBe(404); + }); + + test("advertises exactly the scopes the root document does", async () => { + // Regression guard against the two documents drifting apart: the audience + // scope is what makes `aud` appear on the token at all, and a client that + // discovered us through the suffixed URL would never request a scope that + // only the root document lists. + process.env.OIDC_OFFLINE_ACCESS = "true"; + + const root = await fetchRoot(); + const { body: suffixed } = await fetchSuffixed(["api", "mcp"]); + + expect(suffixed.scopes_supported).toEqual(root.scopes_supported); + expect(suffixed.scopes_supported).toContain("aud-test-audience"); + expect(suffixed.scopes_supported).toContain("offline_access"); + }); + + test("honours an explicit audience scope name, like the root document", async () => { + process.env.OIDC_AUDIENCE_SCOPE = "custom-aud-scope"; + + const root = await fetchRoot(); + const { body: suffixed } = await fetchSuffixed(["api", "mcp"]); + + expect(suffixed.scopes_supported).toContain("custom-aud-scope"); + expect(suffixed.scopes_supported).toEqual(root.scopes_supported); + }); +}); diff --git a/apps/web/app/.well-known/oauth-protected-resource/[...path]/route.ts b/apps/web/app/.well-known/oauth-protected-resource/[...path]/route.ts new file mode 100644 index 0000000..47d38b4 --- /dev/null +++ b/apps/web/app/.well-known/oauth-protected-resource/[...path]/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * Resource paths this deployment will publish metadata for. + * + * An allowlist rather than a wildcard, for two reasons. RFC 9728 §3.1 maps a + * metadata URL to one specific protected resource, so answering for arbitrary + * paths would advertise resources this app does not serve — a client could + * "discover" `https://host/anything` as an OAuth-protected resource and be + * told, wrongly, that tokens for it are obtainable from our IdP. And every + * path that answers is surface: a wildcard turns this into an open reflector + * that echoes attacker-chosen path segments back inside a JSON document. + * + * `api/mcp` is the only MCP endpoint here (app/api/mcp/route.ts). Add an + * entry when a second one ships — not before. + */ +const METADATA_RESOURCE_PATHS: ReadonlySet = new Set(["api/mcp"]); + +/** + * RFC 9728 §3.1 — path-suffixed protected resource metadata. + * + * For a resource identified by `https://host/api/mcp`, the spec puts its + * metadata at `https://host/.well-known/oauth-protected-resource/api/mcp`: + * the resource's path is appended to the well-known path. Clients that derive + * the metadata URL from the MCP endpoint URL — rather than reading + * `resource_metadata` off our 401's `WWW-Authenticate` header — probe that URL + * first, and before this route existed they got Next.js's 404 HTML page, which + * fails discovery with a JSON parse error rather than anything diagnosable. + * + * The document is identical to the root one except for `resource`, which must + * name the suffixed identifier: §3.3 requires the client to check that the + * returned `resource` equals the identifier it asked about, so echoing the + * bare origin here would make a strict client reject the document outright. + */ +export async function GET( + _req: Request, + ctx: { params: Promise<{ path: string[] }> }, +): Promise { + const { path } = await ctx.params; + // Next splits the matched suffix on literal `/` and only then decodes each + // piece, so a segment can be empty (`api//mcp` -> ["api","","mcp"]) and a + // single segment can itself contain a decoded slash (`api%2Fmcp` -> one + // element, "api/mcp"). Join and compare on the same normalized form the + // allowlist is written in, and let anything else fail closed. + const resourcePath = path.join("/"); + + if (!METADATA_RESOURCE_PATHS.has(resourcePath)) { + // JSON, not the HTML 404 page, so a client that probes a wrong path gets + // a parseable answer instead of the failure mode this route exists to fix. + return NextResponse.json( + { error: "not_found", error_description: "no such protected resource" }, + { status: 404 }, + ); + } + + return NextResponse.json( + buildResourceMetadata(`${publicOrigin()}/${resourcePath}`), + ); +} diff --git a/apps/web/app/.well-known/oauth-protected-resource/route.ts b/apps/web/app/.well-known/oauth-protected-resource/route.ts index 097c85a..662604e 100644 --- a/apps/web/app/.well-known/oauth-protected-resource/route.ts +++ b/apps/web/app/.well-known/oauth-protected-resource/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { env } from "@/lib/env"; -import { mcpIssuer } from "@/lib/auth/jwt"; +import { buildResourceMetadata, publicOrigin } from "@/lib/auth/resource-metadata"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -10,36 +9,12 @@ export const dynamic = "force-dynamic"; * * MCP clients discover the authorization server (Authentik) via this * endpoint after receiving a 401 with `WWW-Authenticate: resource_metadata=...`. + * + * This is the root form of the document, describing the deployment origin as + * the protected resource. Clients that derive the metadata URL from the MCP + * endpoint URL instead of following the header land on the path-suffixed form + * (§3.1) served by the sibling `[...path]` route. */ export function GET() { - const resource = env().PUBLIC_URL.replace(/\/$/, ""); - - // The audience scope MUST be advertised. Authentik only evaluates a scope - // mapping when the client requests that scope by name, and the client only - // learns scope names from this document. Omit it and every access token - // arrives without `aud`, which jwt.ts rejects as "claim invalid: aud". - const audienceScope = - env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`; - - // Same mechanism as the audience scope, different consequence: a client - // only requests `offline_access` if it sees the name here, and without - // that request the IdP returns no refresh token — so the client cannot - // renew and the user gets kicked back to an interactive login whenever - // the access token expires. - // - // Opt-in, because the IdP needs a matching scope mapping; advertising one - // it doesn't offer can fail the whole authorization request. - const scopes = ["openid", "profile", "email", audienceScope]; - if (env().OIDC_OFFLINE_ACCESS) scopes.push("offline_access"); - - return NextResponse.json({ - resource, - // The MCP application's issuer, which is not necessarily the Web UI's — - // see mcpIssuer(). Advertising the wrong one sends clients to a discovery - // document whose tokens this endpoint will then reject on `iss`. - authorization_servers: [mcpIssuer()], // as configured, slash and all - scopes_supported: scopes, - bearer_methods_supported: ["header"], - resource_documentation: `${resource}/`, - }); + return NextResponse.json(buildResourceMetadata(publicOrigin())); } diff --git a/apps/web/lib/auth/resource-metadata.ts b/apps/web/lib/auth/resource-metadata.ts new file mode 100644 index 0000000..7d123ab --- /dev/null +++ b/apps/web/lib/auth/resource-metadata.ts @@ -0,0 +1,73 @@ +import { env } from "@/lib/env"; +import { mcpIssuer } from "@/lib/auth/jwt"; + +/** + * The protected-resource metadata document, RFC 9728 §2. + * + * Shared by both metadata routes — the root `/.well-known/oauth-protected- + * resource` and the path-suffixed `/.well-known/oauth-protected-resource/ + * ` form of §3.1 — because the two documents differ ONLY in + * the `resource` identifier they describe. Anything else drifting between + * them is a bug: a client that discovers us through the suffixed URL would + * be told to request a different scope set than one that follows the + * `WWW-Authenticate: resource_metadata=...` header, and whichever of the two + * lost the audience scope would hand back tokens with no `aud` claim. + */ +export interface ResourceMetadata { + resource: string; + authorization_servers: string[]; + scopes_supported: string[]; + bearer_methods_supported: string[]; + resource_documentation: string; +} + +/** + * The public origin, with any trailing slash stripped. + * + * `resource` values are compared as exact strings by clients (RFC 9728 §3.3), + * so `https://host/` and `https://host` are not interchangeable — PUBLIC_URL + * is written both ways in the wild and only the stripped form is emitted. + */ +export function publicOrigin(): string { + return env().PUBLIC_URL.replace(/\/$/, ""); +} + +/** + * Build the metadata document for `resource`. + * + * The caller supplies the resource identifier because it depends on which + * URL the document was fetched from; everything else is deployment config. + */ +export function buildResourceMetadata(resource: string): ResourceMetadata { + // The audience scope MUST be advertised. Authentik only evaluates a scope + // mapping when the client requests that scope by name, and the client only + // learns scope names from this document. Omit it and every access token + // arrives without `aud`, which jwt.ts rejects as "claim invalid: aud". + const audienceScope = + env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`; + + // Same mechanism as the audience scope, different consequence: a client + // only requests `offline_access` if it sees the name here, and without + // that request the IdP returns no refresh token — so the client cannot + // renew and the user gets kicked back to an interactive login whenever + // the access token expires. + // + // Opt-in, because the IdP needs a matching scope mapping; advertising one + // it doesn't offer can fail the whole authorization request. + const scopes = ["openid", "profile", "email", audienceScope]; + if (env().OIDC_OFFLINE_ACCESS) scopes.push("offline_access"); + + return { + resource, + // The MCP application's issuer, which is not necessarily the Web UI's — + // see mcpIssuer(). Advertising the wrong one sends clients to a discovery + // document whose tokens this endpoint will then reject on `iss`. + authorization_servers: [mcpIssuer()], // as configured, slash and all + scopes_supported: scopes, + bearer_methods_supported: ["header"], + // Documentation lives at the site root regardless of which resource this + // document describes, so it is always derived from the public origin and + // not from `resource`. + resource_documentation: `${publicOrigin()}/`, + }; +} diff --git a/docs/oidc-entra-id.md b/docs/oidc-entra-id.md index 1676e59..7fb8a6a 100644 --- a/docs/oidc-entra-id.md +++ b/docs/oidc-entra-id.md @@ -231,13 +231,17 @@ https://memory.example.com/auth/cli-callback The first covers the loopback listener from README → *B. OAuth flow*; the second is the manual-paste fallback from *C*. "Mobile and desktop applications" permits arbitrary `https://` URIs alongside the loopback one, so -both live on the same platform. +both live on the same platform. If the server will also be added as a claude.ai +custom connector, that flow is brokered by claude.ai and comes back to +`https://claude.ai/api/mcp/auth_callback` — register it here too, or the +connector stops at the IdP's redirect-URI mismatch error before any login +prompt. See README → *Which redirect URIs to register*. **Note the missing port.** Entra ignores the port component when matching `http://localhost` redirect URIs, so the single registration `http://localhost/callback` matches `http://localhost:33418/callback`, `http://localhost:9999/callback`, and any other port. This is Entra's -equivalent of the Authentik regex (`^http://(127\.0\.0\.1|localhost):\d+/.*$`) +equivalent of the Authentik regex (`^http://(127\.0\.0\.1|localhost):\d+(/.*)?$`) the README mentions — users can pick any `--callback-port` without re-registering. diff --git a/terraform/README.md b/terraform/README.md index d229885..a267d0e 100644 --- a/terraform/README.md +++ b/terraform/README.md @@ -72,6 +72,16 @@ The redirect URI you register on the Web UI client is `https://${domain_name}/api/auth/callback/oidc`, so plan the domain name *before* configuring the IdP. +The MCP client needs its own list, and most of it does not depend on the +domain: a loopback URI for the Claude Code CLI, plus +`https://claude.ai/api/mcp/auth_callback` if anyone will add the server as a +claude.ai custom connector. Only the manual-paste fallback, +`https://${domain_name}/auth/cli-callback`, follows the domain. See +[Which redirect URIs to register](../README.md#which-redirect-uris-to-register) +— a client can only register its own redirect URI against an IdP that offers +Dynamic Client Registration, which Authentik gates behind an enterprise +licence, so plan on adding all of these by hand. + --- ## Quick start