@vantageos/mcp-boilerplate 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +29 -8
- package/.mcp.json +8 -0
- package/CHANGELOG.md +71 -1
- package/README.fr.md +177 -142
- package/README.md +170 -133
- package/dist/auth.d.ts +20 -10
- package/dist/auth.js +9 -8
- package/dist/config.d.ts +10 -3
- package/dist/config.js +31 -3
- package/dist/exampleItems.d.ts +5 -4
- package/dist/exampleItems.js +3 -0
- package/dist/http.js +11 -1
- package/dist/httpApp.d.ts +23 -30
- package/dist/httpApp.js +73 -74
- package/dist/i18n.d.ts +6 -0
- package/dist/i18n.js +9 -0
- package/dist/locales/en.json +9 -0
- package/dist/locales/fr.json +9 -0
- package/dist/mcpServerFactory.d.ts +42 -41
- package/dist/mcpServerFactory.js +114 -146
- package/dist/oauth/authorizationServer.d.ts +56 -0
- package/dist/oauth/authorizationServer.js +423 -0
- package/dist/oauth/clerk.d.ts +15 -0
- package/dist/oauth/clerk.js +59 -0
- package/dist/oauth/clientRegistry.d.ts +50 -0
- package/dist/oauth/clientRegistry.js +203 -0
- package/dist/oauth/codeStore.d.ts +11 -0
- package/dist/oauth/codeStore.js +28 -0
- package/dist/oauth/picker.d.ts +11 -0
- package/dist/oauth/picker.js +67 -0
- package/dist/oauth/settings.d.ts +48 -0
- package/dist/oauth/settings.js +100 -0
- package/dist/oauth/tokenIssuer.d.ts +22 -0
- package/dist/oauth/tokenIssuer.js +61 -0
- package/dist/stdio.d.ts +7 -6
- package/dist/stdio.js +20 -19
- package/dist/tenancy.d.ts +16 -21
- package/dist/tenancy.js +12 -26
- package/dist/ui/uiResource.d.ts +36 -3
- package/dist/ui/uiResource.js +40 -6
- package/dist/version.d.ts +2 -0
- package/dist/version.js +8 -0
- package/docs/architecture.md +82 -0
- package/docs/oauth-stores.md +60 -0
- package/examples/oauth.env.example +25 -0
- package/mcp.json +19 -0
- package/package.json +23 -14
- package/plugin.json +43 -0
- package/scripts/plugin-manifest.source.json +35 -0
- package/skills/connect-mcp-server/SKILL.md +40 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Own OAuth 2.1 authorization server (ruling R1). A code is issued ONLY to a signed-in Clerk
|
|
3
|
+
* person who picked one of their own organisations and approved the request on the picker
|
|
4
|
+
* page (consent ON). Routes:
|
|
5
|
+
*
|
|
6
|
+
* GET /.well-known/oauth-authorization-server (+ /.well-known/openid-configuration)
|
|
7
|
+
* GET /.well-known/jwks.json public signing keys
|
|
8
|
+
* POST /register Dynamic Client Registration (public clients)
|
|
9
|
+
* GET /authorize start; CIMD or DCR client_id
|
|
10
|
+
* GET /authorize/callback return from Clerk sign-in
|
|
11
|
+
* POST /authorize/org person + organisation picker submit
|
|
12
|
+
* POST /token authorization_code + PKCE S256
|
|
13
|
+
* GET /userinfo
|
|
14
|
+
*
|
|
15
|
+
* EVERY decision lives in @vantageos/cloud-identity (startAuthorize, resumeAuthorize,
|
|
16
|
+
* exchangeAuthorizationCode, buildDiscoveryDocument, buildUserInfo, oauthErrorFor). This
|
|
17
|
+
* module owns only what the package takes as parameters: environment, stores, the client
|
|
18
|
+
* lookup, the Clerk calls, the picker page, and the RS256 signature of the access token.
|
|
19
|
+
*
|
|
20
|
+
* FAIL-CLOSED: any missing configuration value answers 503 `temporarily_unavailable`.
|
|
21
|
+
* No refresh tokens are issued; access tokens live one hour.
|
|
22
|
+
*/
|
|
23
|
+
import { buildDiscoveryDocument, buildUserInfo, exchangeAuthorizationCode, oauthErrorFor, resumeAuthorize, startAuthorize, } from "@vantageos/cloud-identity";
|
|
24
|
+
import { getCookie } from "hono/cookie";
|
|
25
|
+
import { cors } from "hono/cors";
|
|
26
|
+
import { detectLocaleFromAcceptLanguage, t } from "../i18n.js";
|
|
27
|
+
import { createClerkBackendCollaborators } from "./clerk.js";
|
|
28
|
+
import { createInMemoryClientRegistry, isAllowedRedirectUri, isUrlShapedClientId, resolveCimdClient, } from "./clientRegistry.js";
|
|
29
|
+
import { createInMemoryCodeStore } from "./codeStore.js";
|
|
30
|
+
import { pickerContentSecurityPolicy, renderOrgPicker } from "./picker.js";
|
|
31
|
+
import { DEFAULT_SCOPE, OAUTH_ENV, SUPPORTED_SCOPES, readAuthorizeSettings, } from "./settings.js";
|
|
32
|
+
import { ACCESS_TOKEN_TTL_SECONDS, createTokenIssuer } from "./tokenIssuer.js";
|
|
33
|
+
const REGISTER_LIMIT_PER_MINUTE = 20;
|
|
34
|
+
const MAX_REDIRECT_URIS = 10;
|
|
35
|
+
function noStore(c) {
|
|
36
|
+
c.header("Cache-Control", "no-store");
|
|
37
|
+
c.header("Pragma", "no-cache");
|
|
38
|
+
}
|
|
39
|
+
function sessionTokenFrom(c) {
|
|
40
|
+
const header = c.req.header("authorization");
|
|
41
|
+
if (header?.toLowerCase().startsWith("bearer ")) {
|
|
42
|
+
const token = header.slice("bearer ".length).trim();
|
|
43
|
+
if (token)
|
|
44
|
+
return token;
|
|
45
|
+
}
|
|
46
|
+
return getCookie(c, "__session") || undefined;
|
|
47
|
+
}
|
|
48
|
+
export function createAuthorizationServer(deps = {}) {
|
|
49
|
+
const env = deps.env ?? process.env;
|
|
50
|
+
const now = deps.now ?? (() => Date.now());
|
|
51
|
+
const codeStore = deps.codeStore ?? createInMemoryCodeStore();
|
|
52
|
+
const registry = deps.clientRegistry ?? createInMemoryClientRegistry();
|
|
53
|
+
const injectedClerk = deps.clerk;
|
|
54
|
+
const clerkInjected = Boolean(injectedClerk?.listMemberships && injectedClerk?.getUser);
|
|
55
|
+
let cachedIssuer = null;
|
|
56
|
+
const issuerFor = (settings) => {
|
|
57
|
+
const jwk = JSON.stringify(settings.signingJwk);
|
|
58
|
+
if (!cachedIssuer || cachedIssuer.jwk !== jwk) {
|
|
59
|
+
cachedIssuer = { jwk, issuer: createTokenIssuer(settings.signingJwk) };
|
|
60
|
+
}
|
|
61
|
+
return cachedIssuer.issuer;
|
|
62
|
+
};
|
|
63
|
+
// One backend instance for the process lifetime, so its JWKS cache actually caches.
|
|
64
|
+
const backendClerk = createClerkBackendCollaborators(env[OAUTH_ENV.clerkSecretKey] ?? "");
|
|
65
|
+
const clerkFor = () => ({
|
|
66
|
+
jwks: injectedClerk?.jwks ?? backendClerk.jwks,
|
|
67
|
+
listMemberships: injectedClerk?.listMemberships ?? backendClerk.listMemberships,
|
|
68
|
+
getUser: injectedClerk?.getUser ?? backendClerk.getUser,
|
|
69
|
+
});
|
|
70
|
+
const settingsNow = () => readAuthorizeSettings(env, clerkInjected);
|
|
71
|
+
const lookupClient = async (clientId) => {
|
|
72
|
+
if (isUrlShapedClientId(clientId)) {
|
|
73
|
+
return resolveCimdClient(clientId, {
|
|
74
|
+
fetchImpl: deps.fetchImpl,
|
|
75
|
+
resolveHost: deps.resolveHost,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const row = await registry.get(clientId);
|
|
79
|
+
if (!row)
|
|
80
|
+
return null;
|
|
81
|
+
return {
|
|
82
|
+
clientId: row.clientId,
|
|
83
|
+
redirectUris: row.redirectUris,
|
|
84
|
+
...(row.clientName !== undefined ? { clientName: row.clientName } : {}),
|
|
85
|
+
revoked: row.revoked === true,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
const runtimeFor = (settings) => {
|
|
89
|
+
const clerk = clerkFor();
|
|
90
|
+
const cfg = {
|
|
91
|
+
stateSecret: settings.stateSecret,
|
|
92
|
+
signInUrl: settings.signInUrl,
|
|
93
|
+
callbackUrl: settings.callbackUrl,
|
|
94
|
+
// The protected-resource metadata publishes `resource: <base>/mcp`, the URL a client
|
|
95
|
+
// configures and sends back (RFC 8707). Only that value is accepted.
|
|
96
|
+
allowedResources: [settings.resource],
|
|
97
|
+
supportedScopes: SUPPORTED_SCOPES,
|
|
98
|
+
defaultScope: DEFAULT_SCOPE,
|
|
99
|
+
session: {
|
|
100
|
+
issuer: settings.clerkIssuer,
|
|
101
|
+
jwks: () => clerk.jwks(settings.clerkIssuer),
|
|
102
|
+
authorizedParties: settings.authorizedParties,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const authDeps = {
|
|
106
|
+
lookupClient,
|
|
107
|
+
listMemberships: (userId) => clerk.listMemberships(userId),
|
|
108
|
+
codeStore,
|
|
109
|
+
now,
|
|
110
|
+
};
|
|
111
|
+
return { cfg, authDeps, clerk };
|
|
112
|
+
};
|
|
113
|
+
const unavailable = (c, missing) => {
|
|
114
|
+
// Names only, never values.
|
|
115
|
+
console.error(`[oauth] refused: missing configuration ${missing.join(", ")}`);
|
|
116
|
+
noStore(c);
|
|
117
|
+
return c.json({
|
|
118
|
+
error: "temporarily_unavailable",
|
|
119
|
+
error_description: t("en", "oauth.unavailable"),
|
|
120
|
+
}, 503);
|
|
121
|
+
};
|
|
122
|
+
const respond = async (c, outcome, settings) => {
|
|
123
|
+
noStore(c);
|
|
124
|
+
c.header("Referrer-Policy", "no-referrer");
|
|
125
|
+
switch (outcome.kind) {
|
|
126
|
+
case "redirect-to-sign-in":
|
|
127
|
+
return c.redirect(outcome.url, 302);
|
|
128
|
+
case "redirect-to-client": {
|
|
129
|
+
// RFC 9207 section 2: identify the issuer in the authorization response.
|
|
130
|
+
const back = new URL(outcome.url);
|
|
131
|
+
back.searchParams.set("iss", settings.baseUrl);
|
|
132
|
+
return c.redirect(back.toString(), 302);
|
|
133
|
+
}
|
|
134
|
+
case "org-picker": {
|
|
135
|
+
c.header("Content-Security-Policy", await pickerContentSecurityPolicy());
|
|
136
|
+
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
137
|
+
return c.html(renderOrgPicker(outcome.model, locale));
|
|
138
|
+
}
|
|
139
|
+
case "refused": {
|
|
140
|
+
const mapped = oauthErrorFor(outcome.refusal);
|
|
141
|
+
return c.json({ error: mapped.error, error_description: outcome.refusal.reason }, mapped.status);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const registerHits = new Map();
|
|
146
|
+
const registerAllowed = (key) => {
|
|
147
|
+
const t0 = now();
|
|
148
|
+
const hit = registerHits.get(key);
|
|
149
|
+
if (!hit || t0 - hit.windowStart >= 60_000) {
|
|
150
|
+
registerHits.set(key, { windowStart: t0, count: 1 });
|
|
151
|
+
if (registerHits.size > 5000)
|
|
152
|
+
registerHits.clear();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
hit.count += 1;
|
|
156
|
+
return hit.count <= REGISTER_LIMIT_PER_MINUTE;
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
async resourceServer() {
|
|
160
|
+
const result = await settingsNow();
|
|
161
|
+
if (!result.ok)
|
|
162
|
+
return result;
|
|
163
|
+
const s = result.settings;
|
|
164
|
+
const issuer = await issuerFor(s);
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
auth: {
|
|
168
|
+
realm: "mcp",
|
|
169
|
+
resourceMetadataUrl: `${s.baseUrl}/.well-known/oauth-protected-resource/mcp`,
|
|
170
|
+
resource: s.resource,
|
|
171
|
+
// The issuer publishes RSA public members only (kty "RSA", RS256, kid), the verifier shape.
|
|
172
|
+
jwks: issuer.jwks,
|
|
173
|
+
issuer: s.baseUrl,
|
|
174
|
+
audience: s.resource,
|
|
175
|
+
authorizationServers: [s.baseUrl],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
mount(app) {
|
|
180
|
+
app.use("/.well-known/*", cors());
|
|
181
|
+
app.use("/register", cors());
|
|
182
|
+
app.use("/token", cors());
|
|
183
|
+
const metadata = async (c) => {
|
|
184
|
+
const result = await settingsNow();
|
|
185
|
+
if (!result.ok)
|
|
186
|
+
return unavailable(c, result.missing);
|
|
187
|
+
const s = result.settings;
|
|
188
|
+
const doc = buildDiscoveryDocument({
|
|
189
|
+
issuer: s.baseUrl,
|
|
190
|
+
authorizationEndpoint: `${s.baseUrl}/authorize`,
|
|
191
|
+
tokenEndpoint: `${s.baseUrl}/token`,
|
|
192
|
+
jwksUri: `${s.baseUrl}/.well-known/jwks.json`,
|
|
193
|
+
userinfoEndpoint: `${s.baseUrl}/userinfo`,
|
|
194
|
+
registrationEndpoint: `${s.baseUrl}/register`,
|
|
195
|
+
scopesSupported: SUPPORTED_SCOPES,
|
|
196
|
+
});
|
|
197
|
+
if (!doc.ok) {
|
|
198
|
+
const mapped = oauthErrorFor(doc.refusal);
|
|
199
|
+
return c.json({ error: mapped.error, error_description: doc.refusal.reason }, mapped.status);
|
|
200
|
+
}
|
|
201
|
+
return c.json({
|
|
202
|
+
...doc.document,
|
|
203
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
204
|
+
// RFC 9207 — /authorize appends `iss` to every authorization response.
|
|
205
|
+
authorization_response_iss_parameter_supported: true,
|
|
206
|
+
// Client ID Metadata Documents are fetched SSRF-guarded (clientRegistry.ts).
|
|
207
|
+
client_id_metadata_document_supported: true,
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
app.get("/.well-known/oauth-authorization-server", metadata);
|
|
211
|
+
app.get("/.well-known/openid-configuration", metadata);
|
|
212
|
+
app.get("/.well-known/jwks.json", async (c) => {
|
|
213
|
+
const result = await settingsNow();
|
|
214
|
+
if (!result.ok)
|
|
215
|
+
return unavailable(c, result.missing);
|
|
216
|
+
return c.json((await issuerFor(result.settings)).jwks);
|
|
217
|
+
});
|
|
218
|
+
app.post("/register", async (c) => {
|
|
219
|
+
const key = (c.req.header("x-forwarded-for") ?? "").split(",")[0]?.trim() || "unknown";
|
|
220
|
+
if (!registerAllowed(key)) {
|
|
221
|
+
c.header("Retry-After", "60");
|
|
222
|
+
return c.json({ error: "too_many_requests" }, 429);
|
|
223
|
+
}
|
|
224
|
+
let body = {};
|
|
225
|
+
try {
|
|
226
|
+
const parsed = await c.req.json();
|
|
227
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
228
|
+
body = parsed;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// an unreadable body falls through to the redirect_uris refusal below
|
|
233
|
+
}
|
|
234
|
+
const uris = body.redirect_uris;
|
|
235
|
+
if (!Array.isArray(uris) ||
|
|
236
|
+
uris.length === 0 ||
|
|
237
|
+
uris.length > MAX_REDIRECT_URIS ||
|
|
238
|
+
!uris.every(isAllowedRedirectUri)) {
|
|
239
|
+
return c.json({
|
|
240
|
+
error: "invalid_redirect_uri",
|
|
241
|
+
error_description: "redirect_uris is required: a non-empty array of https URIs (http only on loopback), no fragment",
|
|
242
|
+
}, 400);
|
|
243
|
+
}
|
|
244
|
+
// Public clients only: PKCE protects the code, and the server issues no client secret.
|
|
245
|
+
const method = body.token_endpoint_auth_method;
|
|
246
|
+
if (method !== undefined && method !== "none") {
|
|
247
|
+
return c.json({
|
|
248
|
+
error: "invalid_client_metadata",
|
|
249
|
+
error_description: 'only token_endpoint_auth_method "none" (public client + PKCE) is supported',
|
|
250
|
+
}, 400);
|
|
251
|
+
}
|
|
252
|
+
const clientName = typeof body.client_name === "string" ? body.client_name.slice(0, 200) : "anonymous-dcr";
|
|
253
|
+
const clientId = crypto.randomUUID();
|
|
254
|
+
const issuedAt = Math.floor(now() / 1000);
|
|
255
|
+
try {
|
|
256
|
+
await registry.register({
|
|
257
|
+
clientId,
|
|
258
|
+
clientName,
|
|
259
|
+
redirectUris: uris,
|
|
260
|
+
issuedAt,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
console.error("[oauth] /register failed:", err instanceof Error ? err.message : err);
|
|
265
|
+
return c.json({ error: "server_error", error_description: "failed to persist client" }, 500);
|
|
266
|
+
}
|
|
267
|
+
noStore(c);
|
|
268
|
+
return c.json({
|
|
269
|
+
client_id: clientId,
|
|
270
|
+
client_id_issued_at: issuedAt,
|
|
271
|
+
redirect_uris: uris,
|
|
272
|
+
client_name: clientName,
|
|
273
|
+
token_endpoint_auth_method: "none",
|
|
274
|
+
grant_types: ["authorization_code"],
|
|
275
|
+
response_types: ["code"],
|
|
276
|
+
scope: DEFAULT_SCOPE,
|
|
277
|
+
}, 201);
|
|
278
|
+
});
|
|
279
|
+
app.get("/authorize", async (c) => {
|
|
280
|
+
const result = await settingsNow();
|
|
281
|
+
if (!result.ok)
|
|
282
|
+
return unavailable(c, result.missing);
|
|
283
|
+
const { cfg, authDeps } = runtimeFor(result.settings);
|
|
284
|
+
const outcome = await startAuthorize(c.req.query(), sessionTokenFrom(c), cfg, authDeps);
|
|
285
|
+
return respond(c, outcome, result.settings);
|
|
286
|
+
});
|
|
287
|
+
// The return from Clerk sign-in. The request is rebuilt only from the signed
|
|
288
|
+
// `authorize_state`; no other query parameter is read.
|
|
289
|
+
app.get("/authorize/callback", async (c) => {
|
|
290
|
+
const result = await settingsNow();
|
|
291
|
+
if (!result.ok)
|
|
292
|
+
return unavailable(c, result.missing);
|
|
293
|
+
const { cfg, authDeps } = runtimeFor(result.settings);
|
|
294
|
+
const outcome = await resumeAuthorize({ state: c.req.query("authorize_state") ?? "", sessionToken: sessionTokenFrom(c) }, cfg, authDeps);
|
|
295
|
+
return respond(c, outcome, result.settings);
|
|
296
|
+
});
|
|
297
|
+
// The picker's form post. `consentToken` is the CSRF protection: a cross-site form
|
|
298
|
+
// cannot know it, and resumeAuthorize refuses `approved` without it.
|
|
299
|
+
app.post("/authorize/org", async (c) => {
|
|
300
|
+
const result = await settingsNow();
|
|
301
|
+
if (!result.ok)
|
|
302
|
+
return unavailable(c, result.missing);
|
|
303
|
+
let form;
|
|
304
|
+
try {
|
|
305
|
+
form = await c.req.parseBody();
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return c.json({ error: "invalid_request", error_description: "unreadable form" }, 400);
|
|
309
|
+
}
|
|
310
|
+
const field = (name) => typeof form[name] === "string" ? form[name] : undefined;
|
|
311
|
+
if (field("approved") === "false") {
|
|
312
|
+
noStore(c);
|
|
313
|
+
const locale = detectLocaleFromAcceptLanguage(c.req.header("accept-language"));
|
|
314
|
+
return c.json({ error: "access_denied", error_description: t(locale, "oauth.denied") }, 403);
|
|
315
|
+
}
|
|
316
|
+
const { cfg, authDeps } = runtimeFor(result.settings);
|
|
317
|
+
const outcome = await resumeAuthorize({
|
|
318
|
+
state: field("state") ?? "",
|
|
319
|
+
sessionToken: sessionTokenFrom(c),
|
|
320
|
+
orgId: field("orgId"),
|
|
321
|
+
approved: field("approved") === "true",
|
|
322
|
+
consentToken: field("consentToken"),
|
|
323
|
+
}, cfg, authDeps);
|
|
324
|
+
return respond(c, outcome, result.settings);
|
|
325
|
+
});
|
|
326
|
+
app.post("/token", async (c) => {
|
|
327
|
+
const result = await settingsNow();
|
|
328
|
+
if (!result.ok)
|
|
329
|
+
return unavailable(c, result.missing);
|
|
330
|
+
const settings = result.settings;
|
|
331
|
+
noStore(c);
|
|
332
|
+
let body = {};
|
|
333
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
334
|
+
try {
|
|
335
|
+
if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
336
|
+
body = Object.fromEntries(new URLSearchParams(await c.req.text()));
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
body = (await c.req.json());
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return c.json({ error: "invalid_request", error_description: "unreadable body" }, 400);
|
|
344
|
+
}
|
|
345
|
+
if (body.grant_type !== "authorization_code") {
|
|
346
|
+
return c.json({ error: "unsupported_grant_type" }, 400);
|
|
347
|
+
}
|
|
348
|
+
const { code, code_verifier: codeVerifier, redirect_uri: redirectUri, client_id: clientId, } = body;
|
|
349
|
+
if (!code || !codeVerifier || !redirectUri || !clientId) {
|
|
350
|
+
return c.json({
|
|
351
|
+
error: "invalid_request",
|
|
352
|
+
error_description: "missing code, code_verifier, redirect_uri or client_id",
|
|
353
|
+
}, 400);
|
|
354
|
+
}
|
|
355
|
+
// Public clients only: a presented secret means the client expects authentication this
|
|
356
|
+
// server does not offer.
|
|
357
|
+
if (body.client_secret ||
|
|
358
|
+
c.req.header("authorization")?.toLowerCase().startsWith("basic ")) {
|
|
359
|
+
c.header("WWW-Authenticate", 'Basic realm="oauth"');
|
|
360
|
+
return c.json({ error: "invalid_client", error_description: "public clients only" }, 401);
|
|
361
|
+
}
|
|
362
|
+
const client = await lookupClient(clientId);
|
|
363
|
+
if (!client || client.revoked)
|
|
364
|
+
return c.json({ error: "invalid_client" }, 400);
|
|
365
|
+
const exchanged = await exchangeAuthorizationCode({
|
|
366
|
+
code,
|
|
367
|
+
codeVerifier,
|
|
368
|
+
redirectUri,
|
|
369
|
+
clientId,
|
|
370
|
+
...(body.resource ? { resource: body.resource } : {}),
|
|
371
|
+
}, { codeStore, now });
|
|
372
|
+
if (!exchanged.ok) {
|
|
373
|
+
const mapped = oauthErrorFor(exchanged.refusal);
|
|
374
|
+
return c.json({ error: mapped.error, error_description: exchanged.refusal.reason }, mapped.status);
|
|
375
|
+
}
|
|
376
|
+
const issuer = await issuerFor(settings);
|
|
377
|
+
const accessToken = await issuer.sign(exchanged.claims, settings.baseUrl, now());
|
|
378
|
+
return c.json({
|
|
379
|
+
access_token: accessToken,
|
|
380
|
+
token_type: "Bearer",
|
|
381
|
+
expires_in: ACCESS_TOKEN_TTL_SECONDS,
|
|
382
|
+
scope: exchanged.claims.scope,
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
app.get("/userinfo", async (c) => {
|
|
386
|
+
const result = await settingsNow();
|
|
387
|
+
if (!result.ok)
|
|
388
|
+
return unavailable(c, result.missing);
|
|
389
|
+
const header = c.req.header("authorization") ?? "";
|
|
390
|
+
const token = header.toLowerCase().startsWith("bearer ") ? header.slice(7).trim() : "";
|
|
391
|
+
const invalid = () => {
|
|
392
|
+
c.header("WWW-Authenticate", 'Bearer error="invalid_token"');
|
|
393
|
+
return c.json({ error: "invalid_token" }, 401);
|
|
394
|
+
};
|
|
395
|
+
if (!token)
|
|
396
|
+
return invalid();
|
|
397
|
+
const issuer = await issuerFor(result.settings);
|
|
398
|
+
let claims;
|
|
399
|
+
try {
|
|
400
|
+
claims = await issuer.verify(token, result.settings.baseUrl, result.settings.resource);
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return invalid();
|
|
404
|
+
}
|
|
405
|
+
let user;
|
|
406
|
+
try {
|
|
407
|
+
user = await clerkFor().getUser(claims.sub);
|
|
408
|
+
}
|
|
409
|
+
catch (err) {
|
|
410
|
+
console.error("[oauth] /userinfo Clerk user lookup failed:", err instanceof Error ? err.message : err);
|
|
411
|
+
return c.json({ error: "temporarily_unavailable" }, 503);
|
|
412
|
+
}
|
|
413
|
+
const info = buildUserInfo({ sub: claims.sub, scope: claims.scope }, user);
|
|
414
|
+
if (!info.ok) {
|
|
415
|
+
const mapped = oauthErrorFor(info.refusal);
|
|
416
|
+
return c.json({ error: mapped.error, error_description: info.refusal.reason }, mapped.status);
|
|
417
|
+
}
|
|
418
|
+
noStore(c);
|
|
419
|
+
return c.json(info.userinfo);
|
|
420
|
+
});
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clerk collaborators the authorization server takes as parameters: the Clerk JWKS (to verify
|
|
3
|
+
* the person's session token), the person's organisation memberships and the user record.
|
|
4
|
+
* The decisions on what they return live in @vantageos/cloud-identity.
|
|
5
|
+
*
|
|
6
|
+
* The defaults call Clerk's Backend REST API with `CLERK_SECRET_KEY`; a deployment (or a test)
|
|
7
|
+
* injects its own implementation through `AuthorizationServerDeps.clerk`.
|
|
8
|
+
*/
|
|
9
|
+
import type { ClerkJwks, ClerkOrgMembership, ClerkUserLike } from "@vantageos/cloud-identity";
|
|
10
|
+
export interface ClerkCollaborators {
|
|
11
|
+
jwks(issuer: string): Promise<ClerkJwks>;
|
|
12
|
+
listMemberships(userId: string): Promise<readonly ClerkOrgMembership[]>;
|
|
13
|
+
getUser(userId: string): Promise<ClerkUserLike>;
|
|
14
|
+
}
|
|
15
|
+
export declare function createClerkBackendCollaborators(secretKey: string, fetchImpl?: typeof fetch): ClerkCollaborators;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clerk collaborators the authorization server takes as parameters: the Clerk JWKS (to verify
|
|
3
|
+
* the person's session token), the person's organisation memberships and the user record.
|
|
4
|
+
* The decisions on what they return live in @vantageos/cloud-identity.
|
|
5
|
+
*
|
|
6
|
+
* The defaults call Clerk's Backend REST API with `CLERK_SECRET_KEY`; a deployment (or a test)
|
|
7
|
+
* injects its own implementation through `AuthorizationServerDeps.clerk`.
|
|
8
|
+
*/
|
|
9
|
+
const CLERK_API = "https://api.clerk.com/v1";
|
|
10
|
+
const JWKS_CACHE_MS = 10 * 60 * 1000;
|
|
11
|
+
const MEMBERSHIP_PAGE_LIMIT = 100;
|
|
12
|
+
export function createClerkBackendCollaborators(secretKey, fetchImpl = fetch) {
|
|
13
|
+
let cachedJwks = null;
|
|
14
|
+
const api = async (path) => {
|
|
15
|
+
const res = await fetchImpl(`${CLERK_API}${path}`, {
|
|
16
|
+
headers: { Authorization: `Bearer ${secretKey}`, Accept: "application/json" },
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok)
|
|
19
|
+
throw new Error(`Clerk Backend API answered ${res.status}`);
|
|
20
|
+
return (await res.json());
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
async jwks(issuer) {
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
if (cachedJwks && cachedJwks.issuer === issuer && now - cachedJwks.at < JWKS_CACHE_MS) {
|
|
26
|
+
return cachedJwks.keys;
|
|
27
|
+
}
|
|
28
|
+
const res = await fetchImpl(`${issuer}/.well-known/jwks.json`);
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new Error(`Clerk JWKS answered ${res.status}`);
|
|
31
|
+
const keys = (await res.json());
|
|
32
|
+
cachedJwks = { at: now, issuer, keys };
|
|
33
|
+
return keys;
|
|
34
|
+
},
|
|
35
|
+
async listMemberships(userId) {
|
|
36
|
+
const page = await api(`/users/${encodeURIComponent(userId)}/organization_memberships?limit=${MEMBERSHIP_PAGE_LIMIT}`);
|
|
37
|
+
return page.data.map((m) => ({
|
|
38
|
+
role: m.role,
|
|
39
|
+
organization: {
|
|
40
|
+
id: m.organization.id,
|
|
41
|
+
slug: m.organization.slug ?? null,
|
|
42
|
+
name: m.organization.name,
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
async getUser(userId) {
|
|
47
|
+
const user = await api(`/users/${encodeURIComponent(userId)}`);
|
|
48
|
+
return {
|
|
49
|
+
id: user.id,
|
|
50
|
+
primaryEmailAddressId: user.primary_email_address_id ?? null,
|
|
51
|
+
emailAddresses: (user.email_addresses ?? []).map((e) => ({
|
|
52
|
+
id: e.id,
|
|
53
|
+
emailAddress: e.email_address,
|
|
54
|
+
verification: e.verification ?? null,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth client registry: how `/authorize` and `/token` learn who a `client_id` is.
|
|
3
|
+
*
|
|
4
|
+
* Two sources, tried by the SHAPE of the client_id:
|
|
5
|
+
* - a URL-shaped client_id is a Client ID Metadata Document (CIMD): fetched over https,
|
|
6
|
+
* SSRF-guarded (public addresses only, no redirects, size and time capped), and accepted
|
|
7
|
+
* only when the document names exactly that URL;
|
|
8
|
+
* - anything else is a Dynamic Client Registration (DCR) id looked up in the registry.
|
|
9
|
+
*
|
|
10
|
+
* Only PUBLIC clients (PKCE, no client secret) exist here: MCP clients are public clients,
|
|
11
|
+
* so the server issues and stores no client secret at all.
|
|
12
|
+
*/
|
|
13
|
+
import type { AuthorizeClient } from "@vantageos/cloud-identity";
|
|
14
|
+
export interface RegisteredClient {
|
|
15
|
+
clientId: string;
|
|
16
|
+
clientName?: string;
|
|
17
|
+
redirectUris: string[];
|
|
18
|
+
/** Epoch seconds. */
|
|
19
|
+
issuedAt: number;
|
|
20
|
+
revoked?: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Durable-adapter contract: docs/oauth-stores.md. */
|
|
23
|
+
export interface ClientRegistry {
|
|
24
|
+
get(clientId: string): Promise<RegisteredClient | null>;
|
|
25
|
+
register(client: RegisteredClient): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare function createInMemoryClientRegistry(maxClients?: number): ClientRegistry;
|
|
28
|
+
/** True for an https URL, or an http URL on a loopback host (native-app redirects). */
|
|
29
|
+
export declare function isAllowedRedirectUri(raw: unknown): raw is string;
|
|
30
|
+
/** True only for a globally routable address. Fail-closed: anything unrecognised is refused. */
|
|
31
|
+
export declare function isPublicAddress(ip: string): boolean;
|
|
32
|
+
export declare function defaultResolveHost(host: string): Promise<string[]>;
|
|
33
|
+
export interface CimdOptions {
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
resolveHost?: (host: string) => Promise<string[]>;
|
|
36
|
+
maxBytes?: number;
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
/** True when a client_id is URL-shaped, i.e. a CIMD identifier (valid or not). */
|
|
40
|
+
export declare function isUrlShapedClientId(clientId: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Fetches and validates a Client ID Metadata Document. Returns null for ANY failure — an
|
|
43
|
+
* unreachable, oversized, malformed, private-addressed or self-contradicting document is an
|
|
44
|
+
* unknown client, never a partial one.
|
|
45
|
+
*
|
|
46
|
+
* Limitation (documented in docs/oauth-stores.md): the address check resolves the host and
|
|
47
|
+
* the fetch resolves it again, so a DNS-rebinding window exists. A hostile-network deployment
|
|
48
|
+
* injects a `fetchImpl` that pins the validated address (for example an undici dispatcher).
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolveCimdClient(clientId: string, options?: CimdOptions): Promise<AuthorizeClient | null>;
|