Unified · Split
| @@ -126,6 +126,10 @@ const cloudFirstPartyOAuthClients = (): readonly FirstPartyOAuthClientConfig[] = · 125 unmodified lines | ||
| 126 | 126 | env.FIRST_PARTY_GITHUB_TOKEN_URL ?? "https://github.com/login/oauth/access_token", |
| 127 | 127 | clientId: env.FIRST_PARTY_GITHUB_CLIENT_ID, |
| 128 | 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 | : []), |
| @@ -129,6 +129,11 @@ export interface FirstPartyOAuthClientConfig { · 128 unmodified lines | ||
| 129 | 129 | * exact-match default for those integrations. Endpoint-host matching still |
| 130 | 130 | * applies when omitted. */ |
| 131 | 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 | 137 | /** OAuth scopes this deployment permits the app to request. Omit to allow |
| 133 | 138 | * every scope declared by a matching integration. For declared scopes, |
| 134 | 139 | * start and completion fail unless every requested scope belongs to this |
| @@ -17,7 +17,7 @@ import { · 16 unmodified lines | ||
| 17 | 17 | } from "./oauth-client"; |
| 18 | 18 | import { definePlugin } from "./plugin"; |
| 19 | 19 | import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; |
| 20 | −import { serveOAuthTestServer } from "./testing/oauth-test-server"; | |
| 20 | +import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; | |
| 21 | 21 | |
| 22 | 22 | // First-party OAuth clients: host-operated apps declared in executor config |
| 23 | 23 | // (`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 | 202 | it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () => |
| 169 | 203 | Effect.scoped( |
| 170 | 204 | Effect.gen(function* () { |
| @@ -1302,9 +1302,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1301 unmodified lines | ||
| 1302 | 1302 | // list is already authoritative (§7.2) and must not be re-narrowed by a |
| 1303 | 1303 | // divergent authorization server. |
| 1304 | 1304 | 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); | |
| 1308 | 1310 | |
| 1309 | 1311 | // authorization_code: persist a session + build the authorize URL. |
| 1310 | 1312 | const verifier = createPkceCodeVerifier(); |