diff --git a/.changeset/quiet-connection-setup.md b/.changeset/quiet-connection-setup.md new file mode 100644 index 0000000000..9d75373928 --- /dev/null +++ b/.changeset/quiet-connection-setup.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Accept Slack bot and user OAuth token envelopes during sign-in and token refresh. diff --git a/e2e/scenarios/connection-setup-ux.test.ts b/e2e/scenarios/connection-setup-ux.test.ts new file mode 100644 index 0000000000..8158a9edbd --- /dev/null +++ b/e2e/scenarios/connection-setup-ux.test.ts @@ -0,0 +1,137 @@ +import { randomBytes } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { connectEmulator } from "@executor-js/emulate"; +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; +import { variable } from "@executor-js/sdk/http-auth"; +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([openApiHttpPlugin()] as const); +// Each journey has its own real provider state, OAuth app, user and integration. +const fixture = Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const slug = IntegrationSlug.make(`setup-${randomBytes(4).toString("hex")}`); + const app = OAuthClientSlug.make(`${slug}-app`); + const baseUrl = yield* createEmulatorInstance("slack", "connection-setup"); + const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl })); + const credential = yield* Effect.promise(() => + emulator.credentials.mint({ + type: "oauth-authorization-code", + redirect_uris: [new URL("/api/oauth/callback", target.baseUrl).toString()], + }), + ); + const { + client_id: clientId, + client_secret: clientSecret, + authorization_url: authorizationUrl, + token_url: tokenUrl, + } = credential; + if (!clientId || !clientSecret || !authorizationUrl || !tokenUrl) { + return yield* Effect.die("Slack emulator did not mint an OAuth app"); + } + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const connections = yield* client.connections.list({ query: { integration: slug } }); + for (const connection of connections) { + yield* client.connections + .remove({ + params: { + owner: connection.owner, + integration: slug, + name: connection.name, + }, + }) + .pipe(Effect.ignore); + } + yield* client.oauth + .removeClient({ params: { slug: app }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore); + }).pipe(Effect.ignore), + ); + yield* client.openapi.addSpec({ + payload: { + slug, + name: "Team chat", + baseUrl: "https://slack.com", + displayDomain: "slack.com", + spec: { + kind: "blob", + value: JSON.stringify({ + openapi: "3.0.3", + info: { title: "Team chat", version: "1" }, + // No API operations: only the isolated emulator receives OAuth traffic. + servers: [{ url: "https://slack.com" }], + paths: {}, + }), + }, + authenticationTemplate: [ + { + slug: "token", + type: "apiKey", + headers: { Authorization: ["Bearer ", variable("token")] }, + }, + { slug: "oauth", kind: "oauth2", authorizationUrl, tokenUrl, scopes: ["users:read"] }, + ], + }, + }); + yield* client.oauth.createClient({ + payload: { + slug: app, + owner: "org", + grant: "authorization_code", + clientId, + clientSecret, + authorizationUrl, + tokenUrl, + originIntegration: slug, + }, + }); + return { target, browser, identity, client, slug, emulator }; +}); + +scenario( + "Slack OAuth ยท provider consent saves a connection", + {}, + Effect.scoped( + Effect.gen(function* () { + const { browser, identity, slug, client, emulator } = yield* fixture; + yield* browser.session(identity, async ({ page, step }) => { + await step("Sign in with a provider account without naming the connection", async () => { + await visit(page, `/integrations/${slug}?addAccount=1`); + await page.getByRole("tab", { name: "OAuth2", exact: true }).click(); + const opened = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click(); + const popup = await opened; + await popup.waitForURL(/oauth\/v2\/authorize/); + // The hosted emulator renders a root-relative form action. Rebase only + // that provider transport onto this run's isolated instance. + await popup.route("https://emulators.dev/oauth/v2/authorize/callback", (route) => + route.continue({ url: `${emulator.baseUrl}/oauth/v2/authorize/callback` }), + ); + await popup.getByRole("button", { name: /admin/ }).click(); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 30_000 }); + }); + }); + const connections = yield* client.connections.list({ query: { integration: slug } }); + expect(connections, "the completed callback persists the new account").toHaveLength(1); + expect(connections[0]?.name).toBeTruthy(); + const ledger = yield* Effect.promise(() => emulator.ledger.list()); + expect( + ledger.some((entry) => entry.method === "POST" && entry.path.includes("oauth.v2.access")), + "the real provider exchanged an authorization code", + ).toBe(true); + }), + ), +); diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index d94e8c34ad..92bc623d06 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -621,6 +621,7 @@ describe("exchangeAuthorizationCode", () => { it.effect("uses nested granted scopes for Slack-style user token responses", () => withTokenEndpoint( tokenResponse({ + ok: true, access_token: "xoxp-user-token", token_type: "Bearer", scope: "", @@ -1838,3 +1839,155 @@ describe("OAuth2Error tagging", () => { }); }); }); + +// Slack labels bearer credentials by actor type. The same envelope is used +// during authorization-code exchange and refresh-token rotation. +describe("Provider token envelopes", () => { + const grants = [ + { + label: "standard bearer response with ok metadata", + body: { + ok: true, + access_token: "provider-token", + token_type: "Bearer", + scope: "scope,with-comma other.scope", + }, + expected: { + access_token: "provider-token", + token_type: "bearer", + scope: "scope,with-comma other.scope", + }, + }, + { + // https://docs.slack.dev/reference/methods/oauth.v2.access/ + // A single response can contain two distinct accounts and refresh tokens. + label: "Slack bot and user response", + body: { + ok: true, + access_token: "bot-token", + token_type: "bot", + scope: "commands,incoming-webhook", + expires_in: 43200, + refresh_token: "bot-refresh", + authed_user: { + access_token: "user-token", + token_type: "user", + scope: "chat:write", + expires_in: 43200, + refresh_token: "user-refresh", + }, + }, + expected: { + access_token: "bot-token", + token_type: "bearer", + scope: "commands incoming-webhook", + expires_in: 43200, + refresh_token: "bot-refresh", + }, + }, + { + label: "bot", + body: { + ok: true, + access_token: "bot-token", + token_type: "bot", + scope: "channels:read,chat:write", + refresh_token: "bot-refresh", + expires_in: 3600, + }, + expected: { + access_token: "bot-token", + token_type: "bearer", + scope: "channels:read chat:write", + refresh_token: "bot-refresh", + expires_in: 3600, + }, + }, + { + label: "user", + body: { + ok: true, + access_token: "user-token", + token_type: "user", + scope: "users:read,users:read.email", + refresh_token: "user-refresh", + expires_in: 3600, + }, + expected: { + access_token: "user-token", + token_type: "bearer", + scope: "users:read users:read.email", + refresh_token: "user-refresh", + expires_in: 3600, + }, + }, + { + label: "nested user", + body: { + ok: true, + authed_user: { + access_token: "nested-user-token", + token_type: "user", + scope: "users:read,chat:write", + refresh_token: "nested-refresh", + expires_in: 3600, + }, + }, + expected: { + access_token: "nested-user-token", + token_type: "bearer", + scope: "users:read chat:write", + refresh_token: "nested-refresh", + expires_in: 3600, + }, + }, + ]; + for (const grant of grants) { + it.effect(`exchanges a ${grant.label} grant`, () => + withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + redirectUrl: "https://app.example/callback", + codeVerifier: "verifier", + code: "code", + }); + expect(result).toMatchObject(grant.expected); + }), + ), + ); + it.effect(`refreshes a ${grant.label} grant`, () => + withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) => + Effect.gen(function* () { + const result = yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + refreshToken: "old-refresh", + }); + expect(result).toMatchObject(grant.expected); + }), + ), + ); + } + it.effect("still rejects unsupported token types in an otherwise successful envelope", () => + withTokenEndpoint( + tokenResponse({ ok: true, access_token: "token", token_type: "mac" }), + ({ tokenUrl }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + redirectUrl: "https://app.example/callback", + codeVerifier: "verifier", + code: "code", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index c6e3209fe7..b8b11714c2 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -1117,16 +1117,74 @@ const stripIdToken = async (response: Response): Promise }; }; +const SlackGrant = Schema.Struct({ + access_token: Schema.optional(Schema.String), + token_type: Schema.optional(Schema.Literals(["bot", "user", "Bearer", "bearer"])), + refresh_token: Schema.optional(Schema.String), + expires_in: Schema.optional(Schema.Number), + scope: Schema.optional(Schema.String), +}); +const decodeSlackEnvelope = Schema.decodeUnknownOption( + Schema.Struct({ + ...SlackGrant.fields, + ok: Schema.Literal(true), + authed_user: Schema.optional(SlackGrant), + }), +); + +/** Slack's `bot` and `user` values identify the account, not an HTTP auth + * scheme. Project its successful envelope to an RFC 6749 bearer grant before + * oauth4webapi validates it. User-only grants may live entirely in authed_user; + * never replace a populated top-level grant with another account's grant. */ +const normalizeSlackTokenEnvelope = async (response: Response): Promise => { + const decoded = decodeSlackEnvelope(await safeJsonFromResponse(response)); + if (Option.isNone(decoded)) return response; + const envelope = decoded.value; + const user = envelope.authed_user; + const grant = + user?.access_token !== undefined && + (envelope.access_token === undefined || !envelope.scope?.trim()) + ? user + : envelope; + // Standard bearer responses may also contain `ok: true`. Preserve their + // scopes and provider metadata; only Slack's actor token types need adapting. + if ( + grant.access_token === undefined || + (grant.token_type !== "bot" && grant.token_type !== "user") + ) { + return response; + } + const scope = grant.scope + ?.split(/[\s,]+/) + .filter(Boolean) + .join(" "); + return new Response( + JSON.stringify({ + access_token: grant.access_token, + token_type: "Bearer", + refresh_token: grant.refresh_token, + expires_in: grant.expires_in, + ...(scope ? { scope } : {}), + }), + { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }, + ); +}; + const processTokenEndpointResponse = async ( as: oauth.AuthorizationServer, client: oauth.Client, response: Response, ): Promise => { const stripped = await stripIdToken(response); - const providerUserGrant = await nestedAuthedUserGrant(stripped.response); + const normalizedResponse = await normalizeSlackTokenEnvelope(stripped.response); + const providerUserGrant = await nestedAuthedUserGrant(normalizedResponse); const parsed = tokenResponseFrom( as, - await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), + await oauth.processGenericTokenEndpointResponse(as, client, normalizedResponse), ); const token = parsed.scope === undefined && providerUserGrant !== undefined @@ -1442,7 +1500,7 @@ export const refreshAccessToken = ( const result = await oauth.processRefreshTokenResponse( as, client, - (await stripIdToken(response)).response, + await normalizeSlackTokenEnvelope((await stripIdToken(response)).response), ); return tokenResponseFrom(as, result); },