Unified · Split
| @@ -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. | |
| @@ -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 | +); | |
| @@ -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. | |
| 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 | +}); | |
| @@ -1117,16 +1117,68 @@ const stripIdToken = async (response: Response): Promise<StrippedTokenResponse> · 1116 unmodified lines | ||
| 1117 | 1117 | }; |
| 1118 | 1118 | }; |
| 1119 | 1119 | |
| 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 | + | |
| 1120 | 1171 | const processTokenEndpointResponse = async ( |
| 1121 | 1172 | as: oauth.AuthorizationServer, |
| 1122 | 1173 | client: oauth.Client, |
| 1123 | 1174 | response: Response, |
| 1124 | 1175 | ): Promise<OAuth2TokenResponse> => { |
| 1125 | 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 | 1179 | const parsed = tokenResponseFrom( |
| 1128 | 1180 | as, |
| 1129 | − await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), | |
| 1181 | + await oauth.processGenericTokenEndpointResponse(as, client, normalizedResponse), | |
| 1130 | 1182 | ); |
| 1131 | 1183 | const token = |
| 1132 | 1184 | parsed.scope === undefined && providerUserGrant !== undefined |
| @@ -1442,7 +1494,7 @@ export const refreshAccessToken = ( · 309 unmodified lines | ||
| 1442 | 1494 | const result = await oauth.processRefreshTokenResponse( |
| 1443 | 1495 | as, |
| 1444 | 1496 | client, |
| 1445 | − (await stripIdToken(response)).response, | |
| 1497 | + await normalizeSlackTokenEnvelope((await stripIdToken(response)).response), | |
| 1446 | 1498 | ); |
| 1447 | 1499 | return tokenResponseFrom(as, result); |
| 1448 | 1500 | }, |