Public: anyone can read this; the crew can change it.

The OpenAPI contract describes OAuth: an oauth2 security scheme with the authorization-code flow and its refresh URL #8454

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/8433-the-openapi-contract-describes-oauth-an → main 4 commits fork

  • Merged. torque/8433-the-openapi-contract-describes-oauth-an was merged into main as de250a8.

Review

Sign in to review this change.
4 files changed +68 −3
docs/features/openapi-oauth.md added +20
@@ -0,0 +1,20 @@
1 # OpenAPI and OAuth
2
3 `GET /contract/openapi.json` describes three ways to sign in, under `components.securitySchemes`:
4
5 - `token`: an HTTP bearer token (a personal or proposal token).
6 - `oauth2`: the authorization-code flow (with PKCE) and its refresh. Scopes are the ones in `OAUTH_SCOPES` (`ro`, `rw`); the document is built from that list, so it cannot drift.
7 - `openid`: OpenID Connect discovery, at `/.well-known/openid-configuration`. No operation requires it; it is for discovery only.
8
9 Every operation that needs a login lists `token` and `oauth2`; either one is enough. Each `oauth2` requirement names one scope: `ro` on a GET, `rw` on anything else (`rw` reads too). A client asks for one scope, `ro` or `rw`: the forge grants a single scope, so ask for `rw` if the client will write.
10
11 ## A generated client signs in
12
13 1. Send the person to `authorizationUrl` (`/oauth/authorize`) with `response_type=code`, your `client_id`, `redirect_uri`, `scope` (`ro` or `rw`), `state` and a PKCE `code_challenge` and `code_challenge_method=S256`.
14 2. The person approves; the forge redirects back with `code`.
15 3. POST `code`, `code_verifier` and `grant_type=authorization_code` to `tokenUrl` (`/oauth/token`). The answer holds `access_token` and, when the grant has one, `refresh_token`.
16 4. Call the API with `Authorization: Bearer <access_token>`.
17
18 ## Refresh
19
20 When the access token expires (a `401`), POST `grant_type=refresh_token` and the `refresh_token` to `refreshUrl`. It is the same endpoint as `tokenUrl`. Keep the new refresh token the answer returns.
src/contract/openapi.ts +31 −2
@@ -12,8 +12,22 @@
1212 */
1313
1414 import type { Route } from "./openapi/shared.ts";
15 import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts";
1516 import * as each from "./openapi/routes/index.ts";
1617
18 /** What each OAuth scope grants; a scope added to OAUTH_SCOPES without a line here fails the type check. */
19 const SCOPE_WORDS: Readonly<Record<OAuthScope, string>> = {
20 ro: "Read the API as the person",
21 rw: "Read and write the API as the person",
22 };
23
24 /**
25 * The one scope an operation asks for. Scopes in one requirement are all
26 * required (OpenAPI 3.1 §4.8.30) and `/oauth/authorize` grants a single
27 * one, so a read asks for `ro` and anything else for `rw`, which reads too.
28 */
29 export const oauthScopeFor = (method: Route["method"]): OAuthScope => (method === "get" ? "ro" : "rw");
30
1731 /** Every row, sorted by operationId: the index's order. */
1832 export const ROUTES: readonly Route[] = Object.values(each);
1933
@@ -74,7 +88,7 @@ export function openapi(origin: string, version: string): Record<string, unknown
7488 },
7589 },
7690 }),
77 ...(r.auth ? { security: [{ token: [] }] } : {}),
91 ...(r.auth ? { security: [{ token: [] }, { oauth2: [oauthScopeFor(r.method)] }] } : {}),
7892 responses: {
7993 [String(r.status ?? 200)]: {
8094 description: "Done",
@@ -114,7 +128,22 @@ export function openapi(origin: string, version: string): Record<string, unknown
114128 servers: [{ url: origin }],
115129 paths,
116130 components: {
117 securitySchemes: { token: { type: "http", scheme: "bearer" } },
131 securitySchemes: {
132 token: { type: "http", scheme: "bearer" },
133 // RFC 6749 §4.1 authorization code (with PKCE), §6 refresh; the token endpoint serves both grants.
134 oauth2: {
135 type: "oauth2",
136 flows: {
137 authorizationCode: {
138 authorizationUrl: `${origin}/oauth/authorize`,
139 tokenUrl: `${origin}/oauth/token`,
140 refreshUrl: `${origin}/oauth/token`,
141 scopes: Object.fromEntries(OAUTH_SCOPES.map((scope) => [scope, SCOPE_WORDS[scope]])),
142 },
143 },
144 },
145 openid: { type: "openIdConnect", openIdConnectUrl: `${origin}/.well-known/openid-configuration` },
146 },
118147 },
119148 };
120149 }
test/openapi-flow.test.ts +16
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
6 import { OAUTH_SCOPES } from "../src/core/oauth.ts";
67 import { createApp } from "../src/app.ts";
78 import { serve, type Servers } from "../src/http/server.ts";
89
@@ -37,3 +38,18 @@ test("GET /contract/openapi.json has the flow and the scorecard", async () => {
3738 expect(card.responses.default.description).toContain("`unauthorized`");
3839 expect(card.responses.default.description).toContain("`forbidden`");
3940 });
41
42 test("the document describes OAuth: authorization code, refresh, and OpenID discovery", async () => {
43 const doc = (await (await fetch(`${servers.url}/contract/openapi.json`)).json()) as any;
44 const schemes = doc.components.securitySchemes;
45 const flow = schemes.oauth2.flows.authorizationCode;
46 expect(flow.refreshUrl.endsWith("/oauth/token")).toBe(true);
47 expect(flow.tokenUrl).toBe(flow.refreshUrl);
48 expect(flow.authorizationUrl.endsWith("/oauth/authorize")).toBe(true);
49 expect(Object.keys(flow.scopes)).toEqual([...OAUTH_SCOPES]);
50 expect(schemes.openid.openIdConnectUrl.endsWith("/.well-known/openid-configuration")).toBe(true);
51 const card = doc.paths["/api/v1/admin/invites"].post;
52 expect(card.security).toEqual([{ token: [] }, { oauth2: ["rw"] }]);
53 const read = Object.values(doc.paths).map((p: any) => p.get).find((op: any) => op?.security);
54 expect(read.security).toEqual([{ token: [] }, { oauth2: ["ro"] }]);
55 });
test/openapi-oa-pull-checks.test.ts +1 −1
@@ -9,7 +9,7 @@ test("GET and POST …/pulls/{number}/checks are described", () => {
99 expect(paths[P]?.get?.summary).toContain("checks");
1010 expect(paths[P]?.post?.responses?.["202"]).toBeDefined();
1111 expect(paths[P]?.get?.security).toBeUndefined();
12 expect(paths[P]?.post?.security).toEqual([{ token: [] }]);
12 expect(paths[P]?.post?.security).toEqual([{ token: [] }, { oauth2: ["rw"] }]);
1313 const ok = (m: string, code: string) =>
1414 paths[P]?.[m]?.responses?.[code]?.content?.["application/json"]?.schema?.properties;
1515 expect(Object.keys(ok("get", "200"))).toEqual(["head", "state", "required", "run", "runs"]);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs