Merge pull request 'fix: serve RFC 9728 path-suffixed metadata, document connector redirect URIs' (#26) from fix/oauth-redirect-uris-and-rfc9728-discovery into main

This commit was merged in pull request #26.
This commit is contained in:
2026-09-16 23:33:06 +00:00
7 changed files with 327 additions and 38 deletions
+82 -4
View File
@@ -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:<port>/callback`, where `<port>` 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.
@@ -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<Metadata> {
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);
});
});
@@ -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<string> = 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<NextResponse> {
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}`),
);
}
@@ -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()));
}
+73
View File
@@ -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/
* <resource path>` 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()}/`,
};
}
+6 -2
View File
@@ -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.
+10
View File
@@ -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