History

Support GitHub App OAuth permissions (#1659)

Rhys Sullivan committed 2 months ago

Commit 739805f1 parent 34f37204 files · +49 −4Browse files

Unified · Split

4 files
  1. apps/cloud/src/engine/execution-stack.ts +4 −0
  2. packages/core/sdk/src/oauth-client.ts +5 −0
  3. packages/core/sdk/src/oauth-first-party.test.ts +35 −1
  4. packages/core/sdk/src/oauth-service.ts +5 −3
apps/cloud/src/engine/execution-stack.ts+4−0
@@ -126,6 +126,10 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = · 125 unmodified lines
126126 env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
127127 clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
128128 clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,
129+ integrations: [IntegrationSlug.make("github_rest")],
130+ // GitHub App user access tokens do not use classic OAuth scopes;
131+ // their capabilities come from the app's registered permissions.
132+ authorizationScopes: [],
129133 },
130134 ]
131135 : []),
packages/core/sdk/src/oauth-client.ts+5−0
@@ -129,6 +129,11 @@ export interface FirstPartyOAuthClientConfig { · 128 unmodified lines
129129 * exact-match default for those integrations. Endpoint-host matching still
130130 * applies when omitted. */
131131 readonly integrations?: readonly IntegrationSlug[];
132+ /** Scopes sent on the provider authorization request instead of the
133+ * integration-declared set. Use an empty array for providers such as
134+ * GitHub Apps, whose capabilities are configured on the app and whose OAuth
135+ * user-token flow does not use scopes. Omit for normal OAuth clients. */
136+ readonly authorizationScopes?: readonly string[];
132137 /** OAuth scopes this deployment permits the app to request. Omit to allow
133138 * every scope declared by a matching integration. For declared scopes,
134139 * start and completion fail unless every requested scope belongs to this
packages/core/sdk/src/oauth-first-party.test.ts+35−1
@@ -17,7 +17,7 @@ import { · 16 unmodified lines
1717 } from "./oauth-client";
1818 import { definePlugin } from "./plugin";
1919 import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
20−import { serveOAuthTestServer } from "./testing/oauth-test-server";
20+import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
2121
2222 // First-party OAuth clients: host-operated apps declared in executor config
2323 // (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from
@@ -165,6 +165,40 @@ describe("first-party oauth clients", () => { · 141 unmodified lines
165165 },
166166 );
167167
168+ it.effect("an authorization scope override supports scope-less provider app tokens", () =>
169+ Effect.scoped(
170+ Effect.gen(function* () {
171+ const server = yield* serveOAuthTestServer({ scopes: ["repo"] });
172+ const { executor } = yield* makeTestWorkspaceHarness({
173+ plugins,
174+ firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }],
175+ });
176+ yield* executor.acme.seed(["repo"]);
177+
178+ const started = yield* executor.oauth.start({
179+ owner: "org",
180+ client: FIRST_PARTY,
181+ clientOwner: "org",
182+ name: ConnectionName.make("github-app"),
183+ integration: INTEG,
184+ template: TEMPLATE,
185+ });
186+ expect(started.status).toBe("redirect");
187+ if (started.status !== "redirect") return;
188+ expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);
189+
190+ const callback = yield* server.completeAuthorizationCodeFlow({
191+ authorizationUrl: started.authorizationUrl,
192+ });
193+ const connection = yield* executor.oauth.complete({
194+ state: started.state,
195+ code: callback.code,
196+ });
197+ expect(connection.oauthScope).toBeNull();
198+ }),
199+ ),
200+ );
201+
168202 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
169203 Effect.scoped(
170204 Effect.gen(function* () {
packages/core/sdk/src/oauth-service.ts+5−3
@@ -1302,9 +1302,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1301 unmodified lines
13021302 // list is already authoritative (§7.2) and must not be re-narrowed by a
13031303 // divergent authorization server.
13041304 const authorizationRequestedScopes =
1305− scopePolicy.kind === "discover"
1306− ? requestedScopes
1307− : yield* filterAuthorizationCodeScopes(client, requestedScopes);
1305+ firstParty?.authorizationScopes !== undefined
1306+ ? dedupeScopes(firstParty.authorizationScopes)
1307+ : scopePolicy.kind === "discover"
1308+ ? requestedScopes
1309+ : yield* filterAuthorizationCodeScopes(client, requestedScopes);
13081310
13091311 // authorization_code: persist a session + build the authorize URL.
13101312 const verifier = createPkceCodeVerifier();