Skip to content

Commit 124ab8f

Browse files
committed
feat(oauth): v0.4 step 3 — Dynamic Client Registration (RFC 7591)
POST /oauth/register lets MCP clients that have no prior relationship with this server self-register. Anthropic's Claude.ai connector docs explicitly mention DCR support — without this endpoint, the connector couldn't complete the flow. Behaviour: - Public clients only (token_endpoint_auth_method=none). Confidential clients are rejected rather than silently downgraded, so DCR clients don't get a false sense of authentication on top of PKCE. - redirect_uris: https:// only, plus http://localhost / 127.0.0.1 / [::1] for desktop clients. Fragments rejected per RFC 6749 §3.1.2. - grant_types: authorization_code, refresh_token (the only ones our /oauth/token endpoint understands). - response_types: code only (matches V64 discovery advertisement). - client_id = "mcp-" + 16 random hex bytes. Returned to the caller plus a registration_access_token (issued but not used yet; step 4 can wire it up for client self-management). - Accept-and-ignore the optional metadata fields Anthropic and others send (client_uri, logo_uri, tos_uri, policy_uri, software_id, software_version, scope) so registration doesn't fail on benign extras. GET /oauth/authorize now refuses unknown client_ids and validates that redirect_uri is in the registered client's allow-list. Without this, an attacker who learned a client_id could supply their own redirect_uri and capture the auth code. Constant-time comparison on the URI to avoid leaking the registered set via timing. Persistence: in-memory. Same posture as V53 + OAuthStore — single user, restart = re-register. Step 4 adds a Settings UI for review and revoke; persistent client storage can land later if the single-user assumption breaks.
1 parent 66f7c42 commit 124ab8f

3 files changed

Lines changed: 215 additions & 3 deletions

File tree

server/src/api/oauth-flow.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Elysia, t } from "elysia";
22
import type { AuthStore } from "../auth/store";
33
import type { OAuthStore } from "../auth/oauth-store";
4+
import type { OAuthClientStore, ClientRegistrationRequest } from "../auth/oauth-clients";
45

56
// V63 step 2: /oauth/authorize + /oauth/token endpoints.
67
//
@@ -134,10 +135,48 @@ function redirectWithError(redirectUri: string, state: string, error: string, de
134135

135136
const SUPPORTED_SCOPES = new Set(["vault:read", "vault:write"]);
136137

137-
export function oauthFlowRoutes(authStore: AuthStore, oauth: OAuthStore) {
138+
export function oauthFlowRoutes(
139+
authStore: AuthStore,
140+
oauth: OAuthStore,
141+
clients: OAuthClientStore,
142+
) {
138143
return (
139144
new Elysia()
140145

146+
// ----- POST /oauth/register (RFC 7591 Dynamic Client Registration) -----
147+
.post(
148+
"/oauth/register",
149+
({ body, set }) => {
150+
const req = body as ClientRegistrationRequest;
151+
const r = clients.register(req);
152+
if (!r.ok) {
153+
set.status = 400;
154+
return { error: r.error, error_description: r.error_description };
155+
}
156+
set.status = 201;
157+
set.headers["cache-control"] = "no-store";
158+
set.headers["pragma"] = "no-cache";
159+
return r.client;
160+
},
161+
{
162+
body: t.Object({
163+
client_name: t.Optional(t.String()),
164+
redirect_uris: t.Array(t.String()),
165+
grant_types: t.Optional(t.Array(t.String())),
166+
response_types: t.Optional(t.Array(t.String())),
167+
token_endpoint_auth_method: t.Optional(t.String()),
168+
// Anthropic-style metadata fields we accept but ignore.
169+
scope: t.Optional(t.String()),
170+
client_uri: t.Optional(t.String()),
171+
logo_uri: t.Optional(t.String()),
172+
tos_uri: t.Optional(t.String()),
173+
policy_uri: t.Optional(t.String()),
174+
software_id: t.Optional(t.String()),
175+
software_version: t.Optional(t.String()),
176+
}),
177+
},
178+
)
179+
141180
// ----- GET /oauth/authorize (render consent) -----
142181
.get(
143182
"/oauth/authorize",
@@ -170,6 +209,25 @@ export function oauthFlowRoutes(authStore: AuthStore, oauth: OAuthStore) {
170209
set.status = 400;
171210
return { error: "invalid_request", error_description: redirOk.reason };
172211
}
212+
// V63 step 3: client_id must match a registered client and the
213+
// redirect_uri must be in that client's allow-list. Without this
214+
// check, an attacker who learns a client_id can swap in their own
215+
// redirect_uri and capture the auth code.
216+
const knownClient = clients.get(clientId);
217+
if (!knownClient) {
218+
set.status = 400;
219+
return {
220+
error: "invalid_client",
221+
error_description: "unknown client_id — register via POST /oauth/register first",
222+
};
223+
}
224+
if (!clients.validateRedirectUri(clientId, redirectUri)) {
225+
set.status = 400;
226+
return {
227+
error: "invalid_request",
228+
error_description: "redirect_uri not registered for this client",
229+
};
230+
}
173231

174232
// From here on, errors redirect.
175233
if (responseType !== "code") {

server/src/app.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { authMiddleware } from "./api/auth-middleware";
3030
import { oauthDiscoveryRoutes } from "./api/oauth-discovery";
3131
import { oauthFlowRoutes } from "./api/oauth-flow";
3232
import { OAuthStore } from "./auth/oauth-store";
33+
import { OAuthClientStore } from "./auth/oauth-clients";
3334
import { folderPermsRoutes } from "./api/folder-perms";
3435
import { AuthStore } from "./auth/store";
3536
import { TokenStore } from "./auth/tokens";
@@ -69,6 +70,7 @@ export function createApp(opts: AppOptions = {}) {
6970
const tokenStore = new TokenStore();
7071
const oauthStore = new OAuthStore();
7172
oauthStore.startSweeper();
73+
const oauthClients = new OAuthClientStore();
7274

7375
const app = new Elysia()
7476
.use(cors())
@@ -77,8 +79,8 @@ export function createApp(opts: AppOptions = {}) {
7779
// V64: OAuth discovery surface, served before everything else so it's
7880
// reachable even when auth.json gates the rest of the API.
7981
.use(oauthDiscoveryRoutes())
80-
// V63 step 2: authorize + token endpoints (PKCE flow).
81-
.use(oauthFlowRoutes(authStore, oauthStore))
82+
// V63 step 2+3: authorize + token + register endpoints (PKCE + DCR).
83+
.use(oauthFlowRoutes(authStore, oauthStore, oauthClients))
8284
.use(authRoutes(authStore, tokenStore))
8385
.use(treeRoutes(vault))
8486
.use(noteRoutes(vault, index))

server/src/auth/oauth-clients.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { randomBytes, timingSafeEqual } from "node:crypto";
2+
3+
// V63 step 3: Dynamic Client Registration store (RFC 7591).
4+
//
5+
// MCP clients that don't have a prior relationship with this server can
6+
// POST /oauth/register with a JSON body declaring their redirect_uris and
7+
// client_name; we mint a client_id (and skip client_secret — every MCP
8+
// client we care about is public, PKCE is the only credential).
9+
//
10+
// Persistence: in-memory, same as the rest of the auth surface. The
11+
// rationale matches V53 — single-user single-device, restart = fresh.
12+
// Step 4 may add Settings → Security → "Registered clients" with persist
13+
// + revoke, but the spec doesn't require persistence; clients re-register
14+
// after a restart, which costs them one extra round trip and nothing else.
15+
16+
export interface RegisteredClient {
17+
client_id: string;
18+
client_name: string;
19+
redirect_uris: string[];
20+
grant_types: string[];
21+
response_types: string[];
22+
token_endpoint_auth_method: "none";
23+
client_id_issued_at: number;
24+
registration_access_token: string;
25+
}
26+
27+
export interface ClientRegistrationRequest {
28+
client_name?: string;
29+
redirect_uris: string[];
30+
grant_types?: string[];
31+
response_types?: string[];
32+
token_endpoint_auth_method?: string;
33+
}
34+
35+
function isValidRedirect(uri: string): boolean {
36+
let u: URL;
37+
try {
38+
u = new URL(uri);
39+
} catch {
40+
return false;
41+
}
42+
if (u.hash) return false;
43+
if (u.protocol === "https:") return true;
44+
if (u.protocol === "http:") {
45+
return u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "[::1]";
46+
}
47+
return false;
48+
}
49+
50+
function constantTimeEq(a: string, b: string): boolean {
51+
const ab = Buffer.from(a);
52+
const bb = Buffer.from(b);
53+
if (ab.length !== bb.length) return false;
54+
return timingSafeEqual(ab, bb);
55+
}
56+
57+
export class OAuthClientStore {
58+
private readonly clients = new Map<string, RegisteredClient>();
59+
60+
register(req: ClientRegistrationRequest):
61+
| { ok: true; client: RegisteredClient }
62+
| { ok: false; error: string; error_description: string } {
63+
if (!Array.isArray(req.redirect_uris) || req.redirect_uris.length === 0) {
64+
return {
65+
ok: false,
66+
error: "invalid_redirect_uri",
67+
error_description: "redirect_uris must be a non-empty array",
68+
};
69+
}
70+
for (const uri of req.redirect_uris) {
71+
if (typeof uri !== "string" || !isValidRedirect(uri)) {
72+
return {
73+
ok: false,
74+
error: "invalid_redirect_uri",
75+
error_description: `redirect_uri rejected: ${uri} (must be https:// or http://localhost)`,
76+
};
77+
}
78+
}
79+
const grantTypes = req.grant_types ?? ["authorization_code", "refresh_token"];
80+
const allowedGrants = new Set(["authorization_code", "refresh_token"]);
81+
for (const g of grantTypes) {
82+
if (!allowedGrants.has(g)) {
83+
return {
84+
ok: false,
85+
error: "invalid_client_metadata",
86+
error_description: `grant_type not supported: ${g}`,
87+
};
88+
}
89+
}
90+
const responseTypes = req.response_types ?? ["code"];
91+
for (const r of responseTypes) {
92+
if (r !== "code") {
93+
return {
94+
ok: false,
95+
error: "invalid_client_metadata",
96+
error_description: `response_type not supported: ${r}`,
97+
};
98+
}
99+
}
100+
const auth = req.token_endpoint_auth_method ?? "none";
101+
if (auth !== "none") {
102+
// We're a public-client server (PKCE-only). Reject confidential
103+
// client registrations rather than silently downgrading.
104+
return {
105+
ok: false,
106+
error: "invalid_client_metadata",
107+
error_description: "token_endpoint_auth_method must be 'none' (PKCE-only public clients)",
108+
};
109+
}
110+
111+
const client: RegisteredClient = {
112+
client_id: `mcp-${randomBytes(16).toString("hex")}`,
113+
client_name: typeof req.client_name === "string" && req.client_name.length > 0
114+
? req.client_name.slice(0, 200)
115+
: "Unnamed MCP client",
116+
redirect_uris: req.redirect_uris.slice(),
117+
grant_types: grantTypes,
118+
response_types: responseTypes,
119+
token_endpoint_auth_method: "none",
120+
client_id_issued_at: Math.floor(Date.now() / 1000),
121+
registration_access_token: randomBytes(32).toString("base64url"),
122+
};
123+
this.clients.set(client.client_id, client);
124+
return { ok: true, client };
125+
}
126+
127+
get(clientId: string): RegisteredClient | undefined {
128+
return this.clients.get(clientId);
129+
}
130+
131+
// Returns true if (clientId, redirectUri) pair was registered. Used by
132+
// the /authorize handler so unknown clients with arbitrary redirect_uris
133+
// don't slip through. Constant-time comparison on the URI to avoid
134+
// leaking which redirect_uris are registered.
135+
validateRedirectUri(clientId: string, redirectUri: string): boolean {
136+
const c = this.clients.get(clientId);
137+
if (!c) return false;
138+
return c.redirect_uris.some((r) => constantTimeEq(r, redirectUri));
139+
}
140+
141+
list(): RegisteredClient[] {
142+
return Array.from(this.clients.values());
143+
}
144+
145+
revoke(clientId: string): boolean {
146+
return this.clients.delete(clientId);
147+
}
148+
149+
count(): number {
150+
return this.clients.size;
151+
}
152+
}

0 commit comments

Comments
 (0)