Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-connection-setup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

Accept Slack bot and user OAuth token envelopes during sign-in and token refresh.
137 changes: 137 additions & 0 deletions e2e/scenarios/connection-setup-ux.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
),
);
153 changes: 153 additions & 0 deletions packages/core/sdk/src/oauth-helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down Expand Up @@ -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);
}),
),
);
});
64 changes: 61 additions & 3 deletions packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,16 +1117,74 @@ const stripIdToken = async (response: Response): Promise<StrippedTokenResponse>
};
};

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<Response> => {
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<OAuth2TokenResponse> => {
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
Expand Down Expand Up @@ -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);
},
Expand Down
Loading