Commit369ad65
feat(oauth): serve RFC 8414 + RFC 9728 discovery metadata
feat(oauth): serve RFC 8414 + RFC 9728 discovery metadata First piece of "connectable by any agent": expose the well-known documents that claude.ai remote connectors, Cursor, and Copilot read to auto-discover how to authenticate — no manual config. - GET /.well-known/oauth-authorization-server (RFC 8414): issuer, authorize/ token/revoke endpoints, PKCE S256, scopes, public-client (none) auth. - GET /.well-known/oauth-protected-resource (+ /mcp form) (RFC 9728): points the /mcp resource at this authorization server. Advertises ONLY endpoints that exist today — registration_endpoint (RFC 7591 DCR) and introspection_endpoint (RFC 7662) are deliberately omitted until they ship, with a test guarding against re-adding a broken discovery flow. New route is pure/read-only/no-auth; reuses SUPPORTED_SCOPES + config.appBaseUrl. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed+177−0369ad65afe63442abe10402073a24482e978bc7e
3 changed files+177−0
Addedsrc/__tests__/well-known.test.ts+76−0View fileUnifiedSplit
@@ -0,0 +1,76 @@
1import { describe, it, expect, beforeAll } from "bun:test";
2import app from "../app";
3import { SUPPORTED_SCOPES } from "../lib/oauth";
4
5// These endpoints are what claude.ai / Cursor / Copilot read first to learn
6// how to authenticate. They must be anonymous, JSON, and advertise only
7// endpoints that actually exist.
8
9beforeAll(() => {
10 process.env.APP_BASE_URL = "https://gluecron.com";
11});
12
13describe("RFC 8414 — authorization server metadata", () => {
14 it("serves valid metadata anonymously", async () => {
15 const res = await app.request("/.well-known/oauth-authorization-server");
16 expect(res.status).toBe(200);
17 expect(res.headers.get("content-type") || "").toContain("application/json");
18 const doc = await res.json();
19 expect(doc.issuer).toBe("https://gluecron.com");
20 expect(doc.authorization_endpoint).toBe("https://gluecron.com/oauth/authorize");
21 expect(doc.token_endpoint).toBe("https://gluecron.com/oauth/token");
22 expect(doc.revocation_endpoint).toBe("https://gluecron.com/oauth/revoke");
23 });
24
25 it("advertises PKCE S256 and the authorization_code + refresh grants", async () => {
26 const doc = await (
27 await app.request("/.well-known/oauth-authorization-server")
28 ).json();
29 expect(doc.code_challenge_methods_supported).toContain("S256");
30 expect(doc.grant_types_supported).toContain("authorization_code");
31 expect(doc.grant_types_supported).toContain("refresh_token");
32 expect(doc.response_types_supported).toEqual(["code"]);
33 });
34
35 it("advertises exactly the supported scopes", async () => {
36 const doc = await (
37 await app.request("/.well-known/oauth-authorization-server")
38 ).json();
39 expect(doc.scopes_supported).toEqual([...SUPPORTED_SCOPES]);
40 });
41
42 it("supports the public-client (none) auth method for PKCE apps", async () => {
43 const doc = await (
44 await app.request("/.well-known/oauth-authorization-server")
45 ).json();
46 expect(doc.token_endpoint_auth_methods_supported).toContain("none");
47 });
48
49 it("does NOT advertise endpoints that don't exist yet (register/introspect)", async () => {
50 const doc = await (
51 await app.request("/.well-known/oauth-authorization-server")
52 ).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();
56 expect(doc.introspection_endpoint).toBeUndefined();
57 });
58});
59
60describe("RFC 9728 — protected resource metadata", () => {
61 it("serves the resource metadata for the MCP endpoint", async () => {
62 const res = await app.request("/.well-known/oauth-protected-resource");
63 expect(res.status).toBe(200);
64 const doc = await res.json();
65 expect(doc.resource).toBe("https://gluecron.com/mcp");
66 expect(doc.authorization_servers).toEqual(["https://gluecron.com"]);
67 expect(doc.bearer_methods_supported).toContain("header");
68 });
69
70 it("also serves the resource-specific /mcp form", async () => {
71 const res = await app.request("/.well-known/oauth-protected-resource/mcp");
72 expect(res.status).toBe(200);
73 const doc = await res.json();
74 expect(doc.resource).toBe("https://gluecron.com/mcp");
75 });
76});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -90,6 +90,7 @@ import hookRoutes from "./routes/hooks";
9090import eventsRoutes from "./routes/events";
9191import passkeyRoutes from "./routes/passkeys";
9292import oauthRoutes from "./routes/oauth";
93import wellKnownRoutes from "./routes/well-known";
9394import developerAppsRoutes from "./routes/developer-apps";
9495import themeRoutes from "./routes/theme";
9596import auditRoutes from "./routes/audit";
@@ -474,6 +475,9 @@ app.route("/", passkeyRoutes);
474475// OAuth 2.0 provider (Block B6)
475476app.route("/", oauthRoutes);
476477app.route("/", developerAppsRoutes);
478// OAuth / agent discovery metadata (RFC 8414 + RFC 9728) — lets claude.ai,
479// Cursor, Copilot, etc. auto-discover how to authenticate.
480app.route("/", wellKnownRoutes);
477481
478482// Theme toggle (dark/light cookie)
479483app.route("/", themeRoutes);
Addedsrc/routes/well-known.ts+97−0View fileUnifiedSplit
@@ -0,0 +1,97 @@
1/**
2 * OAuth / agent discovery metadata under `/.well-known/*`.
3 *
4 * These endpoints let an AI agent (claude.ai remote connectors, Cursor,
5 * Copilot, etc.) auto-discover how to authenticate against Gluecron without
6 * any manual configuration. They advertise ONLY endpoints that actually
7 * exist today:
8 *
9 * - RFC 8414 Authorization Server Metadata
10 * GET /.well-known/oauth-authorization-server
11 * - RFC 9728 Protected Resource Metadata
12 * GET /.well-known/oauth-protected-resource
13 * GET /.well-known/oauth-protected-resource/mcp (resource-specific)
14 *
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.
19 *
20 * Pure/read-only, no auth, no DB. Safe to serve to anyone.
21 */
22
23import { Hono } from "hono";
24import { config } from "../lib/config";
25import { SUPPORTED_SCOPES } from "../lib/oauth";
26
27const wellKnown = new Hono();
28
29/** Absolute URL on this deployment's public origin (no trailing slash). */
30function base(): string {
31 // config.appBaseUrl already strips a trailing slash.
32 return config.appBaseUrl;
33}
34
35/**
36 * RFC 8414 — OAuth 2.0 Authorization Server Metadata.
37 * The document a client reads to learn where /authorize and /token live,
38 * which PKCE methods are supported, and which scopes exist.
39 */
40wellKnown.get("/.well-known/oauth-authorization-server", (c) => {
41 const b = base();
42 return c.json(
43 {
44 issuer: b,
45 authorization_endpoint: `${b}/oauth/authorize`,
46 token_endpoint: `${b}/oauth/token`,
47 revocation_endpoint: `${b}/oauth/revoke`,
48 scopes_supported: SUPPORTED_SCOPES,
49 response_types_supported: ["code"],
50 grant_types_supported: ["authorization_code", "refresh_token"],
51 // PKCE (RFC 7636) — the provider verifies S256 and plain.
52 code_challenge_methods_supported: ["S256", "plain"],
53 token_endpoint_auth_methods_supported: [
54 "client_secret_basic",
55 "client_secret_post",
56 "none", // public clients (PKCE, no secret)
57 ],
58 revocation_endpoint_auth_methods_supported: [
59 "client_secret_basic",
60 "client_secret_post",
61 "none",
62 ],
63 service_documentation: `${b}/api/docs`,
64 },
65 200,
66 { "cache-control": "public, max-age=3600" }
67 );
68});
69
70/**
71 * RFC 9728 — OAuth 2.0 Protected Resource Metadata.
72 *
73 * Served both at the root path and at `/mcp` (resource-specific). A remote
74 * MCP connector reads this to learn which authorization server guards the
75 * `/mcp` resource and which scopes it accepts.
76 */
77function protectedResourceDoc(c: Parameters<Parameters<typeof wellKnown.get>[1]>[0]) {
78 const b = base();
79 return c.json(
80 {
81 resource: `${b}/mcp`,
82 authorization_servers: [b],
83 scopes_supported: SUPPORTED_SCOPES,
84 bearer_methods_supported: ["header"],
85 resource_documentation: `${b}/api/docs`,
86 },
87 200,
88 { "cache-control": "public, max-age=3600" }
89 );
90}
91
92wellKnown.get("/.well-known/oauth-protected-resource", protectedResourceDoc);
93// Resource-specific form (RFC 9728 §3.1) — some clients append the resource
94// path when the WWW-Authenticate challenge points at `<origin>/mcp`.
95wellKnown.get("/.well-known/oauth-protected-resource/mcp", protectedResourceDoc);
96
97export default wellKnown;
098