feat: /auth/cli-callback page + OAuth-first connect docs

Adds the manual-paste fallback page MCP clients hit when their OAuth
loopback callback isn't reachable (sealed containers, port-restricted
hosts). The page displays the authorization code, the full callback URL,
and the state parameter, all with copy buttons, plus instructions to
paste back into the waiting terminal. Single-use codes plus client-side
PKCE mean displaying the code here is safe — it isn't a credential by
itself.

README "Connecting Claude Code" rewritten to make OAuth the primary
path, with three options ordered by preference: (A) standard OAuth +
loopback, (B) manual paste via /auth/cli-callback, (C) static HMAC
bearer via /connect for fully headless setups. Also notes that the
zero-config plugin path is blocked on Authentik DCR (issue #8751,
expected later this year) so we're shipping the one-liner now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 10:37:08 -07:00
co-authored by Claude Opus 4.7
parent d7707dd158
commit 609039f098
3 changed files with 229 additions and 12 deletions
+64 -12
View File
@@ -231,20 +231,72 @@ prompt, never reaching the app.
## Connecting Claude Code
In a future Phase, we'll publish a one-line Claude Code config snippet. For
Phase 1, follow the [MCP authorization flow][mcp-auth]:
Two paths, in order of preference:
1. Add the MCP server to Claude Code's config, pointing at
`https://memory.dnspegasus.net/api/mcp`.
2. On first connection, the server returns 401 with `WWW-Authenticate`
pointing at `/.well-known/oauth-protected-resource`.
3. Claude Code reads the protected-resource metadata, follows the link to
Authentik's discovery doc, and runs the OAuth 2.1 PKCE flow.
4. You'll be prompted in your browser to authenticate with Authentik.
5. Claude Code stores the access token and uses it on subsequent requests.
### A. OAuth flow (recommended — picks up your IdP credentials)
If Authentik refuses the redirect URI Claude Code attempts to use, copy the
URI from the error and add it under the MCP provider's **Redirect URIs**.
```bash
claude mcp add --transport http --scope user \
--client-id <OIDC_CLIENT_ID_MCP> \
--callback-port 33418 \
shared-memory https://memory.dnspegasus.net/api/mcp
```
What happens:
1. Claude Code hits `/api/mcp`, gets 401 with our `WWW-Authenticate` header
2. It reads `/.well-known/oauth-protected-resource`, finds your OIDC issuer
3. It opens an authorize URL in your browser and starts a local listener
on the `--callback-port` you specified
4. You authenticate with your IdP in the browser
5. The IdP redirects back to `http://localhost:33418/callback?code=…`,
Claude Code's listener catches it, exchanges the code for an access
token, and stores it
`--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.
### B. Manual-paste fallback (when loopback isn't reachable)
Sealed containers, devboxes without port forwarding, etc. The redirect URI
in this case is hosted by *this* server:
```bash
claude mcp add --transport http --scope user \
--client-id <OIDC_CLIENT_ID_MCP> \
--callback-port 0 \
shared-memory https://memory.dnspegasus.net/api/mcp
```
When the loopback listener times out, Claude Code prompts you to paste the
callback URL. Open the authorize URL Claude Code printed in your browser,
sign in, and your IdP redirects to
`https://memory.dnspegasus.net/auth/cli-callback?code=…`. That page shows
the `code` and the full URL with copy buttons — paste either back into
Claude Code's prompt to complete the flow.
The manual-fallback URI must be registered on your MCP client too:
`https://memory.dnspegasus.net/auth/cli-callback`.
### C. Static bearer token (no browser at all)
For fully headless / CI scenarios, mint a long-lived HMAC token at
`https://memory.dnspegasus.net/connect` and pass it via `--header`. See
the `/connect` page for the exact `claude mcp add` command it generates
for you.
### Why no zero-config plugin yet
Claude Code plugins can ship an MCP server entry that handles OAuth
without any flags — but only when the auth server supports Dynamic Client
Registration (RFC 7591). Authentik is tracking DCR in
[goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751);
once it ships we'll publish a plugin so the entire flow above collapses
to `/plugin install shared-memory`. Other IdPs that already support DCR
(Asana-style) can wire this up sooner.
[mcp-auth]: https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
@@ -0,0 +1,29 @@
"use client";
import { useState } from "react";
interface Props {
value: string;
label: string;
}
export default function CopyButton({ value, label }: Props) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Fallback for old browsers / non-secure contexts: select the next
// <pre> and let the user hit ⌘C themselves.
}
}
return (
<button type="button" onClick={copy} style={{ marginBottom: "0.5rem" }}>
{copied ? "✓ Copied" : label}
</button>
);
}
+136
View File
@@ -0,0 +1,136 @@
import Link from "next/link";
import CopyButton from "./copy-button";
export const dynamic = "force-dynamic";
/**
* /auth/cli-callback — OAuth redirect target for clients that can't open a
* loopback port (e.g. Claude Code in a sealed container).
*
* The page is intentionally unauthenticated: the user arrives here as part
* of an in-progress OAuth flow, before any session exists. The code is
* single-use and proof of possession (PKCE on the client side) is still
* required to exchange it. Showing it on this page does NOT grant access
* by itself.
*/
interface SearchParams {
code?: string;
state?: string;
error?: string;
error_description?: string;
iss?: string;
}
export default async function CliCallbackPage({
searchParams,
}: {
searchParams: Promise<SearchParams>;
}) {
const params = await searchParams;
if (params.error) {
return (
<main className="container">
<h1 style={{ color: "#ff6b6b" }}>Sign-in failed</h1>
<p>
<code>{params.error}</code>
{params.error_description ? <> {params.error_description}</> : null}
</p>
<p className="muted">
Switch back to your terminal, cancel the in-progress prompt, and
retry the <code>claude mcp add</code> command. If the error
persists, check that the redirect URI matches what your OIDC
provider has registered.
</p>
<p>
<Link href="/"> home</Link>
</p>
</main>
);
}
if (!params.code) {
return (
<main className="container">
<h1>OAuth callback</h1>
<p className="muted">
This page is the manual-fallback redirect target for the
shared-memory MCP server. It only does something useful in the
middle of an OAuth sign-in flow that couldn&apos;t reach a
loopback callback on your machine.
</p>
<p>
If you&apos;re trying to connect an MCP client, start over from
your terminal with the <code>claude mcp add</code> command shown
in the README.
</p>
<p>
<Link href="/"> home</Link>
</p>
</main>
);
}
const fullUrl = `?code=${encodeURIComponent(params.code)}${
params.state ? `&state=${encodeURIComponent(params.state)}` : ""
}${params.iss ? `&iss=${encodeURIComponent(params.iss)}` : ""}`;
return (
<main className="container">
<h1 style={{ color: "#7ee787" }}>Sign-in complete</h1>
<p>
Switch back to your terminal where Claude Code (or whichever MCP
client) is waiting, and paste one of the values below.
</p>
<h2>Authorization code</h2>
<p className="muted">
Most clients ask for just the <code>code</code>:
</p>
<CopyButton value={params.code} label="Copy code" />
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
}}
>
{params.code}
</pre>
<h2 style={{ marginTop: "2rem" }}>Full callback URL</h2>
<p className="muted">
Some clients ask you to paste the entire URL their loopback timed
out on:
</p>
<CopyButton value={fullUrl} label="Copy URL" />
<pre
style={{
whiteSpace: "pre-wrap",
wordBreak: "break-all",
userSelect: "all",
}}
>
{fullUrl}
</pre>
{params.state ? (
<>
<h3 style={{ marginTop: "2rem" }}>State (verification)</h3>
<p className="muted">
Your terminal client may show its expected state; it should
match this value. If it doesn&apos;t, stop and start over
something is wrong with the flow.
</p>
<pre style={{ userSelect: "all" }}>{params.state}</pre>
</>
) : null}
<p className="muted" style={{ marginTop: "2rem" }}>
The code is single-use and expires in a few minutes. If you take
too long, retry the <code>claude mcp add</code> command.
</p>
</main>
);
}