Commit4c27627
feat(oauth): dynamic client registration (RFC 7591) — agents self-connect
feat(oauth): dynamic client registration (RFC 7591) — agents self-connect The core of "usable by any agent": claude.ai remote connectors, Cursor, and Copilot can now register themselves as OAuth clients with no human filling out a form, then run the standard authorization-code + PKCE flow. - POST /oauth/register (RFC 7591): validates redirect_uris (https or http-localhost, no wildcards, max 8), token_endpoint_auth_method (none=public/PKCE, or confidential+secret), intersects scope with SUPPORTED_SCOPES. Returns client_id, client_secret (confidential only), a registration_access_token, and RFC-7591 metadata. Rate-limited (10/min). - Migration 0111: oauth_apps.owner_id becomes NULLABLE (self-registered clients have no human owner) + dynamically_registered flag + registration_access_token_hash. Backward-compatible — the only code reading owner_id filters "apps owned by user X", which a NULL owner never matches, so DCR apps are correctly invisible in human app lists. - RFC 8414 metadata now advertises registration_endpoint (it exists now). - Reuses generateClientId/Secret, isValidRedirectUri, parseScopes, sha256Hex, serializeScopes from src/lib/oauth.ts; existing /token flow already handles public (PKCE) clients unchanged. 9 new tests. Suite 2952 pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed+331−114c27627bce3b48c9888874bf7ce3f5d1ee552654
6 changed files+331−11
Addeddrizzle/0111_oauth_dcr.sql+27−0View fileUnifiedSplit
@@ -0,0 +1,27 @@
1-- OAuth Dynamic Client Registration (RFC 7591).
2--
3-- Self-registering clients (claude.ai remote connectors, Cursor, Copilot,
4-- etc.) have NO human owner — they POST their metadata to /oauth/register and
5-- get back a client_id/secret. To allow that, owner_id becomes nullable and a
6-- `dynamically_registered` flag distinguishes these apps from human-created
7-- ones. Existing rows keep their owner and default to dynamically_registered
8-- = false, so nothing about the current developer-apps UI changes.
9--
10-- Additive + backward-compatible: the only code that reads owner_id filters
11-- "apps owned by user X" (developer-apps.tsx); a NULL owner simply never
12-- matches, so DCR apps are correctly invisible in any human's app list.
13
14ALTER TABLE oauth_apps
15 ALTER COLUMN owner_id DROP NOT NULL;
16
17ALTER TABLE oauth_apps
18 ADD COLUMN IF NOT EXISTS dynamically_registered BOOLEAN NOT NULL DEFAULT FALSE;
19
20-- Optional per-RFC-7591 registration management token (hashed). Lets a client
21-- later read/update/delete its own registration without a user session.
22ALTER TABLE oauth_apps
23 ADD COLUMN IF NOT EXISTS registration_access_token_hash TEXT;
24
25CREATE INDEX IF NOT EXISTS idx_oauth_apps_dcr
26 ON oauth_apps(dynamically_registered)
27 WHERE dynamically_registered = TRUE;
Addedsrc/__tests__/oauth-dcr.test.ts+98−0View fileUnifiedSplit
@@ -0,0 +1,98 @@
1import { describe, it, expect } from "bun:test";
2import app from "../app";
3
4// RFC 7591 Dynamic Client Registration — POST /oauth/register.
5// The validation paths (redirect_uris, auth method) run BEFORE any DB access,
6// so they're deterministic without a test DB. The happy path needs a DB, so it
7// returns 201 with a DB or 503 without one — we assert both shapes.
8
9async function register(body: unknown) {
10 return app.request("/oauth/register", {
11 method: "POST",
12 headers: { "content-type": "application/json" },
13 body: typeof body === "string" ? body : JSON.stringify(body),
14 });
15}
16
17describe("POST /oauth/register — validation (RFC 7591)", () => {
18 it("rejects a non-JSON body", async () => {
19 const res = await register("{not json");
20 expect(res.status).toBe(400);
21 expect((await res.json()).error).toBe("invalid_client_metadata");
22 });
23
24 it("requires a non-empty redirect_uris array", async () => {
25 const res = await register({ client_name: "X" });
26 expect(res.status).toBe(400);
27 expect((await res.json()).error).toBe("invalid_redirect_uri");
28 });
29
30 it("rejects a wildcard redirect URI", async () => {
31 const res = await register({
32 redirect_uris: ["https://evil.example.com/*"],
33 });
34 expect(res.status).toBe(400);
35 expect((await res.json()).error).toBe("invalid_redirect_uri");
36 });
37
38 it("rejects a non-https remote redirect URI", async () => {
39 const res = await register({
40 redirect_uris: ["http://evil.example.com/cb"],
41 });
42 expect(res.status).toBe(400);
43 expect((await res.json()).error).toBe("invalid_redirect_uri");
44 });
45
46 it("rejects too many redirect URIs", async () => {
47 const res = await register({
48 redirect_uris: Array.from({ length: 9 }, (_, i) => `https://a${i}.example.com/cb`),
49 });
50 expect(res.status).toBe(400);
51 expect((await res.json()).error).toBe("invalid_redirect_uri");
52 });
53
54 it("rejects an unsupported token_endpoint_auth_method", async () => {
55 const res = await register({
56 redirect_uris: ["https://claude.ai/cb"],
57 token_endpoint_auth_method: "private_key_jwt",
58 });
59 expect(res.status).toBe(400);
60 expect((await res.json()).error).toBe("invalid_client_metadata");
61 });
62
63 it("accepts a valid public-client registration (201) or degrades to 503", async () => {
64 const res = await register({
65 client_name: "Claude (test)",
66 redirect_uris: ["https://claude.ai/api/mcp/callback"],
67 token_endpoint_auth_method: "none",
68 scope: "read:repo write:pr bogus:scope",
69 });
70 expect([201, 503]).toContain(res.status);
71 if (res.status === 201) {
72 const doc = await res.json();
73 expect(doc.client_id).toMatch(/^glc_app_/);
74 // Public client (auth method "none") gets NO client_secret.
75 expect(doc.client_secret).toBeUndefined();
76 expect(doc.token_endpoint_auth_method).toBe("none");
77 expect(doc.grant_types).toContain("authorization_code");
78 expect(doc.client_secret_expires_at).toBe(0);
79 expect(doc.registration_access_token).toBeTruthy();
80 // Unknown scope dropped; known scopes preserved.
81 expect(doc.scope).toContain("read:repo");
82 expect(doc.scope).not.toContain("bogus:scope");
83 }
84 });
85
86 it("accepts a confidential-client registration and returns a secret (201) or 503", async () => {
87 const res = await register({
88 client_name: "Confidential (test)",
89 redirect_uris: ["https://app.example.com/callback"],
90 token_endpoint_auth_method: "client_secret_basic",
91 });
92 expect([201, 503]).toContain(res.status);
93 if (res.status === 201) {
94 const doc = await res.json();
95 expect(doc.client_secret).toMatch(/^glcs_/);
96 }
97 });
98});
Modifiedsrc/__tests__/well-known.test.ts+10−4View fileUnifiedSplit
@@ -46,13 +46,19 @@ describe("RFC 8414 — authorization server metadata", () => {
4646 expect(doc.token_endpoint_auth_methods_supported).toContain("none");
4747 });
4848
49 it("does NOT advertise endpoints that don't exist yet (register/introspect)", async () => {
49 it("advertises the registration_endpoint (RFC 7591 DCR is live)", async () => {
5050 const doc = await (
5151 await app.request("/.well-known/oauth-authorization-server")
5252 ).json();
53 // Guard against re-introducing a broken discovery flow: only advertise
54 // registration_endpoint / introspection_endpoint once they're built.
55 expect(doc.registration_endpoint).toBeUndefined();
53 expect(doc.registration_endpoint).toBe("https://gluecron.com/oauth/register");
54 });
55
56 it("does NOT advertise introspection until it ships", async () => {
57 const doc = await (
58 await app.request("/.well-known/oauth-authorization-server")
59 ).json();
60 // Guard: only advertise introspection_endpoint once POST /oauth/introspect
61 // actually exists, or clients that probe it will 404.
5662 expect(doc.introspection_endpoint).toBeUndefined();
5763 });
5864});
Modifiedsrc/db/schema.ts+11−3View fileUnifiedSplit
@@ -1295,9 +1295,11 @@ export const oauthApps = pgTable(
12951295 "oauth_apps",
12961296 {
12971297 id: uuid("id").primaryKey().defaultRandom(),
1298 ownerId: uuid("owner_id")
1299 .notNull()
1300 .references(() => users.id, { onDelete: "cascade" }),
1298 // Nullable since 0111: dynamically-registered clients (RFC 7591) have no
1299 // human owner. Human-created apps always set this.
1300 ownerId: uuid("owner_id").references(() => users.id, {
1301 onDelete: "cascade",
1302 }),
13011303 name: text("name").notNull(),
13021304 clientId: text("client_id").notNull().unique(),
13031305 clientSecretHash: text("client_secret_hash").notNull(),
@@ -1311,6 +1313,12 @@ export const oauthApps = pgTable(
13111313 * Public SPA/mobile apps should set this to `false` and use PKCE.
13121314 */
13131315 confidential: boolean("confidential").default(true).notNull(),
1316 /** RFC 7591 — true when the app registered itself via POST /oauth/register. */
1317 dynamicallyRegistered: boolean("dynamically_registered")
1318 .default(false)
1319 .notNull(),
1320 /** RFC 7591 §4.3 — hashed registration_access_token for self-management. */
1321 registrationAccessTokenHash: text("registration_access_token_hash"),
13141322 revokedAt: timestamp("revoked_at"),
13151323 createdAt: timestamp("created_at").defaultNow().notNull(),
13161324 updatedAt: timestamp("updated_at").defaultNow().notNull(),
Modifiedsrc/routes/oauth.tsx+179−0View fileUnifiedSplit
@@ -19,6 +19,7 @@
1919import { Hono } from "hono";
2020import { and, eq, gt, isNull } from "drizzle-orm";
2121import { db } from "../db";
22import { config } from "../lib/config";
2223import {
2324 oauthApps,
2425 oauthAuthorizations,
@@ -33,17 +34,22 @@ import {
3334 generateAuthCode,
3435 generateAccessToken,
3536 generateRefreshToken,
37 generateClientId,
38 generateClientSecret,
39 isValidRedirectUri,
3640 sha256Hex,
3741 verifyPkce,
3842 parseScopes,
3943 parseRedirectUris,
4044 redirectUriAllowed,
45 serializeScopes,
4146 timingSafeEqual,
4247 ACCESS_TOKEN_TTL_MS,
4348 REFRESH_TOKEN_TTL_MS,
4449 AUTH_CODE_TTL_MS,
4550 SUPPORTED_SCOPES,
4651} from "../lib/oauth";
52import { authRateLimit } from "../middleware/rate-limit";
4753import { audit } from "../lib/notify";
4854
4955const oauth = new Hono<AuthEnv>();
@@ -52,6 +58,10 @@ oauth.use("/oauth/authorize", requireAuth);
5258oauth.use("/oauth/authorize/decision", requireAuth);
5359oauth.use("/settings/authorizations", requireAuth);
5460oauth.use("/settings/authorizations/*", requireAuth);
61// DCR is unauthenticated (that's the point — clients self-register), so it
62// MUST be rate-limited to prevent registration spam. Reuse the strict auth
63// limiter (10/min per IP).
64oauth.use("/oauth/register", authRateLimit);
5565
5666// --- scope explainer copy (human-readable consent text) --------------------
5767//
@@ -1362,6 +1372,175 @@ oauth.post("/settings/authorizations/:appId/revoke", async (c) => {
13621372 }
13631373});
13641374
1375// ---------------------------------------------------------------------------
1376// RFC 7591 — OAuth 2.0 Dynamic Client Registration
1377//
1378// POST /oauth/register
1379//
1380// Lets an agent (claude.ai remote connectors, Cursor, Copilot, …) register
1381// itself as an OAuth client with no human in the loop, then run the normal
1382// authorization-code + PKCE flow. Rate-limited (see the middleware above).
1383//
1384// Request (application/json), RFC 7591 §2:
1385// { "client_name": "...", "redirect_uris": ["https://.../callback"],
1386// "token_endpoint_auth_method": "none" | "client_secret_basic" | "client_secret_post",
1387// "scope": "read:repo write:pr", "client_uri": "https://..." }
1388//
1389// Response 201 (RFC 7591 §3.2.1): client_id, [client_secret], issued_at,
1390// client_secret_expires_at (0 = never), the echoed metadata, plus a
1391// registration_access_token + registration_client_uri for self-management.
1392// ---------------------------------------------------------------------------
1393
1394/** Cap on how many redirect URIs a single dynamic registration may declare. */
1395const DCR_MAX_REDIRECT_URIS = 8;
1396
1397function dcrError(
1398 c: Parameters<Parameters<typeof oauth.post>[1]>[0],
1399 error: string,
1400 description: string,
1401 status: 400 | 401 | 403 = 400
1402) {
1403 return c.json({ error, error_description: description }, status);
1404}
1405
1406oauth.post("/oauth/register", async (c) => {
1407 let body: Record<string, unknown> = {};
1408 try {
1409 body = (await c.req.json()) as Record<string, unknown>;
1410 } catch {
1411 return dcrError(c, "invalid_client_metadata", "Body must be JSON");
1412 }
1413
1414 // redirect_uris — required, non-empty, each a valid https (or http-localhost)
1415 // absolute URI with no wildcard/fragment (isValidRedirectUri enforces this).
1416 const rawUris = body.redirect_uris;
1417 if (!Array.isArray(rawUris) || rawUris.length === 0) {
1418 return dcrError(
1419 c,
1420 "invalid_redirect_uri",
1421 "redirect_uris is required and must be a non-empty array"
1422 );
1423 }
1424 if (rawUris.length > DCR_MAX_REDIRECT_URIS) {
1425 return dcrError(
1426 c,
1427 "invalid_redirect_uri",
1428 `At most ${DCR_MAX_REDIRECT_URIS} redirect URIs are allowed`
1429 );
1430 }
1431 const redirectUris: string[] = [];
1432 for (const u of rawUris) {
1433 if (typeof u !== "string" || !isValidRedirectUri(u)) {
1434 return dcrError(
1435 c,
1436 "invalid_redirect_uri",
1437 `Invalid redirect URI: ${typeof u === "string" ? u : "(non-string)"}`
1438 );
1439 }
1440 redirectUris.push(u);
1441 }
1442
1443 // token_endpoint_auth_method — "none" ⇒ public client (PKCE, no secret);
1444 // anything else ⇒ confidential (issued a client_secret). Default per RFC
1445 // 7591 §2 is client_secret_basic (confidential).
1446 const authMethod = body.token_endpoint_auth_method
1447 ? String(body.token_endpoint_auth_method)
1448 : "client_secret_basic";
1449 const ALLOWED_AUTH_METHODS = [
1450 "none",
1451 "client_secret_basic",
1452 "client_secret_post",
1453 ];
1454 if (!ALLOWED_AUTH_METHODS.includes(authMethod)) {
1455 return dcrError(
1456 c,
1457 "invalid_client_metadata",
1458 `Unsupported token_endpoint_auth_method: ${authMethod}`
1459 );
1460 }
1461 const isPublic = authMethod === "none";
1462
1463 // scope — optional; intersect with SUPPORTED_SCOPES (unknown scopes dropped).
1464 const requestedScopes = parseScopes(
1465 typeof body.scope === "string" ? body.scope : ""
1466 );
1467
1468 const clientName =
1469 typeof body.client_name === "string" && body.client_name.trim()
1470 ? body.client_name.trim().slice(0, 120)
1471 : "Dynamically Registered Client";
1472 const clientUri =
1473 typeof body.client_uri === "string" ? body.client_uri.slice(0, 500) : null;
1474
1475 // Secrets. Public clients still get a stored (unused) secret hash to satisfy
1476 // NOT NULL — authenticateClient() short-circuits for non-confidential apps,
1477 // so it is never checked. Only confidential clients get a returned secret.
1478 const clientId = generateClientId();
1479 const clientSecret = generateClientSecret();
1480 const clientSecretHash = await sha256Hex(clientSecret);
1481 // A registration_access_token lets the client later manage its own record
1482 // (RFC 7591 §4). We hash it; the plaintext is returned once.
1483 const regAccessToken = generateAccessToken();
1484 const regAccessTokenHash = await sha256Hex(regAccessToken);
1485
1486 try {
1487 const [row] = await db
1488 .insert(oauthApps)
1489 .values({
1490 ownerId: null, // self-registered — no human owner
1491 name: clientName,
1492 clientId,
1493 clientSecretHash,
1494 clientSecretPrefix: clientSecret.slice(0, 8),
1495 redirectUris: redirectUris.join("\n"),
1496 homepageUrl: clientUri,
1497 description: "Registered via OAuth Dynamic Client Registration (RFC 7591).",
1498 confidential: !isPublic,
1499 dynamicallyRegistered: true,
1500 registrationAccessTokenHash: regAccessTokenHash,
1501 })
1502 .returning({ id: oauthApps.id });
1503
1504 // Best-effort audit — never block registration on it.
1505 audit({
1506 userId: null,
1507 action: "oauth.client.dynamically_registered",
1508 targetType: "oauth_app",
1509 targetId: row?.id,
1510 metadata: { clientId, clientName, isPublic, redirectUris },
1511 }).catch(() => {});
1512
1513 const base = config.appBaseUrl;
1514 const responseBody: Record<string, unknown> = {
1515 client_id: clientId,
1516 client_id_issued_at: Math.floor(Date.now() / 1000),
1517 client_secret_expires_at: 0, // never expires
1518 client_name: clientName,
1519 redirect_uris: redirectUris,
1520 grant_types: ["authorization_code", "refresh_token"],
1521 response_types: ["code"],
1522 token_endpoint_auth_method: authMethod,
1523 scope: serializeScopes(requestedScopes),
1524 registration_access_token: regAccessToken,
1525 registration_client_uri: `${base}/oauth/register/${clientId}`,
1526 };
1527 if (!isPublic) {
1528 responseBody.client_secret = clientSecret;
1529 }
1530 return c.json(responseBody, 201);
1531 } catch {
1532 // Validation already passed, so a failure here is server-side (e.g. the
1533 // DB is unreachable) — 503, not a client-metadata error.
1534 return c.json(
1535 {
1536 error: "temporarily_unavailable",
1537 error_description: "Registration could not be completed right now",
1538 },
1539 503
1540 );
1541 }
1542});
1543
13651544export default oauth;
13661545
13671546// re-export for test visibility
Modifiedsrc/routes/well-known.ts+6−4View fileUnifiedSplit
@@ -12,10 +12,10 @@
1212 * GET /.well-known/oauth-protected-resource
1313 * GET /.well-known/oauth-protected-resource/mcp (resource-specific)
1414 *
15 * `registration_endpoint` (RFC 7591 dynamic client registration) and
16 * `introspection_endpoint` (RFC 7662) are intentionally omitted until those
17 * endpoints ship — advertising a 404 would break the discovery flow. When
18 * they land, add them here in the same change.
15 * `registration_endpoint` (RFC 7591 dynamic client registration) is now live
16 * at POST /oauth/register and advertised below. `introspection_endpoint`
17 * (RFC 7662) is still omitted until it ships — advertising a 404 would break
18 * clients that probe it. Add it here in the same change that builds it.
1919 *
2020 * Pure/read-only, no auth, no DB. Safe to serve to anyone.
2121 */
@@ -45,6 +45,8 @@ wellKnown.get("/.well-known/oauth-authorization-server", (c) => {
4545 authorization_endpoint: `${b}/oauth/authorize`,
4646 token_endpoint: `${b}/oauth/token`,
4747 revocation_endpoint: `${b}/oauth/revoke`,
48 // RFC 7591 dynamic client registration — lets agents self-register.
49 registration_endpoint: `${b}/oauth/register`,
4850 scopes_supported: SUPPORTED_SCOPES,
4951 response_types_supported: ["code"],
5052 grant_types_supported: ["authorization_code", "refresh_token"],
5153