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
5Accept 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 @@
1import { randomBytes } from "node:crypto";
2import { expect } from "@effect/vitest";
3import { Effect } from "effect";
4import { composePluginApi } from "@executor-js/api/server";
5import { connectEmulator } from "@executor-js/emulate";
6import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
7import { IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
8import { variable } from "@executor-js/sdk/http-auth";
9import { createEmulatorInstance } from "../src/emulator-instance";
10import { scenario } from "../src/scenario";
11import { Api, Browser, Target } from "../src/services";
12import { visit } from "../src/surfaces/browser";
13
14const api = composePluginApi([openApiHttpPlugin()] as const);
15// Each journey has its own real provider state, OAuth app, user and integration.
16const 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
102scenario(
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
1838 });1838 });
1839 });1839 });
1840});1840});
1841
1842// Slack labels bearer credentials by actor type. The same envelope is used
1843// during authorization-code exchange and refresh-token rotation.
1844describe("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
1117 };1117 };
1118};1118};
1119 1119
1120const 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});
1127const 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. */
1139const 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
1120const processTokenEndpointResponse = async (1171const processTokenEndpointResponse = async (
1121 as: oauth.AuthorizationServer,1172 as: oauth.AuthorizationServer,
1122 client: oauth.Client,1173 client: oauth.Client,
1123 response: Response,1174 response: Response,
1124): Promise<OAuth2TokenResponse> => {1175): Promise<OAuth2TokenResponse> => {
1125 const stripped = await stripIdToken(response);1176 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);
1127 const parsed = tokenResponseFrom(1179 const parsed = tokenResponseFrom(
1128 as,1180 as,
1129 await oauth.processGenericTokenEndpointResponse(as, client, stripped.response),1181 await oauth.processGenericTokenEndpointResponse(as, client, normalizedResponse),
1130 );1182 );
1131 const token =1183 const token =
1132 parsed.scope === undefined && providerUserGrant !== undefined1184 parsed.scope === undefined && providerUserGrant !== undefined
@@ -1442,7 +1494,7 @@ export const refreshAccessToken = ( · 309 unmodified lines
1442 const result = await oauth.processRefreshTokenResponse(1494 const result = await oauth.processRefreshTokenResponse(
1443 as,1495 as,
1444 client,1496 client,
1445 (await stripIdToken(response)).response,1497 await normalizeSlackTokenEnvelope((await stripIdToken(response)).response),
1446 );1498 );
1447 return tokenResponseFrom(as, result);1499 return tokenResponseFrom(as, result);
1448 },1500 },

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