From 08c0de576242deedc4873221177049ec922a16ed Mon Sep 17 00:00:00 2001 From: Robin Ferch Date: Wed, 25 Jun 2025 17:23:33 +0200 Subject: [PATCH] feat: add Twitch as oauth provider --- apps/backend/prisma/schema.prisma | 1 + apps/backend/src/oauth/index.tsx | 2 + apps/backend/src/oauth/providers/twitch.tsx | 46 +++++ .../[projectId]/auth-methods/providers.tsx | 1 + packages/stack-shared/src/utils/oauth.tsx | 2 +- .../stack-ui/src/components/brand-icons.tsx | 171 ++++++++++++------ .../template/src/components/oauth-button.tsx | 9 + 7 files changed, 175 insertions(+), 57 deletions(-) create mode 100644 apps/backend/src/oauth/providers/twitch.tsx diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index a6864871f..db3edaf6e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -405,6 +405,7 @@ enum StandardOAuthProviderType { LINKEDIN APPLE X + TWITCH } model OAuthToken { diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index f8a48056b..20d14f094 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -17,6 +17,7 @@ import { MicrosoftProvider } from "./providers/microsoft"; import { MockProvider } from "./providers/mock"; import { SpotifyProvider } from "./providers/spotify"; import { XProvider } from "./providers/x"; +import { TwitchProvider } from "./providers/twitch"; const _providers = { github: GithubProvider, @@ -30,6 +31,7 @@ const _providers = { bitbucket: BitbucketProvider, linkedin: LinkedInProvider, x: XProvider, + twitch: TwitchProvider, } as const; const mockProvider = MockProvider; diff --git a/apps/backend/src/oauth/providers/twitch.tsx b/apps/backend/src/oauth/providers/twitch.tsx new file mode 100644 index 000000000..18190d629 --- /dev/null +++ b/apps/backend/src/oauth/providers/twitch.tsx @@ -0,0 +1,46 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { OAuthBaseProvider, TokenSet } from "./base"; + +export class TwitchProvider extends OAuthBaseProvider { + private constructor( + ...args: ConstructorParameters + ) { + super(...args); + } + + static async create(options: { + clientId: string, + clientSecret: string, + }) { + return new TwitchProvider(...await OAuthBaseProvider.createConstructorArgs({ + issuer: "https://id.twitch.tv", + authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", + tokenEndpoint: "https://id.twitch.tv/oauth2/token", + tokenEndpointAuthMethod: "client_secret_post", + redirectUri: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") + "/api/v1/auth/oauth/callback/twitch", + baseScope: "user:read:email", + ...options, + })); + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + const info = await fetch("https://api.twitch.tv/helix/users", { + headers: { + Authorization: `Bearer ${tokenSet.accessToken}`, + "Client-Id": this.oauthClient.client_id as string, + }, + }).then((res) => res.json()); + + + const userInfo = info.data?.[0]; + + return validateUserInfo({ + accountId: userInfo.id, + displayName: userInfo.display_name, + email: userInfo.email, + profileImageUrl: userInfo.profile_image_url, + emailVerified: true, + }); + } +} 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 71bd9bd08..bb747258b 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 @@ -40,6 +40,7 @@ function toTitle(id: string) { apple: "Apple", bitbucket: "Bitbucket", linkedin: "LinkedIn", + twitch: "Twitch", x: "X", }[id]; } diff --git a/packages/stack-shared/src/utils/oauth.tsx b/packages/stack-shared/src/utils/oauth.tsx index df422db93..e8972bfe2 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", "x"] as const; +export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple", "x", "twitch"] 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-ui/src/components/brand-icons.tsx b/packages/stack-ui/src/components/brand-icons.tsx index a0d5b7989..520e7167f 100644 --- a/packages/stack-ui/src/components/brand-icons.tsx +++ b/packages/stack-ui/src/components/brand-icons.tsx @@ -1,62 +1,70 @@ import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -export function Google({ iconSize } : { iconSize: number} ) { +export function Google({ iconSize }: { iconSize: number }) { return ( - - - - - + + + + + ); } -export function Facebook({ iconSize } : { iconSize: number} ) { +export function Facebook({ iconSize }: { iconSize: number }) { return ( - + ); } -export function GitHub({ iconSize } : { iconSize: number} ) { +export function GitHub({ iconSize }: { iconSize: number }) { return ( - + ); } -export function Microsoft({ iconSize } : { iconSize: number} ) { +export function Microsoft({ iconSize }: { iconSize: number }) { return ( {"MS-SymbolLockup"} - - - - + + + + ); } -export function Spotify({ iconSize } : { iconSize: number} ) { +export function Spotify({ iconSize }: { iconSize: number }) { return ( - + ); } -export function Discord({ iconSize } : { iconSize: number} ) { +export function Discord({ iconSize }: { iconSize: number }) { return ( - + ); } -export function Gitlab({ iconSize } : { iconSize: number} ) { +export function Gitlab({ iconSize }: { iconSize: number }) { return ( - - - - - - - + + + + + + + ); @@ -95,19 +117,23 @@ export function Bitbucket({ iconSize }: { iconSize: number }) { y1="13.818%" y2="78.776%" > - - + + - - - + + + ); } -export function LinkedIn({ iconSize } : { iconSize: number} ) { +export function LinkedIn({ iconSize }: { iconSize: number }) { return ( - - - + + + ); } -export function Apple({ iconSize } : { iconSize: number} ) { +export function Apple({ iconSize }: { iconSize: number }) { return ( - - - + + + ); } -export function X({ iconSize } : { iconSize: number} ) { +export function X({ iconSize }: { iconSize: number }) { return ( - - + + + + ); +} + +export function Twitch({ iconSize }: { iconSize: number }) { + return ( + + + + + + + + + + + ); } @@ -146,42 +200,45 @@ export function Mapping({ provider, iconSize, }: { - provider: string, - iconSize: number, + provider: string, + iconSize: number, }) { switch (provider) { case "google": { - return ; + return ; } case "github": { - return ; + return ; } case "facebook": { - return ; + return ; } case "microsoft": { - return ; + return ; } case "spotify": { - return ; + return ; } case "discord": { - return ; + return ; } case "gitlab": { - return ; + return ; } case "bitbucket": { - return ; + return ; } case "linkedin": { - return ; + return ; } case "apple": { - return ; + return ; } case "x": { - return ; + return ; + } + case "twitch": { + return ; } default: { throw new StackAssertionError(`Icon not found for provider: ${provider}`); @@ -202,6 +259,7 @@ export function toTitle(id: string) { bitbucket: "Bitbucket", linkedin: "LinkedIn", x: "X", + twitch: "Twitch" }[id] || throwErr(`Unknown provider: ${id}`); } @@ -215,4 +273,5 @@ export const BRAND_COLORS: Record = { linkedin: '#0A66C2', x: '#000000', apple: '#000000', + twitch: '#ffffff' }; diff --git a/packages/template/src/components/oauth-button.tsx b/packages/template/src/components/oauth-button.tsx index 52bde4147..f7a964385 100644 --- a/packages/template/src/components/oauth-button.tsx +++ b/packages/template/src/components/oauth-button.tsx @@ -147,6 +147,15 @@ export function OAuthButton({ }; break; } + case 'twitch': { + style = { + backgroundColor: "#6441a5", + textColor: "#fff", + name: "Twitch", + icon: , + }; + break; + } default: { style = { name: provider,