Showing the changes to packages (9 of 13 files). Show all
Unified · Split
| @@ -123,10 +123,12 @@ const OAuthClientSummaryResponse = Schema.Struct({ · 122 unmodified lines | ||
| 123 | 123 | }), |
| 124 | 124 | /** Host-operated app declared in executor config — every org connects |
| 125 | 125 | * through it; nothing to paste. `integrations` ranks it as the default |
| 126 | − * for those integrations in the picker. */ | |
| 126 | + * for those integrations; `allowedScopes` is the host-enforced scope | |
| 127 | + * boundary the picker mirrors before offering it. */ | |
| 127 | 128 | Schema.Struct({ |
| 128 | 129 | kind: Schema.Literal("first_party"), |
| 129 | 130 | integrations: Schema.optional(Schema.Array(IntegrationSlug)), |
| 131 | + allowedScopes: Schema.optional(Schema.Array(Schema.String)), | |
| 130 | 132 | }), |
| 131 | 133 | ]), |
| 132 | 134 | }); |
| @@ -88,6 +88,10 @@ export type OAuthClientOrigin = · 87 unmodified lines | ||
| 88 | 88 | * one Google app deliberately backs gmail, calendar, drive, …. */ |
| 89 | 89 | readonly kind: "first_party"; |
| 90 | 90 | readonly integrations?: readonly IntegrationSlug[]; |
| 91 | + /** OAuth scopes this deployment permits the app to request. Omitted means | |
| 92 | + * the provider app is unrestricted; present means every requested scope | |
| 93 | + * must be in this set. This is public policy metadata, not a secret. */ | |
| 94 | + readonly allowedScopes?: readonly string[]; | |
| 91 | 95 | }; |
| 92 | 96 | |
| 93 | 97 | /** Slug namespace separating config-declared first-party apps from stored |
| @@ -122,8 +126,24 @@ export interface FirstPartyOAuthClientConfig { · 28 unmodified lines | ||
| 122 | 126 | * exact-match default for those integrations. Endpoint-host matching still |
| 123 | 127 | * applies when omitted. */ |
| 124 | 128 | readonly integrations?: readonly IntegrationSlug[]; |
| 129 | + /** OAuth scopes this deployment permits the app to request. Omit to allow | |
| 130 | + * every scope declared by a matching integration. When present, OAuth start | |
| 131 | + * and completion fail unless every requested scope belongs to this set. */ | |
| 132 | + readonly allowedScopes?: readonly string[]; | |
| 125 | 133 | } |
| 126 | 134 | |
| 135 | +/** Whether a first-party app may request an integration's complete OAuth scope | |
| 136 | + * set. An omitted policy preserves provider-wide clients; an explicit policy | |
| 137 | + * is fail-closed and requires every requested scope to be listed. */ | |
| 138 | +export const firstPartyOAuthClientAllowsScopes = ( | |
| 139 | + config: Pick<FirstPartyOAuthClientConfig, "allowedScopes">, | |
| 140 | + requestedScopes: readonly string[], | |
| 141 | +): boolean => { | |
| 142 | + if (config.allowedScopes === undefined) return true; | |
| 143 | + const allowed = new Set(config.allowedScopes); | |
| 144 | + return requestedScopes.every((scope) => allowed.has(scope)); | |
| 145 | +}; | |
| 146 | + | |
| 127 | 147 | export type CreateOAuthClientInput = OAuthClient & { |
| 128 | 148 | /** Stored-row origins only — `first_party` is config-declared, never created |
| 129 | 149 | * through this surface (the service also rejects the slug namespace). */ |
| @@ -176,7 +176,9 @@ describe("first-party oauth clients", () => { · 175 unmodified lines | ||
| 176 | 176 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 177 | 177 | const { executor } = yield* makeTestWorkspaceHarness({ |
| 178 | 178 | plugins, |
| 179 | − firstPartyOAuthClients: [firstPartyClientFor(server)], | |
| 179 | + firstPartyOAuthClients: [ | |
| 180 | + { ...firstPartyClientFor(server), allowedScopes: ["openid", "read"] }, | |
| 181 | + ], | |
| 180 | 182 | }); |
| 181 | 183 | yield* executor.acme.seed(); |
| 182 | 184 | |
| @@ -193,13 +195,45 @@ describe("first-party oauth clients", () => { · 10 unmodified lines | ||
| 193 | 195 | const clients = yield* executor.oauth.listClients(); |
| 194 | 196 | expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]); |
| 195 | 197 | const firstParty = clients[0]!; |
| 196 | − expect(firstParty.origin).toEqual({ kind: "first_party", integrations: [INTEG] }); | |
| 198 | + expect(firstParty.origin).toEqual({ | |
| 199 | + kind: "first_party", | |
| 200 | + integrations: [INTEG], | |
| 201 | + allowedScopes: ["openid", "read"], | |
| 202 | + }); | |
| 197 | 203 | expect(firstParty.clientId).toBe("test-client"); |
| 198 | 204 | expect("clientSecret" in firstParty).toBe(false); |
| 199 | 205 | }), |
| 200 | 206 | ), |
| 201 | 207 | ); |
| 202 | 208 | |
| 209 | + it.effect("a scope-limited first-party app rejects an integration outside its policy", () => | |
| 210 | + Effect.scoped( | |
| 211 | + Effect.gen(function* () { | |
| 212 | + const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] }); | |
| 213 | + const { executor } = yield* makeTestWorkspaceHarness({ | |
| 214 | + plugins, | |
| 215 | + firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }], | |
| 216 | + }); | |
| 217 | + yield* executor.acme.seed(["write"]); | |
| 218 | + | |
| 219 | + const error = yield* executor.oauth | |
| 220 | + .start({ | |
| 221 | + owner: "org", | |
| 222 | + client: FIRST_PARTY, | |
| 223 | + clientOwner: "org", | |
| 224 | + name: ConnectionName.make("blocked"), | |
| 225 | + integration: INTEG, | |
| 226 | + template: TEMPLATE, | |
| 227 | + }) | |
| 228 | + .pipe(Effect.flip); | |
| 229 | + | |
| 230 | + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); | |
| 231 | + const startError = error as OAuthStartError; | |
| 232 | + expect(startError.message).toContain("not enabled for integration acme"); | |
| 233 | + }), | |
| 234 | + ), | |
| 235 | + ); | |
| 236 | + | |
| 203 | 237 | it.effect("createClient and removeClient reject the reserved first-party namespace", () => |
| 204 | 238 | Effect.scoped( |
| 205 | 239 | Effect.gen(function* () { |
| @@ -36,6 +36,7 @@ import { · 35 unmodified lines | ||
| 36 | 36 | OAuthRegisterDynamicError, |
| 37 | 37 | OAuthSessionNotFoundError, |
| 38 | 38 | OAuthStartError, |
| 39 | + firstPartyOAuthClientAllowsScopes, | |
| 39 | 40 | firstPartyOAuthClientSlug, |
| 40 | 41 | isFirstPartyOAuthClientSlug, |
| 41 | 42 | type ConnectResult, |
| @@ -1027,7 +1028,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 985 unmodified lines | ||
| 1027 | 1028 | tokenUrl: config.tokenUrl, |
| 1028 | 1029 | resource: null, |
| 1029 | 1030 | clientId: config.clientId, |
| 1030 | − origin: { kind: "first_party", integrations: config.integrations ?? [] }, | |
| 1031 | + origin: { | |
| 1032 | + kind: "first_party", | |
| 1033 | + ...(config.integrations !== undefined ? { integrations: config.integrations } : {}), | |
| 1034 | + ...(config.allowedScopes !== undefined ? { allowedScopes: config.allowedScopes } : {}), | |
| 1035 | + }, | |
| 1031 | 1036 | }), |
| 1032 | 1037 | ); |
| 1033 | 1038 | return deps.fuma |
| @@ -1218,6 +1223,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 184 unmodified lines | ||
| 1218 | 1223 | ) |
| 1219 | 1224 | : dedupeScopes(scopePolicy.scopes); |
| 1220 | 1225 | |
| 1226 | + // An explicitly scope-limited first-party app is an authorization | |
| 1227 | + // boundary, not picker decoration. Endpoint matching can associate one | |
| 1228 | + // Google client with every Google API, so enforce the complete requested | |
| 1229 | + // set here before persisting an OAuth session or redirecting the browser. | |
| 1230 | + if (firstPartyFlow) { | |
| 1231 | + const firstParty = firstPartyBySlug.get(String(input.client)); | |
| 1232 | + if ( | |
| 1233 | + firstParty !== undefined && | |
| 1234 | + !firstPartyOAuthClientAllowsScopes(firstParty, requestedScopes) | |
| 1235 | + ) { | |
| 1236 | + return yield* new OAuthStartError({ | |
| 1237 | + message: `The built-in OAuth app is not enabled for integration ${input.integration}.`, | |
| 1238 | + }); | |
| 1239 | + } | |
| 1240 | + } | |
| 1241 | + | |
| 1221 | 1242 | // client_credentials: exchange immediately and mint the connection. |
| 1222 | 1243 | if (client.grant === "client_credentials") { |
| 1223 | 1244 | const token = yield* exchangeClientCredentials({ |
| @@ -1399,6 +1420,20 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 175 unmodified lines | ||
| 1399 | 1420 | restartRequired: true, |
| 1400 | 1421 | }); |
| 1401 | 1422 | } |
| 1423 | + if (isFirstPartyOAuthClientSlug(String(session.clientSlug))) { | |
| 1424 | + const firstParty = firstPartyBySlug.get(String(session.clientSlug)); | |
| 1425 | + if ( | |
| 1426 | + firstParty !== undefined && | |
| 1427 | + firstParty.allowedScopes !== undefined && | |
| 1428 | + (session.requestedScopes === null || | |
| 1429 | + !firstPartyOAuthClientAllowsScopes(firstParty, session.requestedScopes)) | |
| 1430 | + ) { | |
| 1431 | + return yield* new OAuthCompleteError({ | |
| 1432 | + message: `The built-in OAuth app is no longer enabled for integration ${session.integration}; restart the flow.`, | |
| 1433 | + restartRequired: true, | |
| 1434 | + }); | |
| 1435 | + } | |
| 1436 | + } | |
| 1402 | 1437 | |
| 1403 | 1438 | // The PKCE verifier is minted by `start` for every authorization_code |
| 1404 | 1439 | // session. A null/missing one means a corrupt session row — exchanging |
| @@ -17,6 +17,7 @@ export { · 16 unmodified lines | ||
| 17 | 17 | googleStandardUserOAuthPresets, |
| 18 | 18 | googleOAuthConsentScopes, |
| 19 | 19 | googleOAuthConsentScopesForPreset, |
| 20 | + googleCatalogOAuthScopesForPreset, | |
| 20 | 21 | googleServiceSlug, |
| 21 | 22 | googleAudienceWarningsForUrls, |
| 22 | 23 | googlePresetForDiscoveryUrl, |
| @@ -317,16 +317,21 @@ const GOOGLE_HEALTH_CHECKS: Readonly<Record<string, HealthCheckSpec>> = { · 316 unmodified lines | ||
| 317 | 317 | "google-cloud-resource-manager": { operation: "cloudresourcemanager.projects.list" }, |
| 318 | 318 | }; |
| 319 | 319 | |
| 320 | +/** Complete Google OAuth scope set requested by a catalog preset, including | |
| 321 | + * identity scopes used to label and distinguish connected accounts. */ | |
| 322 | +export const googleCatalogOAuthScopesForPreset = (presetId: string): readonly string[] => | |
| 323 | + compactGoogleOAuthScopes([ | |
| 324 | + ...GOOGLE_IDENTITY_SCOPES, | |
| 325 | + ...googleOAuthConsentScopesForPreset(presetId), | |
| 326 | + ]); | |
| 327 | + | |
| 320 | 328 | const googleCatalogAuthTemplate = (presetId: string) => [ |
| 321 | 329 | { |
| 322 | 330 | slug: GOOGLE_OAUTH_SECURITY_SCHEME, |
| 323 | 331 | kind: "oauth2" as const, |
| 324 | 332 | authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, |
| 325 | 333 | tokenUrl: GOOGLE_OAUTH_TOKEN_URL, |
| 326 | − scopes: compactGoogleOAuthScopes([ | |
| 327 | − ...GOOGLE_IDENTITY_SCOPES, | |
| 328 | − ...googleOAuthConsentScopesForPreset(presetId), | |
| 329 | − ]), | |
| 334 | + scopes: googleCatalogOAuthScopesForPreset(presetId), | |
| 330 | 335 | }, |
| 331 | 336 | ]; |
| 332 | 337 | |
| @@ -1516,6 +1516,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1515 unmodified lines | ||
| 1516 | 1516 | // unrelated provider's app. |
| 1517 | 1517 | tokenUrl: method?.oauth?.tokenUrl ?? oauthFallbackProbe?.tokenUrl, |
| 1518 | 1518 | authorizationUrl: method?.oauth?.authorizationUrl ?? oauthFallbackProbe?.authorizationUrl, |
| 1519 | + scopes: method?.oauth?.scopes, | |
| 1519 | 1520 | // Recorded intent: a manual app registered from THIS integration's dialog is |
| 1520 | 1521 | // a tier-1 match regardless of host. |
| 1521 | 1522 | integration, |
| @@ -293,6 +293,29 @@ describe("selectClientsForEndpoints", () => { · 292 unmodified lines | ||
| 293 | 293 | ]); |
| 294 | 294 | }); |
| 295 | 295 | |
| 296 | + it("hides a scope-limited first-party app from another API on the same provider", () => { | |
| 297 | + const firstParty = app("first-party:google", { | |
| 298 | + owner: "org", | |
| 299 | + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 300 | + tokenUrl: "https://oauth2.googleapis.com/token", | |
| 301 | + origin: { | |
| 302 | + kind: "first_party", | |
| 303 | + allowedScopes: ["openid", "email", "https://www.googleapis.com/auth/calendar"], | |
| 304 | + }, | |
| 305 | + }); | |
| 306 | + const result = selectClientsForEndpoints([firstParty], { | |
| 307 | + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | |
| 308 | + tokenUrl: "https://oauth2.googleapis.com/token", | |
| 309 | + integration: IntegrationSlug.make("renamed_gmail"), | |
| 310 | + scopes: ["openid", "email", "https://mail.google.com/"], | |
| 311 | + }); | |
| 312 | + | |
| 313 | + expect(result.endpointMatched).toBe(false); | |
| 314 | + expect(result.matched).toEqual([]); | |
| 315 | + expect(result.nearMatches).toEqual([]); | |
| 316 | + expect(result.unmatched).toEqual([]); | |
| 317 | + }); | |
| 318 | + | |
| 296 | 319 | it("intent-matches a first-party app to its declared integrations even without endpoints", () => { |
| 297 | 320 | const integration = IntegrationSlug.make("github_rest"); |
| 298 | 321 | const firstParty = app("first-party:github", { |
| @@ -53,6 +53,19 @@ export const isDcrClient = (app: OAuthClientOption): boolean => · 52 unmodified lines | ||
| 53 | 53 | export const isFirstPartyClient = (app: OAuthClientOption): boolean => |
| 54 | 54 | app.origin.kind === "first_party"; |
| 55 | 55 | |
| 56 | +/** Mirror the host's first-party scope boundary in the picker. The server is | |
| 57 | + * authoritative; this prevents offering a built-in app for a flow it will | |
| 58 | + * reject. An explicit policy with unknown scopes fails closed. */ | |
| 59 | +const firstPartyClientAllowsScopes = ( | |
| 60 | + app: OAuthClientOption, | |
| 61 | + requestedScopes: readonly string[] | undefined, | |
| 62 | +): boolean => { | |
| 63 | + if (app.origin.kind !== "first_party" || app.origin.allowedScopes === undefined) return true; | |
| 64 | + if (requestedScopes === undefined) return false; | |
| 65 | + const allowed = new Set(app.origin.allowedScopes); | |
| 66 | + return requestedScopes.every((scope) => allowed.has(scope)); | |
| 67 | +}; | |
| 68 | + | |
| 56 | 69 | const hostOf = (url: string): string | undefined => { |
| 57 | 70 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL() throws on invalid input; treat as "no host" |
| 58 | 71 | try { |
| @@ -162,6 +175,9 @@ export function selectClientsForEndpoints( · 103 unmodified lines | ||
| 162 | 175 | /** The integration whose picker this is. A manual app stamped with this |
| 163 | 176 | * integration (recorded intent) is a tier-1 match regardless of host. */ |
| 164 | 177 | readonly integration?: IntegrationSlug; |
| 178 | + /** Complete scope set declared by the selected OAuth auth method. Used to | |
| 179 | + * hide scope-limited first-party apps that the host will reject. */ | |
| 180 | + readonly scopes?: readonly string[]; | |
| 165 | 181 | /** When set, an integration that targets a SPECIFIC server (MCP, whose |
| 166 | 182 | * endpoints are discovered at connect) must match by endpoint — absent |
| 167 | 183 | * endpoints mean NO match (show the register CTA), never "every app |
| @@ -175,7 +191,9 @@ export function selectClientsForEndpoints( · 7 unmodified lines | ||
| 175 | 191 | readonly endpointMatched: boolean; |
| 176 | 192 | } { |
| 177 | 193 | // DCR clients are plumbing, never picker options. |
| 178 | − const manual = all.filter((app) => !isDcrClient(app)); | |
| 194 | + const manual = all.filter( | |
| 195 | + (app) => !isDcrClient(app) && firstPartyClientAllowsScopes(app, endpoints.scopes), | |
| 196 | + ); | |
| 179 | 197 | |
| 180 | 198 | const intent = endpoints.integration; |
| 181 | 199 | const matchesIntent = (app: OAuthClientOption): boolean => { |
| @@ -262,6 +280,7 @@ export function useOAuthClientsForIntegration(opts: { · 80 unmodified lines | ||
| 262 | 280 | readonly tokenUrl?: string; |
| 263 | 281 | readonly authorizationUrl?: string; |
| 264 | 282 | readonly integration?: IntegrationSlug; |
| 283 | + readonly scopes?: readonly string[]; | |
| 265 | 284 | readonly requireEndpointMatch?: boolean; |
| 266 | 285 | }): UseOAuthClientsResult { |
| 267 | 286 | // Read the optimistic list so a just-registered/edited/removed app paints |
| @@ -283,9 +302,17 @@ export function useOAuthClientsForIntegration(opts: { · 15 unmodified lines | ||
| 283 | 302 | tokenUrl: opts.tokenUrl, |
| 284 | 303 | authorizationUrl: opts.authorizationUrl, |
| 285 | 304 | integration: opts.integration, |
| 305 | + scopes: opts.scopes, | |
| 286 | 306 | requireEndpointMatch: opts.requireEndpointMatch, |
| 287 | 307 | }), |
| 288 | − [all, opts.tokenUrl, opts.authorizationUrl, opts.integration, opts.requireEndpointMatch], | |
| 308 | + [ | |
| 309 | + all, | |
| 310 | + opts.tokenUrl, | |
| 311 | + opts.authorizationUrl, | |
| 312 | + opts.integration, | |
| 313 | + opts.scopes, | |
| 314 | + opts.requireEndpointMatch, | |
| 315 | + ], | |
| 289 | 316 | ); |
| 290 | 317 | |
| 291 | 318 | if (!loaded) { |