g1t/apps/api/src/oauth.ts

139 lines4,795 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

OAuth 2.1 sign-in for MCP clients and other applications1import { Hono } from "hono";
2
3import {
4 type IdentityApi,
5 decodeOAuthClient,
6 encodeOAuthClient,
7} from "@g1t/contracts";
8
9/**
10 * The OAuth 2.1 endpoints an application calls directly. The page where a
11 * person approves is on the site, at g1t.sh/oauth/authorize.
12 *
13 * Applications sign people in with the authorization code flow and PKCE.
14 * They are public clients: none holds a secret.
15 */
16const ISSUER = "https://api.g1t.sh";
17const MCP_RESOURCE = "https://mcp.g1t.sh";
18
19/** Authorization server metadata (RFC 8414). */
20const SERVER_METADATA = {
21 issuer: ISSUER,
22 authorization_endpoint: "https://g1t.sh/oauth/authorize",
23 token_endpoint: `${ISSUER}/oauth/token`,
24 registration_endpoint: `${ISSUER}/oauth/register`,
25 response_types_supported: ["code"],
26 grant_types_supported: ["authorization_code", "refresh_token"],
27 code_challenge_methods_supported: ["S256"],
28 token_endpoint_auth_methods_supported: ["none"],
29 service_documentation: "https://docs.g1t.sh/guides/authentication/",
30};
31
32/** What an MCP client is told when it must sign in first (RFC 9728). */
33export const MCP_CHALLENGE = `Bearer resource_metadata="${MCP_RESOURCE}/.well-known/oauth-protected-resource"`;
34
35type Fields = Record<string, unknown>;
36
37function oauthError(error: string, description: string, status: 400 | 401 = 400) {
38 return Response.json(
39 { error, error_description: description },
40 { status, headers: { "cache-control": "no-store" } },
41 );
42}
43
44/** The request body as fields, whether sent as a form or as JSON. */
45async function fields(request: Request): Promise<Fields> {
46 try {
47 if (request.headers.get("content-type")?.includes("json")) return await request.json();
48 return Object.fromEntries(await request.formData());
49 } catch {
50 return {};
51 }
52}
53
54export const oauth = new Hono<{ Variables: { services: { IDENTITY: IdentityApi } } }>();
55
56oauth.get("/.well-known/oauth-authorization-server", (c) => c.json(SERVER_METADATA));
57
58// Served for the MCP server, with or without its path appended.
59oauth.get("/.well-known/oauth-protected-resource/*", protectedResource);
60oauth.get("/.well-known/oauth-protected-resource", protectedResource);
61
62function protectedResource() {
63 return Response.json({
64 resource: MCP_RESOURCE,
65 authorization_servers: [ISSUER],
66 bearer_methods_supported: ["header"],
67 resource_documentation: "https://docs.g1t.sh/guides/bring-your-own-agent/",
68 });
69}
70
71// Dynamic client registration (RFC 7591). Nothing is stored: the client id
72// returned is the registration itself, encoded.
73oauth.post("/oauth/register", async (c) => {
74 const body = await fields(c.req.raw);
75 const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : [];
76 const name = typeof body.client_name === "string" ? body.client_name : "";
77 const clientId = encodeOAuthClient({ name, redirectUris });
78 if (!clientId) {
79 return oauthError(
80 "invalid_redirect_uri",
81 "Give one to five redirect_uris: https addresses, http on localhost, or the application's own scheme.",
82 );
83 }
84 const client = decodeOAuthClient(clientId)!;
85 return c.json(
86 {
87 client_id: clientId,
88 client_name: client.name,
89 redirect_uris: client.redirectUris,
90 grant_types: ["authorization_code", "refresh_token"],
91 response_types: ["code"],
92 token_endpoint_auth_method: "none",
93 },
94 201,
95 );
96});
97
98oauth.post("/oauth/token", async (c) => {
99 const body = await fields(c.req.raw);
100 const text = (key: string) => (typeof body[key] === "string" ? (body[key] as string) : "");
101 const identity = c.get("services").IDENTITY;
102
103 let result;
104 switch (text("grant_type")) {
105 case "authorization_code":
106 if (!text("code") || !text("code_verifier") || !text("client_id")) {
107 return oauthError("invalid_request", "code, code_verifier and client_id are required.");
108 }
109 result = await identity.oauthExchange(
110 text("code"),
111 text("code_verifier"),
112 text("client_id"),
113 text("redirect_uri"),
114 );
115 break;
116 case "refresh_token":
117 if (!text("refresh_token") || !text("client_id")) {
118 return oauthError("invalid_request", "refresh_token and client_id are required.");
119 }
120 result = await identity.oauthRefresh(text("refresh_token"), text("client_id"));
121 break;
122 default:
123 return oauthError(
124 "unsupported_grant_type",
125 "grant_type must be authorization_code or refresh_token.",
126 );
127 }
128 if (!result.ok) return oauthError("invalid_grant", result.error.message);
129 return c.json(
130 {
131 access_token: result.value.accessToken,
132 token_type: "Bearer",
133 expires_in: result.value.expiresIn,
134 refresh_token: result.value.refreshToken,
135 },
136 200,
137 { "cache-control": "no-store" },
138 );
139});