Skip to content

Commit e85ddb3

Browse files
committed
Detect auth — the bet
Aggregate how to authenticate across transports into an 'auth' field: - REST: parse the OpenAPI securitySchemes (apiKey/http/oauth2/openIdConnect) from the spec we already fetch. - oauth-protected-resource (RFC 9728): authorization servers + scopes. - MCP: oauth2 + authorization server (already in the onboarding chain). Adds an 'auth' signal to found[].
1 parent 0fe265d commit e85ddb3

2 files changed

Lines changed: 64 additions & 9 deletions

File tree

src/lib/detect.ts

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export interface DetectionResult {
3838
mcp: string[];
3939
};
4040
apiSchema?: { url: string; format: "openapi"; version?: string };
41+
/** How to authenticate — the bet. Aggregated across REST, MCP, and PRM. */
42+
auth?: {
43+
rest?: ReadonlyArray<{ name: string; type: string; scheme?: string; in?: string; bearerFormat?: string; openIdConnectUrl?: string; flows?: unknown }>;
44+
mcp?: ReadonlyArray<{ url: string; type?: string; authorizationServer?: string }>;
45+
oauthProtectedResource?: { authorizationServers: string[]; scopes?: string[] };
46+
};
4147
mcp: McpDetection[];
4248
agentCard?: { name?: string; url?: string };
4349
agentSkills?: { count: number; names: string[] };
@@ -130,24 +136,52 @@ async function checkLlmsTxt(fetchImpl: FetchLike, domain: string): Promise<boole
130136
return hit.text.length > 0 && !/<!doctype|<html/i.test(hit.text.slice(0, 200));
131137
}
132138

133-
/** Probe conventional live-spec paths; trust the OpenAPI content-type / key. */
139+
/** Summarize the OpenAPI `securitySchemes` — how to authenticate to the API. */
140+
function extractRestAuth(doc: any) {
141+
const schemes = doc?.components?.securitySchemes ?? doc?.securityDefinitions; // OAS3 / Swagger2
142+
if (!schemes || typeof schemes !== "object") return undefined;
143+
const out = Object.entries<any>(schemes).map(([name, s]) => ({
144+
name,
145+
type: s?.type,
146+
scheme: s?.scheme,
147+
in: s?.in,
148+
bearerFormat: s?.bearerFormat,
149+
openIdConnectUrl: s?.openIdConnectUrl,
150+
flows: s?.flows,
151+
}));
152+
return out.length ? out : undefined;
153+
}
154+
155+
/** Probe conventional live-spec paths; parse the OpenAPI to also surface auth. */
134156
async function checkApiSchema(fetchImpl: FetchLike, domain: string) {
135157
const paths = ["/api/schema/", "/openapi.json", "/swagger.json", "/api/openapi.json", "/v1/openapi.json"];
136158
for (const p of paths) {
137159
const hit = await get(fetchImpl, `https://${domain}${p}`);
138160
if (!hit || !hit.res.ok) continue;
139161
const ct = hit.res.headers.get("content-type") ?? "";
140-
if (/openapi/i.test(ct)) {
141-
return { url: `https://${domain}${p}`, format: "openapi" as const };
142-
}
143162
const doc = asJson(hit.text, ct);
144-
if (doc && (doc.openapi || doc.swagger)) {
145-
return { url: `https://${domain}${p}`, format: "openapi" as const, version: doc.openapi ?? doc.swagger };
146-
}
163+
const isOpenapi = /openapi/i.test(ct) || Boolean(doc && (doc.openapi || doc.swagger));
164+
if (!isOpenapi) continue;
165+
return {
166+
url: `https://${domain}${p}`,
167+
format: "openapi" as const,
168+
version: doc?.openapi ?? doc?.swagger,
169+
rest: extractRestAuth(doc),
170+
};
147171
}
148172
return undefined;
149173
}
150174

175+
/** OAuth 2.0 Protected Resource Metadata (RFC 9728) — the API's auth servers. */
176+
async function checkOauthProtectedResource(fetchImpl: FetchLike, domain: string) {
177+
const hit = await get(fetchImpl, `https://${domain}/.well-known/oauth-protected-resource`);
178+
if (!hit || !hit.res.ok) return undefined;
179+
const doc = asJson(hit.text, hit.res.headers.get("content-type"));
180+
const servers = doc?.authorization_servers;
181+
if (!Array.isArray(servers) || servers.length === 0) return undefined;
182+
return { authorizationServers: servers as string[], scopes: doc?.scopes_supported as string[] | undefined };
183+
}
184+
151185
/**
152186
* MCP self-onboarding: initialize → WWW-Authenticate → PRM → AS metadata →
153187
* registration_endpoint (DCR) + client_id_metadata_document_supported (CIMD).
@@ -181,15 +215,22 @@ async function detectMcpOnboarding(fetchImpl: FetchLike, mcpUrl: string): Promis
181215

182216
export async function detect(domain: string, fetchImpl: FetchLike = fetch): Promise<DetectionResult> {
183217
const errors: string[] = [];
184-
const [apiCatalog, serverCard, agentCard, agentSkills, llmsTxt, apiSchema] = await Promise.all([
218+
const [apiCatalog, serverCard, agentCard, agentSkills, llmsTxt, apiSchemaRaw, oauthPR] = await Promise.all([
185219
checkApiCatalog(fetchImpl, domain).catch((e) => (errors.push(`api-catalog: ${e}`), undefined)),
186220
checkServerCard(fetchImpl, domain).catch((e) => (errors.push(`server-card: ${e}`), undefined)),
187221
checkAgentCard(fetchImpl, domain).catch((e) => (errors.push(`agent-card: ${e}`), undefined)),
188222
checkAgentSkills(fetchImpl, domain).catch((e) => (errors.push(`agent-skills: ${e}`), undefined)),
189223
checkLlmsTxt(fetchImpl, domain).catch(() => false),
190224
checkApiSchema(fetchImpl, domain).catch(() => undefined),
225+
checkOauthProtectedResource(fetchImpl, domain).catch(() => undefined),
191226
]);
192227

228+
// Keep apiSchema as the spec locator; lift its auth into the aggregate below.
229+
const restAuth = apiSchemaRaw?.rest;
230+
const apiSchema = apiSchemaRaw
231+
? { url: apiSchemaRaw.url, format: apiSchemaRaw.format, version: apiSchemaRaw.version }
232+
: undefined;
233+
193234
// Collect MCP endpoints from the server card + api-catalog, then probe each
194235
// for self-onboarding capability.
195236
const mcpSeen = new Map<string, McpDetection>();
@@ -199,6 +240,18 @@ export async function detect(domain: string, fetchImpl: FetchLike = fetch): Prom
199240
[...mcpSeen.values()].map(async (m) => ({ ...m, ...(await detectMcpOnboarding(fetchImpl, m.url).catch(() => ({}))) })),
200241
);
201242

243+
// Aggregate auth — the bet — across REST (OpenAPI securitySchemes), MCP
244+
// (oauth2 + authorization server), and the PRM well-known.
245+
const mcpAuth = mcp
246+
.filter((m) => m.auth && m.auth !== "none")
247+
.map((m) => ({ url: m.url, type: m.auth, authorizationServer: m.authorizationServer }));
248+
const auth = {
249+
...(restAuth ? { rest: restAuth } : {}),
250+
...(mcpAuth.length ? { mcp: mcpAuth } : {}),
251+
...(oauthPR ? { oauthProtectedResource: oauthPR } : {}),
252+
};
253+
const hasAuth = Object.keys(auth).length > 0;
254+
202255
const found: string[] = [];
203256
if (apiCatalog) found.push("api-catalog");
204257
if (apiSchema) found.push("openapi-schema");
@@ -207,6 +260,7 @@ export async function detect(domain: string, fetchImpl: FetchLike = fetch): Prom
207260
if (agentCard) found.push("agent-card");
208261
if (agentSkills) found.push("agent-skills");
209262
if (llmsTxt) found.push("llms.txt");
263+
if (hasAuth) found.push("auth");
210264

211-
return { domain, found, apiCatalog, apiSchema, mcp, agentCard, agentSkills, llmsTxt, errors };
265+
return { domain, found, apiCatalog, apiSchema, auth: hasAuth ? auth : undefined, mcp, agentCard, agentSkills, llmsTxt, errors };
212266
}

worker/operations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const DetectionResult = Schema.Struct({
1717
found: Schema.Array(Schema.String),
1818
apiCatalog: Schema.optional(Schema.Unknown),
1919
apiSchema: Schema.optional(Schema.Unknown),
20+
auth: Schema.optional(Schema.Unknown),
2021
mcp: Schema.Array(Schema.Unknown),
2122
agentCard: Schema.optional(Schema.Unknown),
2223
agentSkills: Schema.optional(Schema.Unknown),

0 commit comments

Comments
 (0)