Unified · Split
| @@ -99,6 +99,14 @@ const tokenResponse = · 98 unmodified lines | |||
| 99 | () => | 99 | () => |
| 100 | json(200, body); | 100 | json(200, body); |
| 101 | 101 | ||
| 102 | const tokenResponseFetch = | ||
| 103 | (body: unknown): typeof globalThis.fetch => | ||
| 104 | async () => | ||
| 105 | new Response(JSON.stringify(body), { | ||
| 106 | status: 200, | ||
| 107 | headers: { "content-type": "application/json" }, | ||
| 108 | }); | ||
| 109 | |||
| 102 | // --------------------------------------------------------------------------- | 110 | // --------------------------------------------------------------------------- |
| 103 | // PKCE | 111 | // PKCE |
| 104 | // --------------------------------------------------------------------------- | 112 | // --------------------------------------------------------------------------- |
| @@ -575,6 +583,46 @@ describe("exchangeAuthorizationCode", () => { · 470 unmodified lines | |||
| 575 | ), | 583 | ), |
| 576 | ); | 584 | ); |
| 577 | 585 | ||
| 586 | it.effect("normalizes Slack's comma-delimited top-level scopes", () => | ||
| 587 | Effect.gen(function* () { | ||
| 588 | const result = yield* exchangeAuthorizationCode({ | ||
| 589 | tokenUrl: "https://slack.com/api/oauth.v2.user.access", | ||
| 590 | clientId: "cid", | ||
| 591 | clientSecret: "csecret", | ||
| 592 | redirectUrl: "https://app.example.com/cb", | ||
| 593 | codeVerifier: "verifier", | ||
| 594 | code: "abc", | ||
| 595 | fetch: tokenResponseFetch({ | ||
| 596 | access_token: "xoxp-user-token", | ||
| 597 | token_type: "Bearer", | ||
| 598 | scope: "channels:read,chat:write,reactions:read", | ||
| 599 | }), | ||
| 600 | }); | ||
| 601 | |||
| 602 | expect(result.scope).toBe("channels:read chat:write reactions:read"); | ||
| 603 | }), | ||
| 604 | ); | ||
| 605 | |||
| 606 | it.effect("preserves commas in scope tokens from non-Slack providers", () => | ||
| 607 | Effect.gen(function* () { | ||
| 608 | const result = yield* exchangeAuthorizationCode({ | ||
| 609 | tokenUrl: "https://oauth.example.com/token", | ||
| 610 | clientId: "cid", | ||
| 611 | clientSecret: "csecret", | ||
| 612 | redirectUrl: "https://app.example.com/cb", | ||
| 613 | codeVerifier: "verifier", | ||
| 614 | code: "abc", | ||
| 615 | fetch: tokenResponseFetch({ | ||
| 616 | access_token: "provider-token", | ||
| 617 | token_type: "Bearer", | ||
| 618 | scope: "scope,with-comma other.scope", | ||
| 619 | }), | ||
| 620 | }); | ||
| 621 | |||
| 622 | expect(result.scope).toBe("scope,with-comma other.scope"); | ||
| 623 | }), | ||
| 624 | ); | ||
| 625 | |||
| 578 | it.effect("keeps a standard top-level scope ahead of nested provider metadata", () => | 626 | it.effect("keeps a standard top-level scope ahead of nested provider metadata", () => |
| 579 | withTokenEndpoint( | 627 | withTokenEndpoint( |
| 580 | tokenResponse({ | 628 | tokenResponse({ |
| @@ -856,6 +904,24 @@ describe("exchangeClientCredentials", () => { · 275 unmodified lines | |||
| 856 | }); | 904 | }); |
| 857 | 905 | ||
| 858 | describe("refreshAccessToken", () => { | 906 | describe("refreshAccessToken", () => { |
| 907 | it.effect("normalizes Slack's comma-delimited scopes on refresh", () => | ||
| 908 | Effect.gen(function* () { | ||
| 909 | const result = yield* refreshAccessToken({ | ||
| 910 | tokenUrl: "https://slack.com/api/oauth.v2.user.access", | ||
| 911 | clientId: "cid", | ||
| 912 | clientSecret: "csecret", | ||
| 913 | refreshToken: "refresh-token", | ||
| 914 | fetch: tokenResponseFetch({ | ||
| 915 | access_token: "xoxp-refreshed-token", | ||
| 916 | token_type: "Bearer", | ||
| 917 | scope: "channels:read,chat:write,reactions:read", | ||
| 918 | }), | ||
| 919 | }); | ||
| 920 | |||
| 921 | expect(result.scope).toBe("channels:read chat:write reactions:read"); | ||
| 922 | }), | ||
| 923 | ); | ||
| 924 | |||
| 859 | it.effect("posts grant_type=refresh_token with the refresh token", () => | 925 | it.effect("posts grant_type=refresh_token with the refresh token", () => |
| 860 | withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => | 926 | withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => |
| 861 | Effect.gen(function* () { | 927 | Effect.gen(function* () { |
| @@ -552,12 +552,34 @@ const pickClientAuth = ( · 551 unmodified lines | |||
| 552 | : oauth.ClientSecretPost(clientSecret); | 552 | : oauth.ClientSecretPost(clientSecret); |
| 553 | }; | 553 | }; |
| 554 | 554 | ||
| 555 | const tokenResponseFrom = (r: oauth.TokenEndpointResponse): OAuth2TokenResponse => ({ | 555 | const normalizedTokenScope = ( |
| 556 | as: oauth.AuthorizationServer, | ||
| 557 | scope: string | undefined, | ||
| 558 | ): string | undefined => { | ||
| 559 | if (scope === undefined || scope.trim().length === 0) return undefined; | ||
| 560 | const tokenEndpoint = typeof as.token_endpoint === "string" ? URL.parse(as.token_endpoint) : null; | ||
| 561 | const isSlackTokenEndpoint = | ||
| 562 | tokenEndpoint?.hostname.toLowerCase() === "slack.com" && | ||
| 563 | (tokenEndpoint.pathname === "/api/oauth.v2.access" || | ||
| 564 | tokenEndpoint.pathname === "/api/oauth.v2.user.access"); | ||
| 565 | if (!isSlackTokenEndpoint) return scope; | ||
| 566 | |||
| 567 | const normalized = scope | ||
| 568 | .split(/[\s,]+/) | ||
| 569 | .filter(Boolean) | ||
| 570 | .join(" "); | ||
| 571 | return normalized.length > 0 ? normalized : undefined; | ||
| 572 | }; | ||
| 573 | |||
| 574 | const tokenResponseFrom = ( | ||
| 575 | as: oauth.AuthorizationServer, | ||
| 576 | r: oauth.TokenEndpointResponse, | ||
| 577 | ): OAuth2TokenResponse => ({ | ||
| 556 | access_token: r.access_token, | 578 | access_token: r.access_token, |
| 557 | token_type: r.token_type, | 579 | token_type: r.token_type, |
| 558 | refresh_token: r.refresh_token, | 580 | refresh_token: r.refresh_token, |
| 559 | expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, | 581 | expires_in: typeof r.expires_in === "number" ? r.expires_in : undefined, |
| 560 | scope: typeof r.scope === "string" && r.scope.trim().length > 0 ? r.scope : undefined, | 582 | scope: normalizedTokenScope(as, typeof r.scope === "string" ? r.scope : undefined), |
| 561 | }); | 583 | }); |
| 562 | 584 | ||
| 563 | const JwtClaims = Schema.Record(Schema.String, Schema.Unknown); | 585 | const JwtClaims = Schema.Record(Schema.String, Schema.Unknown); |
| @@ -694,6 +716,7 @@ const processTokenEndpointResponse = async ( · 130 unmodified lines | |||
| 694 | const stripped = await stripIdToken(response); | 716 | const stripped = await stripIdToken(response); |
| 695 | const providerUserGrant = await nestedAuthedUserGrant(stripped.response); | 717 | const providerUserGrant = await nestedAuthedUserGrant(stripped.response); |
| 696 | const parsed = tokenResponseFrom( | 718 | const parsed = tokenResponseFrom( |
| 719 | as, | ||
| 697 | await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), | 720 | await oauth.processGenericTokenEndpointResponse(as, client, stripped.response), |
| 698 | ); | 721 | ); |
| 699 | const token = | 722 | const token = |
| @@ -839,7 +862,7 @@ export const exchangeClientCredentials = ( · 139 unmodified lines | |||
| 839 | ), | 862 | ), |
| 840 | ); | 863 | ); |
| 841 | const result = await oauth.processClientCredentialsResponse(as, client, response); | 864 | const result = await oauth.processClientCredentialsResponse(as, client, response); |
| 842 | return tokenResponseFrom(result); | 865 | return tokenResponseFrom(as, result); |
| 843 | }, | 866 | }, |
| 844 | catch: (cause) => cause, | 867 | catch: (cause) => cause, |
| 845 | }).pipe( | 868 | }).pipe( |
| @@ -918,7 +941,7 @@ export const refreshAccessToken = ( · 72 unmodified lines | |||
| 918 | client, | 941 | client, |
| 919 | (await stripIdToken(response)).response, | 942 | (await stripIdToken(response)).response, |
| 920 | ); | 943 | ); |
| 921 | return tokenResponseFrom(result); | 944 | return tokenResponseFrom(as, result); |
| 922 | }, | 945 | }, |
| 923 | catch: (cause) => cause, | 946 | catch: (cause) => cause, |
| 924 | }).pipe( | 947 | }).pipe( |