Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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>
ccanty labs committed on July 13, 2026Parent: a0071d7
6 files changed+331114c27627bce3b48c9888874bf7ce3f5d1ee552654
6 changed files+331−11
Addeddrizzle/0111_oauth_dcr.sql+27−0View fileUnifiedSplit
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
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
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
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(),
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
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,
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>();
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//
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
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 */
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