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
126 env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",126 env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token",
127 clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,127 clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID,
128 clientSecret: env.FIRST_PARTY_GITHUB_CLIENT_SECRET,128 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: [],
129 },133 },
130 ]134 ]
131 : []),135 : []),
packages/core/sdk/src/oauth-client.ts+5−0
@@ -129,6 +129,11 @@ export interface FirstPartyOAuthClientConfig { · 128 unmodified lines
129 * exact-match default for those integrations. Endpoint-host matching still129 * exact-match default for those integrations. Endpoint-host matching still
130 * applies when omitted. */130 * applies when omitted. */
131 readonly integrations?: readonly IntegrationSlug[];131 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[];
132 /** OAuth scopes this deployment permits the app to request. Omit to allow137 /** OAuth scopes this deployment permits the app to request. Omit to allow
133 * every scope declared by a matching integration. For declared scopes,138 * every scope declared by a matching integration. For declared scopes,
134 * start and completion fail unless every requested scope belongs to this139 * 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
17} from "./oauth-client";17} from "./oauth-client";
18import { definePlugin } from "./plugin";18import { definePlugin } from "./plugin";
19import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";19import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
20import { serveOAuthTestServer } from "./testing/oauth-test-server";20import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server";
21 21
22// First-party OAuth clients: host-operated apps declared in executor config22// First-party OAuth clients: host-operated apps declared in executor config
23// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from23// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from
@@ -165,6 +165,40 @@ describe("first-party oauth clients", () => { · 141 unmodified lines
165 },165 },
166 );166 );
167 167
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
168 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>202 it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>
169 Effect.scoped(203 Effect.scoped(
170 Effect.gen(function* () {204 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
1302 // list is already authoritative (§7.2) and must not be re-narrowed by a1302 // list is already authoritative (§7.2) and must not be re-narrowed by a
1303 // divergent authorization server.1303 // divergent authorization server.
1304 const authorizationRequestedScopes =1304 const authorizationRequestedScopes =
1305 scopePolicy.kind === "discover"1305 firstParty?.authorizationScopes !== undefined
1306 ? requestedScopes1306 ? dedupeScopes(firstParty.authorizationScopes)
1307 : yield* filterAuthorizationCodeScopes(client, requestedScopes);1307 : scopePolicy.kind === "discover"
1308 ? requestedScopes
1309 : yield* filterAuthorizationCodeScopes(client, requestedScopes);
1308 1310
1309 // authorization_code: persist a session + build the authorize URL.1311 // authorization_code: persist a session + build the authorize URL.
1310 const verifier = createPkceCodeVerifier();1312 const verifier = createPkceCodeVerifier();