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 #8440
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8433-the-openapi-contract-describes-oauth-an → main 4 commits fork 2 threads · 2 unresolved
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 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | import type { Route } from "./openapi/shared.ts"; |
| 15 | import { OAUTH_SCOPES, type OAuthScope } from "../core/oauth.ts"; | |
| 15 | 16 | import * as each from "./openapi/routes/index.ts"; |
| 16 | 17 | |
| 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 | ||
| 17 | 31 | /** Every row, sorted by operationId: the index's order. */ |
| 18 | 32 | export const ROUTES: readonly Route[] = Object.values(each); |
| 19 | 33 | |
| @@ -74,7 +88,7 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 74 | 88 | }, |
| 75 | 89 | }, |
| 76 | 90 | }), |
| 77 | ...(r.auth ? { security: [{ token: [] }] } : {}), | |
| 91 | ...(r.auth ? { security: [{ token: [] }, { oauth2: [oauthScopeFor(r.method)] }] } : {}), | |
| 78 | 92 | responses: { |
| 79 | 93 | [String(r.status ?? 200)]: { |
| 80 | 94 | description: "Done", |
| @@ -114,7 +128,22 @@ export function openapi(origin: string, version: string): Record<string, unknown | ||
| 114 | 128 | servers: [{ url: origin }], |
| 115 | 129 | paths, |
| 116 | 130 | 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 | }, | |
| 118 | 147 | }, |
| 119 | 148 | }; |
| 120 | 149 | } |
test/openapi-flow.test.ts +16
| @@ -3,6 +3,7 @@ import { afterAll, beforeAll, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { OAUTH_SCOPES } from "../src/core/oauth.ts"; | |
| 6 | 7 | import { createApp } from "../src/app.ts"; |
| 7 | 8 | import { serve, type Servers } from "../src/http/server.ts"; |
| 8 | 9 | |
| @@ -37,3 +38,18 @@ test("GET /contract/openapi.json has the flow and the scorecard", async () => { | ||
| 37 | 38 | expect(card.responses.default.description).toContain("`unauthorized`"); |
| 38 | 39 | expect(card.responses.default.description).toContain("`forbidden`"); |
| 39 | 40 | }); |
| 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", () => { | ||
| 9 | 9 | expect(paths[P]?.get?.summary).toContain("checks"); |
| 10 | 10 | expect(paths[P]?.post?.responses?.["202"]).toBeDefined(); |
| 11 | 11 | expect(paths[P]?.get?.security).toBeUndefined(); |
| 12 | expect(paths[P]?.post?.security).toEqual([{ token: [] }]); | |
| 12 | expect(paths[P]?.post?.security).toEqual([{ token: [] }, { oauth2: ["rw"] }]); | |
| 13 | 13 | const ok = (m: string, code: string) => |
| 14 | 14 | paths[P]?.[m]?.responses?.[code]?.content?.["application/json"]?.schema?.properties; |
| 15 | 15 | expect(Object.keys(ok("get", "200"))).toEqual(["head", "state", "required", "run", "runs"]); |
new above
offline
Derive these from OAUTH_SCOPES (Record<OAuthScope,string> for the descriptions) so the scheme and the per-operation security cannot drift.