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 | const processTokenEndpointResponse = async ( | 1171 | const 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 !== undefined | 1184 | 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 | }, |