History

Accept Slack bot and user OAuth grants

Rhys Sullivan committed 1 month ago

Commit cdf7e621 parent 3890d6f4 files · +308 −3Browse files

Unified · Split

4 files
  1. .changeset/quiet-connection-setup.md +5 −0
  2. e2e/scenarios/connection-setup-ux.test.ts +137 −0
  3. packages/core/sdk/src/oauth-helpers.test.ts +111 −0
  4. packages/core/sdk/src/oauth-helpers.ts +55 −3
.changeset/quiet-connection-setup.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Accept Slack bot and user OAuth token envelopes during sign-in and token refresh.
e2e/scenarios/connection-setup-ux.test.ts+137−0
@@ -0,0 +1,137 @@
1+import { randomBytes } from "node:crypto";
2+import { expect } from "@effect/vitest";
3+import { Effect } from "effect";
4+import { composePluginApi } from "@executor-js/api/server";
5+import { connectEmulator } from "@executor-js/emulate";
6+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
7+import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
8+import { variable } from "@executor-js/sdk/http-auth";
9+import { createEmulatorInstance } from "../src/emulator-instance";
10+import { scenario } from "../src/scenario";
11+import { Api, Browser, Target } from "../src/services";
12+import { visit } from "../src/surfaces/browser";
13+
14+const api = composePluginApi([openApiHttpPlugin()] as const);
15+// Each journey has its own real provider state, OAuth app, user and integration.
16+const fixture = Effect.gen(function* () {
17+ const target = yield* Target;
18+ const browser = yield* Browser;
19+ const { client: makeClient } = yield* Api;
20+ const identity = yield* target.newIdentity();
21+ const client = yield* makeClient(api, identity);
22+ const slug = IntegrationSlug.make(`setup-${randomBytes(4).toString("hex")}`);
23+ const app = OAuthClientSlug.make(`${slug}-app`);
24+ const baseUrl = yield* createEmulatorInstance("slack", "connection-setup");
25+ const emulator = yield* Effect.promise(() => connectEmulator({ baseUrl }));
26+ const credential = yield* Effect.promise(() =>
27+ emulator.credentials.mint({
28+ type: "oauth-authorization-code",
29+ redirect_uris: [new URL("/api/oauth/callback", target.baseUrl).toString()],
30+ }),
31+ );
32+ const {
33+ client_id: clientId,
34+ client_secret: clientSecret,
35+ authorization_url: authorizationUrl,
36+ token_url: tokenUrl,
37+ } = credential;
38+ if (!clientId || !clientSecret || !authorizationUrl || !tokenUrl) {
39+ return yield* Effect.die("Slack emulator did not mint an OAuth app");
40+ }
41+ yield* Effect.addFinalizer(() =>
42+ Effect.gen(function* () {
43+ const connections = yield* client.connections.list({ query: { integration: slug } });
44+ for (const connection of connections) {
45+ yield* client.connections
46+ .remove({
47+ params: {
48+ owner: connection.owner,
49+ integration: slug,
50+ name: connection.name,
51+ },
52+ })
53+ .pipe(Effect.ignore);
54+ }
55+ yield* client.oauth
56+ .removeClient({ params: { slug: app }, payload: { owner: "org" } })
57+ .pipe(Effect.ignore);
58+ yield* client.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
59+ }).pipe(Effect.ignore),
60+ );
61+ yield* client.openapi.addSpec({
62+ payload: {
63+ slug,
64+ name: "Team chat",
65+ baseUrl: "https://slack.com",
66+ displayDomain: "slack.com",
67+ spec: {
68+ kind: "blob",
69+ value: JSON.stringify({
70+ openapi: "3.0.3",
71+ info: { title: "Team chat", version: "1" },
72+ // No API operations: only the isolated emulator receives OAuth traffic.
73+ servers: [{ url: "https://slack.com" }],
74+ paths: {},
75+ }),
76+ },
77+ authenticationTemplate: [
78+ {
79+ slug: "token",
80+ type: "apiKey",
81+ headers: { Authorization: ["Bearer ", variable("token")] },
82+ },
83+ { slug: "oauth", kind: "oauth2", authorizationUrl, tokenUrl, scopes: ["users:read"] },
84+ ],
85+ },
86+ });
87+ yield* client.oauth.createClient({
88+ payload: {
89+ slug: app,
90+ owner: "org",
91+ grant: "authorization_code",
92+ clientId,
93+ clientSecret,
94+ authorizationUrl,
95+ tokenUrl,
96+ originIntegration: slug,
97+ },
98+ });
99+ return { target, browser, identity, client, slug, emulator };
100+});
101+
102+scenario(
103+ "Slack OAuth · provider consent saves a connection",
104+ {},
105+ Effect.scoped(
106+ Effect.gen(function* () {
107+ const { browser, identity, slug, client, emulator } = yield* fixture;
108+ yield* browser.session(identity, async ({ page, step }) => {
109+ await step("Sign in with a provider account without naming the connection", async () => {
110+ await visit(page, `/integrations/${slug}?addAccount=1`);
111+ await page.getByRole("tab", { name: "OAuth2", exact: true }).click();
112+ const opened = page.waitForEvent("popup");
113+ await page.getByRole("button", { name: "Connect with OAuth", exact: true }).click();
114+ const popup = await opened;
115+ await popup.waitForURL(/oauth\/v2\/authorize/);
116+ // The hosted emulator renders a root-relative form action. Rebase only
117+ // that provider transport onto this run's isolated instance.
118+ await popup.route("https://emulators.dev/oauth/v2/authorize/callback", (route) =>
119+ route.continue({ url: `${emulator.baseUrl}/oauth/v2/authorize/callback` }),
120+ );
121+ await popup.getByRole("button", { name: /admin/ }).click();
122+ await page
123+ .getByRole("heading", { name: /Add connection/ })
124+ .waitFor({ state: "hidden", timeout: 30_000 });
125+ });
126+ });
127+ const connections = yield* client.connections.list({ query: { integration: slug } });
128+ expect(connections, "the completed callback persists the new account").toHaveLength(1);
129+ expect(connections[0]?.name).toBeTruthy();
130+ const ledger = yield* Effect.promise(() => emulator.ledger.list());
131+ expect(
132+ ledger.some((entry) => entry.method === "POST" && entry.path.includes("oauth.v2.access")),
133+ "the real provider exchanged an authorization code",
134+ ).toBe(true);
135+ }),
136+ ),
137+);
packages/core/sdk/src/oauth-helpers.test.ts+111−0
@@ -1838,3 +1838,114 @@ describe("OAuth2Error tagging", () => { · 1837 unmodified lines
18381838 });
18391839 });
18401840 });
1841+
1842+// Slack labels bearer credentials by actor type. The same envelope is used
1843+// during authorization-code exchange and refresh-token rotation.
1844+describe("Slack token envelopes", () => {
1845+ const grants = [
1846+ {
1847+ label: "bot",
1848+ body: {
1849+ ok: true,
1850+ access_token: "bot-token",
1851+ token_type: "bot",
1852+ scope: "channels:read,chat:write",
1853+ refresh_token: "bot-refresh",
1854+ expires_in: 3600,
1855+ },
1856+ expected: {
1857+ access_token: "bot-token",
1858+ token_type: "bearer",
1859+ scope: "channels:read chat:write",
1860+ refresh_token: "bot-refresh",
1861+ expires_in: 3600,
1862+ },
1863+ },
1864+ {
1865+ label: "user",
1866+ body: {
1867+ ok: true,
1868+ access_token: "user-token",
1869+ token_type: "user",
1870+ scope: "users:read,users:read.email",
1871+ refresh_token: "user-refresh",
1872+ expires_in: 3600,
1873+ },
1874+ expected: {
1875+ access_token: "user-token",
1876+ token_type: "bearer",
1877+ scope: "users:read users:read.email",
1878+ refresh_token: "user-refresh",
1879+ expires_in: 3600,
1880+ },
1881+ },
1882+ {
1883+ label: "nested user",
1884+ body: {
1885+ ok: true,
1886+ authed_user: {
1887+ access_token: "nested-user-token",
1888+ token_type: "user",
1889+ scope: "users:read,chat:write",
1890+ refresh_token: "nested-refresh",
1891+ expires_in: 3600,
1892+ },
1893+ },
1894+ expected: {
1895+ access_token: "nested-user-token",
1896+ token_type: "bearer",
1897+ scope: "users:read chat:write",
1898+ refresh_token: "nested-refresh",
1899+ expires_in: 3600,
1900+ },
1901+ },
1902+ ];
1903+ for (const grant of grants) {
1904+ it.effect(`exchanges a Slack ${grant.label} grant`, () =>
1905+ withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) =>
1906+ Effect.gen(function* () {
1907+ const result = yield* exchangeAuthorizationCode({
1908+ tokenUrl,
1909+ clientId: "cid",
1910+ clientSecret: "secret",
1911+ redirectUrl: "https://app.example/callback",
1912+ codeVerifier: "verifier",
1913+ code: "code",
1914+ });
1915+ expect(result).toMatchObject(grant.expected);
1916+ }),
1917+ ),
1918+ );
1919+ it.effect(`refreshes a Slack ${grant.label} grant`, () =>
1920+ withTokenEndpoint(tokenResponse(grant.body), ({ tokenUrl }) =>
1921+ Effect.gen(function* () {
1922+ const result = yield* refreshAccessToken({
1923+ tokenUrl,
1924+ clientId: "cid",
1925+ clientSecret: "secret",
1926+ refreshToken: "old-refresh",
1927+ });
1928+ expect(result).toMatchObject(grant.expected);
1929+ }),
1930+ ),
1931+ );
1932+ }
1933+ it.effect("still rejects unsupported token types in an otherwise successful envelope", () =>
1934+ withTokenEndpoint(
1935+ tokenResponse({ ok: true, access_token: "token", token_type: "mac" }),
1936+ ({ tokenUrl }) =>
1937+ Effect.gen(function* () {
1938+ const exit = yield* Effect.exit(
1939+ exchangeAuthorizationCode({
1940+ tokenUrl,
1941+ clientId: "cid",
1942+ redirectUrl: "https://app.example/callback",
1943+ codeVerifier: "verifier",
1944+ code: "code",
1945+ }),
1946+ );
1947+ expect(Exit.isFailure(exit)).toBe(true);
1948+ }),
1949+ ),
1950+ );
1951+});
packages/core/sdk/src/oauth-helpers.ts+55−3
@@ -1117,16 +1117,68 @@ const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> · 1116 unmodified lines
11171117 };
11181118 };
11191119
1120+const SlackGrant = Schema.Struct({
1121+ access_token: Schema.optional(Schema.String),
1122+ token_type: Schema.optional(Schema.Literals(["bot", "user", "Bearer", "bearer"])),
1123+ refresh_token: Schema.optional(Schema.String),
1124+ expires_in: Schema.optional(Schema.Number),
1125+ scope: Schema.optional(Schema.String),
1126+});
1127+const decodeSlackEnvelope = Schema.decodeUnknownOption(
1128+ Schema.Struct({
1129+ ...SlackGrant.fields,
1130+ ok: Schema.Literal(true),
1131+ authed_user: Schema.optional(SlackGrant),
1132+ }),
1133+);
1134+
1135+/** Slack's `bot` and `user` values identify the account, not an HTTP auth
1136+ * scheme. Project its successful envelope to an RFC 6749 bearer grant before
1137+ * oauth4webapi validates it. User-only grants may live entirely in authed_user;
1138+ * never replace a populated top-level grant with another account's grant. */
1139+const normalizeSlackTokenEnvelope = async (response: Response): Promise<Response> => {
1140+ const decoded = decodeSlackEnvelope(await safeJsonFromResponse(response));
1141+ if (Option.isNone(decoded)) return response;
1142+ const envelope = decoded.value;
1143+ const user = envelope.authed_user;
1144+ const grant =
1145+ user?.access_token !== undefined &&
1146+ (envelope.access_token === undefined || !envelope.scope?.trim())
1147+ ? user
1148+ : envelope;
1149+ if (grant.access_token === undefined) return response;
1150+ const scope = grant.scope
1151+ ?.split(/[\s,]+/)
1152+ .filter(Boolean)
1153+ .join(" ");
1154+ return new Response(
1155+ JSON.stringify({
1156+ access_token: grant.access_token,
1157+ token_type:
1158+ grant.token_type === "bot" || grant.token_type === "user" ? "Bearer" : grant.token_type,
1159+ refresh_token: grant.refresh_token,
1160+ expires_in: grant.expires_in,
1161+ ...(scope ? { scope } : {}),
1162+ }),
1163+ {
1164+ status: response.status,
1165+ statusText: response.statusText,
1166+ headers: response.headers,
1167+ },
1168+ );
1169+};
1170+
11201171 const processTokenEndpointResponse = async (
11211172 as: oauth.AuthorizationServer,
11221173 client: oauth.Client,
11231174 response: Response,
11241175 ): Promise<OAuth2TokenResponse> => {
11251176 const stripped = await stripIdToken(response);
1126− const providerUserGrant = await nestedAuthedUserGrant(stripped.response);
1177+ const normalizedResponse = await normalizeSlackTokenEnvelope(stripped.response);
1178+ const providerUserGrant = await nestedAuthedUserGrant(normalizedResponse);
11271179 const parsed = tokenResponseFrom(
11281180 as,
1129− await oauth.processGenericTokenEndpointResponse(as, client, stripped.response),
1181+ await oauth.processGenericTokenEndpointResponse(as, client, normalizedResponse),
11301182 );
11311183 const token =
11321184 parsed.scope === undefined && providerUserGrant !== undefined
@@ -1442,7 +1494,7 @@ export const refreshAccessToken = ( · 309 unmodified lines
14421494 const result = await oauth.processRefreshTokenResponse(
14431495 as,
14441496 client,
1445− (await stripIdToken(response)).response,
1497+ await normalizeSlackTokenEnvelope((await stripIdToken(response)).response),
14461498 );
14471499 return tokenResponseFrom(as, result);
14481500 },

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t