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:
@@ -231,20 +231,72 @@ prompt, never reaching the app.
|
|||||||
|
|
||||||
## Connecting Claude Code
|
## Connecting Claude Code
|
||||||
|
|
||||||
In a future Phase, we'll publish a one-line Claude Code config snippet. For
|
Two paths, in order of preference:
|
||||||
Phase 1, follow the [MCP authorization flow][mcp-auth]:
|
|
||||||
|
|
||||||
1. Add the MCP server to Claude Code's config, pointing at
|
### A. OAuth flow (recommended — picks up your IdP credentials)
|
||||||
`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.
|
|
||||||
|
|
||||||
If Authentik refuses the redirect URI Claude Code attempts to use, copy the
|
```bash
|
||||||
URI from the error and add it under the MCP provider's **Redirect URIs**.
|
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
|
[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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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't reach a
|
||||||
|
loopback callback on your machine.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you'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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user