Two OAuth/discovery footguns found while debugging an Authentik "Redirect URI Error" on a claude.ai custom connector.
1. RFC 9728 path-suffixed metadata returned an HTML 404
§3.1 puts the metadata for a resource identified by https://host/api/mcp at /.well-known/oauth-protected-resource/api/mcp — the resource path is appended to the well-known path. We only served the root form, so a client that derives the metadata URL from the MCP endpoint URL (rather than reading resource_metadata off our 401) got Next.js's HTML 404 and failed discovery with a JSON parse error.
Adds a [...path] route serving the same document with resource naming the suffixed identifier — §3.3 has the client compare that string exactly, so echoing the bare origin there would be rejected by a strict client anyway.
The document body moves to lib/auth/resource-metadata.ts so the two routes cannot drift apart on scopes_supported; a divergence there costs you either the aud claim or the refresh token. Resource paths are allowlisted rather than wildcarded, so this can't advertise resources the app doesn't serve or echo attacker-chosen path segments back inside a JSON document.
buildWwwAuthenticate() is deliberately untouched — it still points at the root URL, so every currently-working client is unaffected. This change is purely additive.
2. Redirect URIs are client-shape-dependent, and that was undocumented
The MCP provider is reached by clients that come back at different redirect URIs:
Client
Redirect URI
Claude Code CLI / this repo's plugin
http://localhost:<port>/callback
claude.ai custom connector
https://claude.ai/api/mcp/auth_callback
Registering only the loopback form is what produces the "Redirect URI Error" page — served after the client sends you to the IdP but before any login or consent screen, with nothing indicating which URI was rejected.
Documents both, keyed on the literal error string so it's searchable, plus the gotcha that a portless http://localhost/callback matches nothing the CLI sends.
Probed the live authorize endpoint before and after registering the connector callback:
redirect_uri
before
after
https://claude.ai/api/mcp/auth_callback
REJECTED
ACCEPTED
http://localhost:5693/callback
ACCEPTED
ACCEPTED
https://example.com/bogus-control
REJECTED
REJECTED
http://localhost/callback
REJECTED
REJECTED
Control still rejected, so the provider allowlist wasn't loosened. Connector confirmed working end-to-end.
Note on the DCR claim
An earlier draft asserted Authentik has no Dynamic Client Registration. That's wrong — goauthentik/authentik#8751 closed as completed in July 2026; DCR ships enterprise-gated, and a maintainer has said it will move to the OSS build. The docs now say that accurately and show how to check a given instance. The hand-registration conclusion still holds for a FOSS instance, just for a different reason.
The loopback regex in the docs is now copied verbatim from the deployed provider: the hand-written …:[0-9]+/.* required a path component and wouldn't match a bare http://localhost:5693.
## What
Two OAuth/discovery footguns found while debugging an Authentik **"Redirect URI Error"** on a claude.ai custom connector.
### 1. RFC 9728 path-suffixed metadata returned an HTML 404
§3.1 puts the metadata for a resource identified by `https://host/api/mcp` at `/.well-known/oauth-protected-resource/api/mcp` — the resource path is appended to the well-known path. We only served the root form, so a client that derives the metadata URL from the MCP endpoint URL (rather than reading `resource_metadata` off our 401) got Next.js's HTML 404 and failed discovery with a JSON parse error.
Adds a `[...path]` route serving the same document with `resource` naming the suffixed identifier — §3.3 has the client compare that string exactly, so echoing the bare origin there would be rejected by a strict client anyway.
The document body moves to `lib/auth/resource-metadata.ts` so the two routes cannot drift apart on `scopes_supported`; a divergence there costs you either the `aud` claim or the refresh token. Resource paths are allowlisted rather than wildcarded, so this can't advertise resources the app doesn't serve or echo attacker-chosen path segments back inside a JSON document.
`buildWwwAuthenticate()` is deliberately untouched — it still points at the root URL, so every currently-working client is unaffected. **This change is purely additive.**
### 2. Redirect URIs are client-shape-dependent, and that was undocumented
The MCP provider is reached by clients that come back at *different* redirect URIs:
| Client | Redirect URI |
|---|---|
| Claude Code CLI / this repo's plugin | `http://localhost:<port>/callback` |
| claude.ai custom connector | `https://claude.ai/api/mcp/auth_callback` |
Registering only the loopback form is what produces the "Redirect URI Error" page — served *after* the client sends you to the IdP but *before* any login or consent screen, with nothing indicating which URI was rejected.
Documents both, keyed on the literal error string so it's searchable, plus the gotcha that a portless `http://localhost/callback` matches nothing the CLI sends.
## Verification
- `pnpm test` — 70 passed (9 files); `app/.well-known` suites: 8 passed
- `pnpm typecheck` — clean; `pnpm lint` — clean
- `pnpm build` — route table lists `ƒ /.well-known/oauth-protected-resource/[...path]`
Probed the live authorize endpoint before and after registering the connector callback:
| redirect_uri | before | after |
|---|---|---|
| `https://claude.ai/api/mcp/auth_callback` | REJECTED | **ACCEPTED** |
| `http://localhost:5693/callback` | ACCEPTED | ACCEPTED |
| `https://example.com/bogus-control` | REJECTED | REJECTED |
| `http://localhost/callback` | REJECTED | REJECTED |
Control still rejected, so the provider allowlist wasn't loosened. Connector confirmed working end-to-end.
## Note on the DCR claim
An earlier draft asserted Authentik has no Dynamic Client Registration. That's **wrong** — [goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751) closed as *completed* in July 2026; DCR ships **enterprise-gated**, and a maintainer has said it will move to the OSS build. The docs now say that accurately and show how to check a given instance. The hand-registration conclusion still holds for a FOSS instance, just for a different reason.
The loopback regex in the docs is now copied verbatim from the deployed provider: the hand-written `…:[0-9]+/.*` required a path component and wouldn't match a bare `http://localhost:5693`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Two separate discovery footguns, both found while debugging an Authentik
"Redirect URI Error" on a claude.ai custom connector.
RFC 9728 §3.1 puts the metadata for a resource identified by
`https://host/api/mcp` at `/.well-known/oauth-protected-resource/api/mcp`.
Only the root form was served, so clients that derive the metadata URL from
the MCP endpoint URL — rather than reading `resource_metadata` off our 401 —
got Next.js's HTML 404 and failed discovery with a JSON parse error.
Add a `[...path]` route serving the same document with `resource` naming the
suffixed identifier (§3.3 has the client compare it as an exact string, so
echoing the bare origin would be rejected). The document body moves to
`lib/auth/resource-metadata.ts` so the two routes cannot drift apart on
`scopes_supported` — a divergence there costs you the `aud` claim or the
refresh token. Paths are allowlisted rather than wildcarded so this cannot
advertise resources the app does not serve. `buildWwwAuthenticate()` still
points at the root URL; this change is purely additive.
Separately, the redirect URIs an MCP provider needs depend on how clients
reach it: a loopback URI for the CLI, `https://claude.ai/api/mcp/auth_callback`
for a claude.ai custom connector. Registering only the former is what produces
the "Redirect URI Error" page, and a portless `http://localhost/callback`
entry matches nothing the CLI sends. Document both, keyed on the literal
error text, and note that DCR is enterprise-gated on Authentik so these are
hand-registered on a FOSS instance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pattern in the walkthrough was written from memory and differs from the
one on the `shared-memory-mcp` provider in a way that matters: `…:[0-9]+/.*`
requires a path component, so it fails to match a bare
`http://localhost:5693`, which the deployed `…:\d+(/.*)?$` accepts. Copy the
live value verbatim instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review caught the stated invariant being false. Next splits the matched
suffix on literal `/` before decoding each piece, so a segment can be empty
(`api//mcp`) and a single segment can carry a decoded slash (`api%2Fmcp`).
Neither reaches the allowlist — both fail closed — but the comment claimed
an invariant the router does not provide.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp
merged commit 87475e60d0 into main2026-09-16 23:33:07 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Two OAuth/discovery footguns found while debugging an Authentik "Redirect URI Error" on a claude.ai custom connector.
1. RFC 9728 path-suffixed metadata returned an HTML 404
§3.1 puts the metadata for a resource identified by
https://host/api/mcpat/.well-known/oauth-protected-resource/api/mcp— the resource path is appended to the well-known path. We only served the root form, so a client that derives the metadata URL from the MCP endpoint URL (rather than readingresource_metadataoff our 401) got Next.js's HTML 404 and failed discovery with a JSON parse error.Adds a
[...path]route serving the same document withresourcenaming the suffixed identifier — §3.3 has the client compare that string exactly, so echoing the bare origin there would be rejected by a strict client anyway.The document body moves to
lib/auth/resource-metadata.tsso the two routes cannot drift apart onscopes_supported; a divergence there costs you either theaudclaim or the refresh token. Resource paths are allowlisted rather than wildcarded, so this can't advertise resources the app doesn't serve or echo attacker-chosen path segments back inside a JSON document.buildWwwAuthenticate()is deliberately untouched — it still points at the root URL, so every currently-working client is unaffected. This change is purely additive.2. Redirect URIs are client-shape-dependent, and that was undocumented
The MCP provider is reached by clients that come back at different redirect URIs:
http://localhost:<port>/callbackhttps://claude.ai/api/mcp/auth_callbackRegistering only the loopback form is what produces the "Redirect URI Error" page — served after the client sends you to the IdP but before any login or consent screen, with nothing indicating which URI was rejected.
Documents both, keyed on the literal error string so it's searchable, plus the gotcha that a portless
http://localhost/callbackmatches nothing the CLI sends.Verification
pnpm test— 70 passed (9 files);app/.well-knownsuites: 8 passedpnpm typecheck— clean;pnpm lint— cleanpnpm build— route table listsƒ /.well-known/oauth-protected-resource/[...path]Probed the live authorize endpoint before and after registering the connector callback:
https://claude.ai/api/mcp/auth_callbackhttp://localhost:5693/callbackhttps://example.com/bogus-controlhttp://localhost/callbackControl still rejected, so the provider allowlist wasn't loosened. Connector confirmed working end-to-end.
Note on the DCR claim
An earlier draft asserted Authentik has no Dynamic Client Registration. That's wrong — goauthentik/authentik#8751 closed as completed in July 2026; DCR ships enterprise-gated, and a maintainer has said it will move to the OSS build. The docs now say that accurately and show how to check a given instance. The hand-registration conclusion still holds for a FOSS instance, just for a different reason.
The loopback regex in the docs is now copied verbatim from the deployed provider: the hand-written
…:[0-9]+/.*required a path component and wouldn't match a barehttp://localhost:5693.🤖 Generated with Claude Code