History

Extract OAuth2 flows from OpenAPI security schemes (#250)

Exposes the `flows` object on `components.securitySchemes` through the preview pipeline so the onboarding UI can offer an OAuth2 path for any spec that declares one. Previously we detected `type: oauth2` but silently dropped the flow metadata, leaving users to paste a bearer token manually. Schema additions: - `OAuth2AuthorizationCodeFlow`, `OAuth2ClientCredentialsFlow`, and `OAuth2Flows` classes capturing `authorizationUrl`, `tokenUrl`, `refreshUrl`, and `scopes` per flow. `implicit` and `password` are intentionally excluded — both are deprecated by OAuth 2.1 and adding them would only encourage users to onboard onto them. - `SecurityScheme` now carries `flows`, `bearerFormat`, and `openIdConnectUrl` (previously dropped). - `OAuth2Preset` class — one entry per (scheme × flow) pair with the label, URLs, and scope map the onboarding UI needs to render a "Connect via OAuth2" flow with a scope checklist. - `SpecPreview.oauth2Presets` carries the derived presets. Extraction: - `extractSecuritySchemes` now takes a `DocResolver` and resolves `$ref` entries instead of silently dropping them. Specs that alias their security schemes through `components.securitySchemes` refs (rare but real) now surface correctly. - `extractFlows` tolerates malformed flows (missing `tokenUrl` / `authorizationUrl`) by dropping them, so a broken flow does not prevent the rest of the spec from previewing. 5 new tests cover authorizationCode + clientCredentials extraction, the `$ref` resolution path, `bearerFormat` / `openIdConnectUrl` capture, and the malformed-flow ignore case.

Rhys Sullivan committed 6 months ago

Commit bb6dfa51 parent 1a15f723 files · +384 −6Browse files

Unified · Split

3 files
  1. packages/plugins/openapi/src/sdk/index.ts +4 −0
  2. packages/plugins/openapi/src/sdk/preview-oauth2.test.ts +192 −0
  3. packages/plugins/openapi/src/sdk/preview.ts +188 −6
packages/plugins/openapi/src/sdk/index.ts+4−0
@@ -15,6 +15,10 @@ export { · 14 unmodified lines
1515 SecurityScheme,
1616 AuthStrategy,
1717 HeaderPreset,
18+ OAuth2Preset,
19+ OAuth2Flows,
20+ OAuth2AuthorizationCodeFlow,
21+ OAuth2ClientCredentialsFlow,
1822 PreviewOperation,
1923 SpecPreview,
2024 } from "./preview";
packages/plugins/openapi/src/sdk/preview-oauth2.test.ts+192−0
@@ -0,0 +1,192 @@
1+// ---------------------------------------------------------------------------
2+// Tests for OAuth2 flow extraction in previewSpec. Covers:
3+// - authorizationCode flow extraction (URLs + scopes)
4+// - clientCredentials flow extraction
5+// - security schemes defined via $ref are no longer silently dropped
6+// - bearerFormat / openIdConnectUrl are captured
7+// - invalid flows (missing tokenUrl) are ignored
8+// ---------------------------------------------------------------------------
9+
10+import { describe, expect, it } from "@effect/vitest";
11+import { Effect, Option } from "effect";
12+
13+import { previewSpec } from "./preview";
14+
15+const minimalSpec = (
16+ securitySchemes: Record<string, unknown>,
17+ components: Record<string, unknown> = {},
18+) => ({
19+ openapi: "3.0.0",
20+ info: { title: "Test API", version: "1.0.0" },
21+ servers: [{ url: "https://api.example.com" }],
22+ paths: {
23+ "/ping": {
24+ get: { responses: { "200": { description: "ok" } } },
25+ },
26+ },
27+ components: { ...components, securitySchemes },
28+});
29+
30+describe("previewSpec OAuth2 extraction", () => {
31+ it.effect("extracts authorizationCode flow with URLs, scopes, refreshUrl", () =>
32+ Effect.gen(function* () {
33+ const spec = minimalSpec({
34+ oauth_app: {
35+ type: "oauth2",
36+ flows: {
37+ authorizationCode: {
38+ authorizationUrl: "https://example.com/oauth/authorize",
39+ tokenUrl: "https://example.com/oauth/token",
40+ refreshUrl: "https://example.com/oauth/refresh",
41+ scopes: {
42+ read: "Read access",
43+ write: "Write access",
44+ },
45+ },
46+ },
47+ },
48+ });
49+ const preview = yield* previewSpec(JSON.stringify(spec));
50+
51+ expect(preview.securitySchemes).toHaveLength(1);
52+ const scheme = preview.securitySchemes[0]!;
53+ expect(scheme.name).toBe("oauth_app");
54+ expect(scheme.type).toBe("oauth2");
55+ expect(Option.isSome(scheme.flows)).toBe(true);
56+ const flows = Option.getOrThrow(scheme.flows);
57+ expect(Option.isSome(flows.authorizationCode)).toBe(true);
58+ const flow = Option.getOrThrow(flows.authorizationCode);
59+ expect(flow.authorizationUrl).toBe("https://example.com/oauth/authorize");
60+ expect(flow.tokenUrl).toBe("https://example.com/oauth/token");
61+ expect(Option.getOrElse(flow.refreshUrl, () => "")).toBe(
62+ "https://example.com/oauth/refresh",
63+ );
64+ expect(flow.scopes).toEqual({ read: "Read access", write: "Write access" });
65+
66+ // A preset should be generated for this flow.
67+ expect(preview.oauth2Presets).toHaveLength(1);
68+ const preset = preview.oauth2Presets[0]!;
69+ expect(preset.flow).toBe("authorizationCode");
70+ expect(preset.securitySchemeName).toBe("oauth_app");
71+ expect(preset.tokenUrl).toBe("https://example.com/oauth/token");
72+ expect(preset.label).toContain("Authorization Code");
73+ expect(preset.label).toContain("oauth_app");
74+ expect(preset.scopes).toEqual({ read: "Read access", write: "Write access" });
75+ }),
76+ );
77+
78+ it.effect("extracts clientCredentials flow alongside authorizationCode", () =>
79+ Effect.gen(function* () {
80+ const spec = minimalSpec({
81+ oauth_app: {
82+ type: "oauth2",
83+ flows: {
84+ authorizationCode: {
85+ authorizationUrl: "https://example.com/auth",
86+ tokenUrl: "https://example.com/token",
87+ scopes: {},
88+ },
89+ clientCredentials: {
90+ tokenUrl: "https://example.com/token",
91+ scopes: { "admin:read": "Admin read" },
92+ },
93+ },
94+ },
95+ });
96+ const preview = yield* previewSpec(JSON.stringify(spec));
97+
98+ expect(preview.oauth2Presets).toHaveLength(2);
99+ const flowKinds = preview.oauth2Presets.map((p) => p.flow).sort();
100+ expect(flowKinds).toEqual(["authorizationCode", "clientCredentials"]);
101+
102+ const cc = preview.oauth2Presets.find((p) => p.flow === "clientCredentials")!;
103+ expect(Option.isNone(cc.authorizationUrl)).toBe(true);
104+ expect(cc.scopes).toEqual({ "admin:read": "Admin read" });
105+ }),
106+ );
107+
108+ it.effect("skips authorizationCode flow missing tokenUrl or authorizationUrl", () =>
109+ Effect.gen(function* () {
110+ const spec = minimalSpec({
111+ broken: {
112+ type: "oauth2",
113+ flows: {
114+ authorizationCode: {
115+ authorizationUrl: "https://example.com/auth",
116+ // tokenUrl missing
117+ scopes: {},
118+ },
119+ },
120+ },
121+ });
122+ const preview = yield* previewSpec(JSON.stringify(spec));
123+
124+ expect(preview.securitySchemes).toHaveLength(1);
125+ // Scheme is still captured but with no flows.
126+ expect(Option.isNone(preview.securitySchemes[0]!.flows)).toBe(true);
127+ expect(preview.oauth2Presets).toHaveLength(0);
128+ }),
129+ );
130+
131+ it.effect("resolves security schemes defined via $ref", () =>
132+ Effect.gen(function* () {
133+ const spec = minimalSpec(
134+ {
135+ api_token: { $ref: "#/components/securitySchemes/_api_token_impl" },
136+ },
137+ {
138+ securitySchemes: {
139+ _api_token_impl: {
140+ type: "http",
141+ scheme: "bearer",
142+ bearerFormat: "JWT",
143+ description: "Internal token scheme",
144+ },
145+ },
146+ },
147+ );
148+ // Note: the outer securitySchemes at `components.securitySchemes` is
149+ // what previewSpec reads; the `_api_token_impl` shim inside
150+ // components.securitySchemes allows $ref resolution via the resolver.
151+ // The test spec above is slightly awkward because we have to nest both
152+ // under the same key — adjust by merging.
153+ spec.components = {
154+ securitySchemes: {
155+ api_token: { $ref: "#/components/securitySchemes/_api_token_impl" },
156+ _api_token_impl: {
157+ type: "http",
158+ scheme: "bearer",
159+ bearerFormat: "JWT",
160+ description: "Internal token scheme",
161+ },
162+ },
163+ };
164+
165+ const preview = yield* previewSpec(JSON.stringify(spec));
166+ // Both keys are present, but the `api_token` entry should resolve to
167+ // the http bearer scheme (previously it was silently dropped).
168+ const apiToken = preview.securitySchemes.find((s) => s.name === "api_token");
169+ expect(apiToken).toBeDefined();
170+ expect(apiToken!.type).toBe("http");
171+ expect(Option.getOrElse(apiToken!.scheme, () => "")).toBe("bearer");
172+ expect(Option.getOrElse(apiToken!.bearerFormat, () => "")).toBe("JWT");
173+ }),
174+ );
175+
176+ it.effect("captures openIdConnectUrl for openIdConnect schemes", () =>
177+ Effect.gen(function* () {
178+ const spec = minimalSpec({
179+ oidc: {
180+ type: "openIdConnect",
181+ openIdConnectUrl: "https://example.com/.well-known/openid-configuration",
182+ },
183+ });
184+ const preview = yield* previewSpec(JSON.stringify(spec));
185+ const scheme = preview.securitySchemes[0]!;
186+ expect(scheme.type).toBe("openIdConnect");
187+ expect(Option.getOrElse(scheme.openIdConnectUrl, () => "")).toBe(
188+ "https://example.com/.well-known/openid-configuration",
189+ );
190+ }),
191+ );
192+});
packages/plugins/openapi/src/sdk/preview.ts+188−6
@@ -1,10 +1,41 @@
11 import { Effect, Option } from "effect";
22 import { Schema } from "effect";
33
4−import { parse } from "./parse";
4+import { parse, type ParsedDocument } from "./parse";
55 import { extract } from "./extract";
6+import { DocResolver } from "./openapi-utils";
67 import { HttpMethod, ServerInfo, type ExtractionResult } from "./types";
78
9+
10+// ---------------------------------------------------------------------------
11+// OAuth 2.0 flows — one entry per supported grant type
12+// ---------------------------------------------------------------------------
13+
14+/** Scopes declared by a flow: `{ scopeName: description }` */
15+const OAuth2Scopes = Schema.Record({ key: Schema.String, value: Schema.String });
16+
17+export class OAuth2AuthorizationCodeFlow extends Schema.Class<OAuth2AuthorizationCodeFlow>(
18+ "OAuth2AuthorizationCodeFlow",
19+)({
20+ authorizationUrl: Schema.String,
21+ tokenUrl: Schema.String,
22+ refreshUrl: Schema.optionalWith(Schema.String, { as: "Option" }),
23+ scopes: OAuth2Scopes,
24+}) {}
25+
26+export class OAuth2ClientCredentialsFlow extends Schema.Class<OAuth2ClientCredentialsFlow>(
27+ "OAuth2ClientCredentialsFlow",
28+)({
29+ tokenUrl: Schema.String,
30+ refreshUrl: Schema.optionalWith(Schema.String, { as: "Option" }),
31+ scopes: OAuth2Scopes,
32+}) {}
33+
34+export class OAuth2Flows extends Schema.Class<OAuth2Flows>("OAuth2Flows")({
35+ authorizationCode: Schema.optionalWith(OAuth2AuthorizationCodeFlow, { as: "Option" }),
36+ clientCredentials: Schema.optionalWith(OAuth2ClientCredentialsFlow, { as: "Option" }),
37+}) {}
38+
839 // ---------------------------------------------------------------------------
940 // Security scheme — what the spec declares it needs
1041 // ---------------------------------------------------------------------------
@@ -16,11 +47,17 @@ export class SecurityScheme extends Schema.Class<SecurityScheme>("SecurityScheme · 5 unmodified lines
1647 type: Schema.Literal("http", "apiKey", "oauth2", "openIdConnect"),
1748 /** For type: "http" — e.g. "bearer", "basic" */
1849 scheme: Schema.optionalWith(Schema.String, { as: "Option" }),
50+ /** For type: "http" with scheme "bearer" — e.g. "JWT" */
51+ bearerFormat: Schema.optionalWith(Schema.String, { as: "Option" }),
1952 /** For type: "apiKey" — where the key goes */
2053 in: Schema.optionalWith(Schema.Literal("header", "query", "cookie"), { as: "Option" }),
2154 /** For type: "apiKey" — the header/query/cookie name */
2255 headerName: Schema.optionalWith(Schema.String, { as: "Option" }),
2356 description: Schema.optionalWith(Schema.String, { as: "Option" }),
57+ /** For type: "oauth2" — declared flows (authorizationCode / clientCredentials only; implicit and password are deprecated). */
58+ flows: Schema.optionalWith(OAuth2Flows, { as: "Option" }),
59+ /** For type: "openIdConnect" — the discovery URL. */
60+ openIdConnectUrl: Schema.optionalWith(Schema.String, { as: "Option" }),
2461 }) {}
2562
2663 // ---------------------------------------------------------------------------
@@ -45,6 +82,27 @@ export class HeaderPreset extends Schema.Class<HeaderPreset>("HeaderPreset")({ · 18 unmodified lines
4582 secretHeaders: Schema.Array(Schema.String),
4683 }) {}
4784
85+// ---------------------------------------------------------------------------
86+// OAuth2 preset — derived from an oauth2 security scheme + a flow choice
87+// ---------------------------------------------------------------------------
88+
89+export class OAuth2Preset extends Schema.Class<OAuth2Preset>("OAuth2Preset")({
90+ /** Human-readable label for the UI (e.g. "OAuth2 (Authorization Code) — oauth_app") */
91+ label: Schema.String,
92+ /** The source security scheme this preset came from (components.securitySchemes key). */
93+ securitySchemeName: Schema.String,
94+ /** Which OAuth2 flow this preset uses. */
95+ flow: Schema.Literal("authorizationCode", "clientCredentials"),
96+ /** For authorizationCode: user-agent redirect URL (from the spec). */
97+ authorizationUrl: Schema.optionalWith(Schema.String, { as: "Option" }),
98+ /** Token endpoint to exchange the code / refresh. */
99+ tokenUrl: Schema.String,
100+ /** Optional refresh endpoint if the spec declares one separately. */
101+ refreshUrl: Schema.optionalWith(Schema.String, { as: "Option" }),
102+ /** Declared scopes for this flow: `{ scope: description }`. */
103+ scopes: Schema.Record({ key: Schema.String, value: Schema.String }),
104+}) {}
105+
48106 // ---------------------------------------------------------------------------
49107 // Preview operation — lightweight shape for the add-source UI list
50108 // ---------------------------------------------------------------------------
@@ -76,17 +134,87 @@ export class SpecPreview extends Schema.Class<SpecPreview>("SpecPreview")({ · 25 unmodified lines
76134 authStrategies: Schema.Array(AuthStrategy),
77135 /** Pre-built header presets derived from auth strategies */
78136 headerPresets: Schema.Array(HeaderPreset),
137+ /** OAuth2 presets — one per (oauth2 scheme × supported flow) combination */
138+ oauth2Presets: Schema.Array(OAuth2Preset),
79139 }) {}
80140
81141 // ---------------------------------------------------------------------------
82142 // Security scheme extraction
83143 // ---------------------------------------------------------------------------
84144
85−const extractSecuritySchemes = (rawSchemes: Record<string, unknown>): SecurityScheme[] =>
145+const stringRecord = (value: unknown): Record<string, string> => {
146+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
147+ const out: Record<string, string> = {};
148+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
149+ if (typeof v === "string") out[k] = v;
150+ }
151+ return out;
152+};
153+
154+const extractFlows = (rawFlows: unknown): Option.Option<OAuth2Flows> => {
155+ if (!rawFlows || typeof rawFlows !== "object") return Option.none();
156+ const flows = rawFlows as Record<string, unknown>;
157+
158+ const parseFlow = <K extends "authorizationCode" | "clientCredentials">(
159+ key: K,
160+ ): unknown => flows[key];
161+
162+ let authorizationCode: Option.Option<OAuth2AuthorizationCodeFlow> = Option.none();
163+ const authCodeRaw = parseFlow("authorizationCode");
164+ if (authCodeRaw && typeof authCodeRaw === "object") {
165+ const f = authCodeRaw as Record<string, unknown>;
166+ const authUrl = typeof f.authorizationUrl === "string" ? f.authorizationUrl : null;
167+ const tokenUrl = typeof f.tokenUrl === "string" ? f.tokenUrl : null;
168+ if (authUrl && tokenUrl) {
169+ authorizationCode = Option.some(
170+ new OAuth2AuthorizationCodeFlow({
171+ authorizationUrl: authUrl,
172+ tokenUrl,
173+ refreshUrl: Option.fromNullable(
174+ typeof f.refreshUrl === "string" ? f.refreshUrl : undefined,
175+ ),
176+ scopes: stringRecord(f.scopes),
177+ }),
178+ );
179+ }
180+ }
181+
182+ let clientCredentials: Option.Option<OAuth2ClientCredentialsFlow> = Option.none();
183+ const ccRaw = parseFlow("clientCredentials");
184+ if (ccRaw && typeof ccRaw === "object") {
185+ const f = ccRaw as Record<string, unknown>;
186+ const tokenUrl = typeof f.tokenUrl === "string" ? f.tokenUrl : null;
187+ if (tokenUrl) {
188+ clientCredentials = Option.some(
189+ new OAuth2ClientCredentialsFlow({
190+ tokenUrl,
191+ refreshUrl: Option.fromNullable(
192+ typeof f.refreshUrl === "string" ? f.refreshUrl : undefined,
193+ ),
194+ scopes: stringRecord(f.scopes),
195+ }),
196+ );
197+ }
198+ }
199+
200+ if (Option.isNone(authorizationCode) && Option.isNone(clientCredentials)) {
201+ return Option.none();
202+ }
203+ return Option.some(new OAuth2Flows({ authorizationCode, clientCredentials }));
204+};
205+
206+const extractSecuritySchemes = (
207+ rawSchemes: Record<string, unknown>,
208+ resolver: DocResolver,
209+): SecurityScheme[] =>
86210 Object.entries(rawSchemes).flatMap(([name, schemeOrRef]) => {
87211 if (!schemeOrRef || typeof schemeOrRef !== "object") return [];
88− const scheme = schemeOrRef as Record<string, unknown>;
89− if ("$ref" in scheme) return [];
212+ // Resolve $ref so schemes defined via `$ref` aren't silently dropped.
213+ const resolved = resolver.resolve<Record<string, unknown>>(
214+ schemeOrRef as Record<string, unknown>,
215+ );
216+ if (!resolved || typeof resolved !== "object") return [];
217+ const scheme = resolved;
90218
91219 const type = scheme.type as string;
92220 if (!["http", "apiKey", "oauth2", "openIdConnect"].includes(type)) return [];
@@ -96,9 +224,14 @@ const extractSecuritySchemes = (rawSchemes: Record<string, unknown>): SecuritySc · 3 unmodified lines
96224 name,
97225 type: type as "http" | "apiKey" | "oauth2" | "openIdConnect",
98226 scheme: Option.fromNullable(scheme.scheme as string | undefined),
227+ bearerFormat: Option.fromNullable(scheme.bearerFormat as string | undefined),
99228 in: Option.fromNullable(scheme.in as "header" | "query" | "cookie" | undefined),
100229 headerName: Option.fromNullable(scheme.name as string | undefined),
101230 description: Option.fromNullable(scheme.description as string | undefined),
231+ flows: type === "oauth2" ? extractFlows(scheme.flows) : Option.none(),
232+ openIdConnectUrl: Option.fromNullable(
233+ scheme.openIdConnectUrl as string | undefined,
234+ ),
102235 }),
103236 ];
104237 });
@@ -153,6 +286,50 @@ const buildHeaderPresets = ( · 48 unmodified lines
153286 });
154287 };
155288
289+// ---------------------------------------------------------------------------
290+// OAuth2 preset builder
291+// ---------------------------------------------------------------------------
292+
293+const buildOAuth2Presets = (schemes: readonly SecurityScheme[]): OAuth2Preset[] => {
294+ const presets: OAuth2Preset[] = [];
295+ for (const scheme of schemes) {
296+ if (scheme.type !== "oauth2") continue;
297+ if (Option.isNone(scheme.flows)) continue;
298+ const flows = scheme.flows.value;
299+
300+ if (Option.isSome(flows.authorizationCode)) {
301+ const flow = flows.authorizationCode.value;
302+ presets.push(
303+ new OAuth2Preset({
304+ label: `OAuth2 Authorization Code · ${scheme.name}`,
305+ securitySchemeName: scheme.name,
306+ flow: "authorizationCode",
307+ authorizationUrl: Option.some(flow.authorizationUrl),
308+ tokenUrl: flow.tokenUrl,
309+ refreshUrl: flow.refreshUrl,
310+ scopes: flow.scopes,
311+ }),
312+ );
313+ }
314+
315+ if (Option.isSome(flows.clientCredentials)) {
316+ const flow = flows.clientCredentials.value;
317+ presets.push(
318+ new OAuth2Preset({
319+ label: `OAuth2 Client Credentials · ${scheme.name}`,
320+ securitySchemeName: scheme.name,
321+ flow: "clientCredentials",
322+ authorizationUrl: Option.none(),
323+ tokenUrl: flow.tokenUrl,
324+ refreshUrl: flow.refreshUrl,
325+ scopes: flow.scopes,
326+ }),
327+ );
328+ }
329+ }
330+ return presets;
331+};
332+
156333 // ---------------------------------------------------------------------------
157334 // Collect unique tags from extraction result
158335 // ---------------------------------------------------------------------------
@@ -172,10 +349,14 @@ const collectTags = (result: ExtractionResult): string[] => { · 13 unmodified lines
172349 /** Preview an OpenAPI spec — extract metadata without registering anything.
173350 * Reuses parse() + extract() for the heavy lifting. */
174351 export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: string) {
175− const doc = yield* parse(specText);
352+ const doc: ParsedDocument = yield* parse(specText);
176353 const result = yield* extract(doc);
177354
178− const securitySchemes = extractSecuritySchemes(doc.components?.securitySchemes ?? {});
355+ const resolver = new DocResolver(doc);
356+ const securitySchemes = extractSecuritySchemes(
357+ doc.components?.securitySchemes ?? {},
358+ resolver,
359+ );
179360
180361 const rawSecurity = (doc.security ?? []) as Array<Record<string, unknown>>;
181362 const authStrategies = rawSecurity.map(
@@ -202,5 +383,6 @@ export const previewSpec = Effect.fn("OpenApi.previewSpec")(function* (specText: · 20 unmodified lines
202383 securitySchemes,
203384 authStrategies,
204385 headerPresets: buildHeaderPresets(securitySchemes, authStrategies),
386+ oauth2Presets: buildOAuth2Presets(securitySchemes),
205387 });
206388 });