diff --git a/apps/backend/prisma/migrations/20240919223009_x_and_slack_oauth/migration.sql b/apps/backend/prisma/migrations/20240919223009_x_and_slack_oauth/migration.sql new file mode 100644 index 000000000..6c2cbd33e --- /dev/null +++ b/apps/backend/prisma/migrations/20240919223009_x_and_slack_oauth/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "StandardOAuthProviderType" ADD VALUE 'X'; +ALTER TYPE "StandardOAuthProviderType" ADD VALUE 'SLACK'; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 533a6586e..f3e67de98 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -568,6 +568,8 @@ enum StandardOAuthProviderType { BITBUCKET LINKEDIN APPLE + X + SLACK } model OAuthToken { diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index b6b758fe6..7922fb9ff 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -14,7 +14,9 @@ import { GoogleProvider } from "./providers/google"; import { LinkedInProvider } from "./providers/linkedin"; import { MicrosoftProvider } from "./providers/microsoft"; import { MockProvider } from "./providers/mock"; +import { SlackProvider } from "./providers/slack"; import { SpotifyProvider } from "./providers/spotify"; +import { XProvider } from "./providers/x"; const _providers = { github: GithubProvider, @@ -27,6 +29,8 @@ const _providers = { apple: AppleProvider, bitbucket: BitbucketProvider, linkedin: LinkedInProvider, + x: XProvider, + slack: SlackProvider, } as const; const mockProvider = MockProvider; diff --git a/apps/backend/src/oauth/providers/slack.tsx b/apps/backend/src/oauth/providers/slack.tsx new file mode 100644 index 000000000..0b986ee43 --- /dev/null +++ b/apps/backend/src/oauth/providers/slack.tsx @@ -0,0 +1,46 @@ +import { OAuthBaseProvider, TokenSet } from "./base"; +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; + +export class SlackProvider extends OAuthBaseProvider { + private constructor( + ...args: ConstructorParameters + ) { + super(...args); + } + + static async create(options: { clientId: string, clientSecret: string }) { + return new SlackProvider( + ...(await OAuthBaseProvider.createConstructorArgs({ + issuer: "https://slack.com", + authorizationEndpoint: "https://slack.com/oauth/v2/authorize", + tokenEndpoint: "https://slack.com/api/oauth.v2.access", + redirectUri: + getEnvVariable("STACK_BASE_URL") + + "/api/v1/auth/oauth/callback/slack", + baseScope: "", + authorizationExtraParams: { + user_scope: "email,profile,openid", + }, + ...options, + })) + ); + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + const userInfo = await fetch( + "https://slack.com/api/openid.connect.userInfo", { + headers: { + Authorization: `Bearer ${tokenSet.accessToken}`, + } + } + ).then(res => res.json()); + return validateUserInfo({ + accountId: userInfo.sub?.toString(), + displayName: userInfo.name, + email: userInfo.email, + profileImageUrl: userInfo.picture, + emailVerified: userInfo.email_verified, + }); + } +} \ No newline at end of file diff --git a/apps/backend/src/oauth/providers/x.tsx b/apps/backend/src/oauth/providers/x.tsx new file mode 100644 index 000000000..6a7936d34 --- /dev/null +++ b/apps/backend/src/oauth/providers/x.tsx @@ -0,0 +1,43 @@ +import { OAuthBaseProvider, TokenSet } from "./base"; +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; + +export class XProvider extends OAuthBaseProvider { + private constructor( + ...args: ConstructorParameters + ) { + super(...args); + } + + static async create(options: { clientId: string, clientSecret: string }) { + return new XProvider( + ...(await OAuthBaseProvider.createConstructorArgs({ + issuer: "https://twitter.com", + authorizationEndpoint: "https://twitter.com/i/oauth2/authorize", + tokenEndpoint: "https://api.x.com/2/oauth2/token", + redirectUri: getEnvVariable("STACK_BASE_URL") + "/api/v1/auth/oauth/callback/x", + baseScope: "users.read offline.access tweet.read", + ...options, + })) + ); + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + const { data: userInfo } = await fetch( + "https://api.x.com/2/users/me?user.fields=id,name,profile_image_url", + { + headers: { + Authorization: `Bearer ${tokenSet.accessToken}`, + }, + } + ).then((res) => res.json()); + + return validateUserInfo({ + accountId: userInfo?.id?.toString(), + displayName: userInfo.name || userInfo.username, + // email: undefined, // There is no way of getting email from X Oauth2.0 API + profileImageUrl: userInfo.profile_image_url as any, + emailVerified: false, + }); + } +} \ No newline at end of file diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 1b57def5a..49fe458aa 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -27,6 +27,8 @@ function toTitle(id: string) { apple: "Apple", bitbucket: "Bitbucket", linkedin: "LinkedIn", + x: "X", + slack: "Slack", }[id]; } diff --git a/apps/oauth-mock-server/src/index.ts b/apps/oauth-mock-server/src/index.ts index 5510654d8..3ac8fa03a 100644 --- a/apps/oauth-mock-server/src/index.ts +++ b/apps/oauth-mock-server/src/index.ts @@ -11,6 +11,8 @@ const mockedProviders = [ "discord", "gitlab", "bitbucket", + "x", + "slack", ]; const configuration: Configuration = { diff --git a/docs/fern/docs/pages/getting-started/production.mdx b/docs/fern/docs/pages/getting-started/production.mdx index ff8a03c87..a24cf1a00 100644 --- a/docs/fern/docs/pages/getting-started/production.mdx +++ b/docs/fern/docs/pages/getting-started/production.mdx @@ -63,6 +63,16 @@ To use your own OAuth provider setups in production, follow these steps for each Callback URL: `https://api.stack-auth.com/api/v1/auth/oauth/callback/linkedin` + + [X OAuth Setup Guide](https://developer.x.com/en/docs/apps/overview) + Callback URL: + `https://api.stack-auth.com/api/v1/auth/oauth/callback/x` + + + [X OAuth Setup Guide](https://api.slack.com/authentication/oauth-v2) + Callback URL: + `https://api.stack-auth.com/api/v1/auth/oauth/callback/slack` + 2. **Enter OAuth Credentials**: Go to the `Auth Methods` section in the Stack dashboard, open the provider's settings, switch from shared keys to custom keys, and enter the client ID and client secret. diff --git a/packages/stack-shared/src/utils/oauth.tsx b/packages/stack-shared/src/utils/oauth.tsx index 5f4b15006..24b9f925c 100644 --- a/packages/stack-shared/src/utils/oauth.tsx +++ b/packages/stack-shared/src/utils/oauth.tsx @@ -1,4 +1,4 @@ -export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple"] as const; +export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple", "x", "slack"] as const; // No more shared providers should be added except for special cases export const sharedProviders = ["google", "github", "microsoft", "spotify"] as const; export const allProviders = standardProviders; diff --git a/packages/stack/src/components/oauth-button.tsx b/packages/stack/src/components/oauth-button.tsx index 05d19b108..491cfdc14 100644 --- a/packages/stack/src/components/oauth-button.tsx +++ b/packages/stack/src/components/oauth-button.tsx @@ -140,6 +140,39 @@ function AppleIcon({ iconSize } : { iconSize: number} ) { ); } +function XIcon({ iconSize } : { iconSize: number} ) { + return ( + + + + ); +} + +function SlackIcon({ iconSize } : { iconSize: number} ) { + return ( + + + + + + + + + ); +} + const changeColor = (c: Color, value: number) => { if (c.isLight()) { value = -value; @@ -261,6 +294,24 @@ export function OAuthButton({ }; break; } + case 'x': { + style = { + backgroundColor: "#000", + textColor: "#fff", + name: "X", + icon: , + }; + break; + } + case 'slack': { + style = { + backgroundColor: "#611f69", + textColor: "#fff", + name: "Slack", + icon: , + }; + break; + } default: { style = { name: provider,