From 9d154456b5eacef660f8c481f5f2b41ea82ebf5b Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 22 Jul 2024 17:40:39 +0200 Subject: [PATCH 1/7] feat: oauth --- .env.example | 12 + package.json | 1 + pnpm-lock.yaml | 10 + prisma/schema/auth.prisma | 9 + prisma/schema/user.prisma | 17 +- src/app/oauth/[provider]/page.tsx | 13 ++ src/env.mjs | 24 ++ src/features/auth/OAuthLogin.tsx | 90 ++++++++ src/features/auth/PageLogin.tsx | 8 + src/features/auth/PageOAuthCallback.tsx | 57 +++++ src/features/auth/PageRegister.tsx | 8 + src/lib/oauth/config.ts | 40 ++++ src/locales/ar/auth.json | 3 + src/locales/en/auth.json | 3 + src/locales/fr/auth.json | 3 + src/locales/sw/auth.json | 3 + src/server/config/oauth/index.ts | 14 ++ src/server/config/oauth/providers/discord.ts | 85 +++++++ src/server/config/oauth/providers/github.ts | 121 ++++++++++ src/server/config/oauth/providers/google.ts | 88 +++++++ src/server/config/oauth/utils.ts | 25 ++ src/server/router.ts | 2 + src/server/routers/oauth.tsx | 231 +++++++++++++++++++ 23 files changed, 859 insertions(+), 8 deletions(-) create mode 100644 src/app/oauth/[provider]/page.tsx create mode 100644 src/features/auth/OAuthLogin.tsx create mode 100644 src/features/auth/PageOAuthCallback.tsx create mode 100644 src/lib/oauth/config.ts create mode 100644 src/server/config/oauth/index.ts create mode 100644 src/server/config/oauth/providers/discord.ts create mode 100644 src/server/config/oauth/providers/github.ts create mode 100644 src/server/config/oauth/providers/google.ts create mode 100644 src/server/config/oauth/utils.ts create mode 100644 src/server/routers/oauth.tsx diff --git a/.env.example b/.env.example index 612184e9e..9a2eb1f44 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,18 @@ NEXT_PUBLIC_IS_DEMO="false" # DATABASE DATABASE_URL="postgres://${DOCKER_DATABASE_USERNAME}:${DOCKER_DATABASE_PASSWORD}@localhost:${DOCKER_DATABASE_PORT}/${DOCKER_DATABASE_NAME}" +# GITHUB +GITHUB_CLIENT_ID="REPLACE ME" +GITHUB_CLIENT_SECRET="REPLACE ME" + +# GOOGLE +GOOGLE_CLIENT_ID="REPLACE ME" +GOOGLE_CLIENT_SECRET="REPLACE ME" + +# DISCORD +DISCORD_CLIENT_ID="REPLACE ME" +DISCORD_CLIENT_SECRET="REPLACE ME" + # EMAILS EMAIL_SERVER="smtp://username:password@0.0.0.0:1025" EMAIL_FROM="Start UI " diff --git a/package.json b/package.json index 5f4e1a8c0..95cafe0ce 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@trpc/client": "10.45.2", "@trpc/react-query": "10.45.2", "@trpc/server": "10.45.2", + "arctic": "1.9.2", "bcrypt": "5.1.1", "chakra-react-select": "4.9.1", "colorette": "2.0.20", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82c2d665f..808d4cd47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: '@trpc/server': specifier: 10.45.2 version: 10.45.2 + arctic: + specifier: 1.9.2 + version: 1.9.2 bcrypt: specifier: 5.1.1 version: 5.1.1(encoding@0.1.13) @@ -4452,6 +4455,9 @@ packages: aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + arctic@1.9.2: + resolution: {integrity: sha512-VTnGpYx+ypboJdNrWnK17WeD7zN/xSCHnpecd5QYsBfVZde/5i+7DJ1wrf/ioSDMiEjagXmyNWAE3V2C9f1hNg==} + are-we-there-yet@2.0.0: resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} engines: {node: '>=10'} @@ -15322,6 +15328,10 @@ snapshots: aproba@2.0.0: {} + arctic@1.9.2: + dependencies: + oslo: 1.2.0 + are-we-there-yet@2.0.0: dependencies: delegates: 1.0.0 diff --git a/prisma/schema/auth.prisma b/prisma/schema/auth.prisma index b5525e5f4..8fb96a7ad 100644 --- a/prisma/schema/auth.prisma +++ b/prisma/schema/auth.prisma @@ -1,3 +1,12 @@ +model OAuthAccount { + provider String + providerUserId String + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) + + @@id([provider, providerUserId]) +} + model Session { id String @id userId String diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 04d7031f2..3c2811600 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -13,16 +13,17 @@ enum UserRole { } model User { - id String @id @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt name String? - email String? @unique - isEmailVerified Boolean @default(false) - accountStatus AccountStatus @default(NOT_VERIFIED) + email String? @unique + isEmailVerified Boolean @default(false) + accountStatus AccountStatus @default(NOT_VERIFIED) image String? - authorizations UserRole[] @default([APP]) - language String @default("en") + authorizations UserRole[] @default([APP]) + language String @default("en") lastLoginAt DateTime? session Session[] + oauth OAuthAccount[] } diff --git a/src/app/oauth/[provider]/page.tsx b/src/app/oauth/[provider]/page.tsx new file mode 100644 index 000000000..a931be385 --- /dev/null +++ b/src/app/oauth/[provider]/page.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { Suspense } from 'react'; + +import PageOAuthCallback from '@/features/auth/PageOAuthCallback'; + +export default function Page() { + return ( + + + + ); +} diff --git a/src/env.mjs b/src/env.mjs index fc4bb2bdd..9e860f2e1 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -6,6 +6,12 @@ import { z } from 'zod'; const zNodeEnv = () => z.enum(['development', 'test', 'production']).default('development'); +const zOptionalWithReplaceMe = () => + z + .string() + .optional() + .transform((value) => (value === 'REPLACE ME' ? undefined : value)); + export const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app @@ -15,6 +21,15 @@ export const env = createEnv({ DATABASE_URL: z.string().url(), NODE_ENV: zNodeEnv(), + GITHUB_CLIENT_ID: zOptionalWithReplaceMe(), + GITHUB_CLIENT_SECRET: zOptionalWithReplaceMe(), + + GOOGLE_CLIENT_ID: zOptionalWithReplaceMe(), + GOOGLE_CLIENT_SECRET: zOptionalWithReplaceMe(), + + DISCORD_CLIENT_ID: zOptionalWithReplaceMe(), + DISCORD_CLIENT_SECRET: zOptionalWithReplaceMe(), + EMAIL_SERVER: z.string().url(), EMAIL_FROM: z.string(), LOGGER_LEVEL: z @@ -77,6 +92,15 @@ export const env = createEnv({ LOGGER_LEVEL: process.env.LOGGER_LEVEL, LOGGER_PRETTY: process.env.LOGGER_PRETTY, + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, + + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET, + + DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, + DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, + NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_VERCEL_URL ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : process.env.NEXT_PUBLIC_BASE_URL, diff --git a/src/features/auth/OAuthLogin.tsx b/src/features/auth/OAuthLogin.tsx new file mode 100644 index 000000000..b1eac1b3e --- /dev/null +++ b/src/features/auth/OAuthLogin.tsx @@ -0,0 +1,90 @@ +import { + Button, + ButtonProps, + Divider, + Flex, + SimpleGrid, + Text, +} from '@chakra-ui/react'; +import { useRouter } from 'next/navigation'; +import { useTranslation } from 'react-i18next'; + +import { Icon } from '@/components/Icons'; +import { useToastError } from '@/components/Toast'; +import { + OAUTH_PROVIDERS, + OAUTH_PROVIDERS_ENABLED_ARRAY, + OAuthProvider, +} from '@/lib/oauth/config'; +import { trpc } from '@/lib/trpc/client'; + +export const OAuthLoginButton = ({ + provider, + ...rest +}: { + provider: OAuthProvider; +} & ButtonProps) => { + const { t } = useTranslation(['auth']); + const router = useRouter(); + const toastError = useToastError(); + const loginWith = trpc.oauth.createAuthorizationUrl.useMutation({ + onSuccess: (data) => { + router.push(data.url); + }, + onError: (error) => { + toastError({ + title: t('auth:login.feedbacks.oAuthError.title', { + provider: OAUTH_PROVIDERS[provider].label, + }), + description: error.message, + }); + }, + }); + + return ( + + ); +}; + +export const OAuthLoginButtonsGrid = () => { + if (!OAUTH_PROVIDERS_ENABLED_ARRAY.some((p) => p.isEnabled)) return null; + return ( + + {OAUTH_PROVIDERS_ENABLED_ARRAY.map(({ provider }) => { + return ( + + ); + })} + + ); +}; + +export const OAuthLoginDivider = () => { + const { t } = useTranslation(['common']); + if (!OAUTH_PROVIDERS_ENABLED_ARRAY.some((p) => p.isEnabled)) return null; + return ( + + + + {t('common:or')} + + + + ); +}; diff --git a/src/features/auth/PageLogin.tsx b/src/features/auth/PageLogin.tsx index 3827d5a69..dba498c58 100644 --- a/src/features/auth/PageLogin.tsx +++ b/src/features/auth/PageLogin.tsx @@ -6,6 +6,10 @@ import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { LoginForm } from '@/features/auth/LoginForm'; +import { + OAuthLoginButtonsGrid, + OAuthLoginDivider, +} from '@/features/auth/OAuthLogin'; import { ROUTES_AUTH } from '@/features/auth/routes'; import type { RouterInputs, RouterOutputs } from '@/lib/trpc/types'; @@ -51,6 +55,10 @@ export default function PageLogin() { + + + + ); diff --git a/src/features/auth/PageOAuthCallback.tsx b/src/features/auth/PageOAuthCallback.tsx new file mode 100644 index 000000000..d312a5295 --- /dev/null +++ b/src/features/auth/PageOAuthCallback.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useRef } from 'react'; + +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; + +import { LoaderFull } from '@/components/LoaderFull'; +import { useToastError } from '@/components/Toast'; +import { ROUTES_ADMIN } from '@/features/admin/routes'; +import { ROUTES_APP } from '@/features/app/routes'; +import { ROUTES_AUTH } from '@/features/auth/routes'; +import { zOAuthProvider } from '@/lib/oauth/config'; +import { trpc } from '@/lib/trpc/client'; + +export default function PageOAuthCallback() { + const { i18n, t } = useTranslation(['auth']); + const toastError = useToastError(); + const router = useRouter(); + const isTriggeredRef = useRef(false); + const params = z.object({ provider: zOAuthProvider() }).parse(useParams()); + const searchParams = z + .object({ code: z.string(), state: z.string() }) + .safeParse({ + code: useSearchParams().get('code'), + state: useSearchParams().get('state'), + }); + const validateLogin = trpc.oauth.validateLogin.useMutation({ + onSuccess: (data) => { + if (data.account.authorizations.includes('ADMIN')) { + router.replace(ROUTES_ADMIN.root()); + return; + } + router.replace(ROUTES_APP.root()); + }, + onError: () => { + toastError({ title: t('auth:login.feedbacks.loginError.title') }); + router.replace(ROUTES_AUTH.login()); + }, + }); + + useEffect(() => { + const trigger = () => { + if (isTriggeredRef.current) return; + isTriggeredRef.current = true; + + validateLogin.mutate({ + provider: params.provider, + code: searchParams.data?.code ?? '', + state: searchParams.data?.state ?? '', + language: i18n.language, + }); + }; + trigger(); + }, [validateLogin, params, searchParams, i18n]); + + return ; +} diff --git a/src/features/auth/PageRegister.tsx b/src/features/auth/PageRegister.tsx index a2486865a..694f56195 100644 --- a/src/features/auth/PageRegister.tsx +++ b/src/features/auth/PageRegister.tsx @@ -14,6 +14,10 @@ import { FormFieldLabel, } from '@/components/Form'; import { useToastError } from '@/components/Toast'; +import { + OAuthLoginButtonsGrid, + OAuthLoginDivider, +} from '@/features/auth/OAuthLogin'; import { ROUTES_AUTH } from '@/features/auth/routes'; import { FormFieldsRegister, @@ -87,6 +91,10 @@ export default function PageRegister() { + + + +
{ diff --git a/src/lib/oauth/config.ts b/src/lib/oauth/config.ts new file mode 100644 index 000000000..8bfaa49b1 --- /dev/null +++ b/src/lib/oauth/config.ts @@ -0,0 +1,40 @@ +import { FC } from 'react'; + +import { FaDiscord, FaGithub, FaGoogle } from 'react-icons/fa6'; +import { entries } from 'remeda'; +import { z } from 'zod'; + +export type OAuthProvider = z.infer>; +export const zOAuthProvider = () => z.enum(['github', 'google', 'discord']); + +export const OAUTH_PROVIDERS = { + github: { + isEnabled: true, + order: 1, + label: 'GitHub', + icon: FaGithub, + }, + discord: { + isEnabled: true, + order: 2, + label: 'Discord', + icon: FaDiscord, + }, + google: { + isEnabled: true, + order: 3, + label: 'Google', + icon: FaGoogle, + }, +} satisfies Record< + OAuthProvider, + { isEnabled: boolean; order: number; label: string; icon: FC } +>; + +export const OAUTH_PROVIDERS_ENABLED_ARRAY = entries(OAUTH_PROVIDERS) + .map(([key, value]) => ({ + provider: key, + ...value, + })) + .filter((p) => p.isEnabled) + .sort((a, b) => a.order - b.order); diff --git a/src/locales/ar/auth.json b/src/locales/ar/auth.json index fe680ad64..5c939abc9 100644 --- a/src/locales/ar/auth.json +++ b/src/locales/ar/auth.json @@ -26,6 +26,9 @@ "feedbacks": { "loginError": { "title": "فشل تسجيل الدخول" + }, + "oAuthError": { + "title": "فشل في إنشاء عنوان URL {{provider}}." } }, "appTitle": "تسجيل الدخول" diff --git a/src/locales/en/auth.json b/src/locales/en/auth.json index 5affc2af4..ac3c8f4fb 100644 --- a/src/locales/en/auth.json +++ b/src/locales/en/auth.json @@ -2,6 +2,9 @@ "login": { "appTitle": "Sign in", "feedbacks": { + "oAuthError": { + "title": "Failed to create the {{provider}} url" + }, "loginError": { "title": "Failed to sign in" } diff --git a/src/locales/fr/auth.json b/src/locales/fr/auth.json index aaacd1fd2..35311724f 100644 --- a/src/locales/fr/auth.json +++ b/src/locales/fr/auth.json @@ -26,6 +26,9 @@ "feedbacks": { "loginError": { "title": "Échec de la connexion" + }, + "oAuthError": { + "title": "Échec de la création de l'URL {{provider}}" } }, "appTitle": "Se connecter" diff --git a/src/locales/sw/auth.json b/src/locales/sw/auth.json index af7dd72a1..cbf876d4c 100644 --- a/src/locales/sw/auth.json +++ b/src/locales/sw/auth.json @@ -26,6 +26,9 @@ "feedbacks": { "loginError": { "title": "Imeshindwa kuingia" + }, + "oAuthError": { + "title": "Imeshindwa kuunda url ya {{mtoa huduma}}" } }, "appTitle": "Weka sahihi" diff --git a/src/server/config/oauth/index.ts b/src/server/config/oauth/index.ts new file mode 100644 index 000000000..1cd17addf --- /dev/null +++ b/src/server/config/oauth/index.ts @@ -0,0 +1,14 @@ +import { match } from 'ts-pattern'; + +import { OAuthProvider } from '@/lib/oauth/config'; +import { discord } from '@/server/config/oauth/providers/discord'; +import { github } from '@/server/config/oauth/providers/github'; +import { google } from '@/server/config/oauth/providers/google'; + +export const oAuthProvider = (provider: OAuthProvider) => { + return match(provider) + .with('github', () => github) + .with('google', () => google) + .with('discord', () => discord) + .exhaustive(); +}; diff --git a/src/server/config/oauth/providers/discord.ts b/src/server/config/oauth/providers/discord.ts new file mode 100644 index 000000000..a088de80e --- /dev/null +++ b/src/server/config/oauth/providers/discord.ts @@ -0,0 +1,85 @@ +import { TRPCError } from '@trpc/server'; +import { Discord } from 'arctic'; +import { z } from 'zod'; + +import { env } from '@/env.mjs'; +import { OAuthClient, getOAuthCallbackUrl } from '@/server/config/oauth/utils'; + +const zDiscordUser = () => + z.object({ + id: z.string(), + global_name: z.string().nullish(), + email: z.string().email().nullish(), + verified: z.boolean().nullish(), + locale: z.string().nullish(), + }); + +const discordClient = + env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET + ? new Discord( + env.DISCORD_CLIENT_ID, + env.DISCORD_CLIENT_SECRET, + getOAuthCallbackUrl('discord') + ) + : null; + +export const discord: OAuthClient = { + shouldUseCodeVerifier: true, + createAuthorizationUrl: async (state) => { + if (!discordClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing Discord environnement variables', + }); + } + return await discordClient.createAuthorizationURL(state, { + scopes: ['identify', 'email'], + }); + }, + validateAuthorizationCode: async (code) => { + if (!discordClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing Discord environnement variables', + }); + } + return discordClient.validateAuthorizationCode(code); + }, + getUser: async ({ accessToken, ctx }) => { + ctx.logger.info('Get the user from Discord'); + + const userResponse = await fetch('https://discord.com/api/users/@me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + if (!userResponse.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve the Discord user', + }); + } + + const userData = await userResponse.json(); + ctx.logger.debug(userData); + + ctx.logger.info('Parse the Discord user'); + const discordUser = zDiscordUser().safeParse(userData); + + if (discordUser.error) { + ctx.logger.error(discordUser.error.formErrors.fieldErrors); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to parse the Discord user', + }); + } + + return { + id: discordUser.data.id, + name: discordUser.data.global_name, + email: discordUser.data.email, + isEmailVerified: !!discordUser.data.verified, + language: discordUser.data.locale, + }; + }, +}; diff --git a/src/server/config/oauth/providers/github.ts b/src/server/config/oauth/providers/github.ts new file mode 100644 index 000000000..bc8f4a0bc --- /dev/null +++ b/src/server/config/oauth/providers/github.ts @@ -0,0 +1,121 @@ +import { TRPCError } from '@trpc/server'; +import { GitHub } from 'arctic'; +import { z } from 'zod'; + +import { env } from '@/env.mjs'; +import { OAuthClient, getOAuthCallbackUrl } from '@/server/config/oauth/utils'; + +const zGitHubUser = () => + z.object({ + id: z.number(), + name: z.string().nullish(), + email: z.string().email().nullish(), + }); + +const zGitHubEmails = () => + z.array( + z.object({ + primary: z.boolean().nullish(), + verified: z.boolean().nullish(), + email: z.string().nullish(), + }) + ); + +const githubClient = + env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET + ? new GitHub(env.GITHUB_CLIENT_ID, env.GITHUB_CLIENT_SECRET, { + redirectURI: getOAuthCallbackUrl('github'), + }) + : null; + +export const github: OAuthClient = { + shouldUseCodeVerifier: false, + createAuthorizationUrl: async (state: string) => { + if (!githubClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing GitHub environnement variables', + }); + } + return await githubClient.createAuthorizationURL(state, { + scopes: ['user:email'], + }); + }, + validateAuthorizationCode: async (code: string) => { + if (!githubClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing GitHub environnement variables', + }); + } + return githubClient.validateAuthorizationCode(code); + }, + getUser: async ({ accessToken, ctx }) => { + ctx.logger.info('Get the user from GitHub'); + const [userResponse, emailsResponse] = await Promise.all([ + fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }), + fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }), + ]); + + if (!userResponse.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve GitHub user', + }); + } + + if (!emailsResponse.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve GitHub emails', + }); + } + + const emailsData = await emailsResponse.json(); + ctx.logger.debug(emailsData); + + ctx.logger.info('Parse the GitHub user emails'); + const emails = zGitHubEmails().safeParse(emailsData); + + if (emails.error) { + ctx.logger.error( + `Zod error while parsing the GitHub emails: ${JSON.stringify(emails.error.formErrors.fieldErrors, null, 2)}` + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to parse the GitHub emails', + }); + } + + const primaryEmail = emails.data?.find((email) => email.primary) ?? null; + + const userData = await userResponse.json(); + ctx.logger.debug(userData); + + ctx.logger.info('Parse the GitHub user'); + const gitHubUser = zGitHubUser().safeParse(userData); + + if (gitHubUser.error) { + ctx.logger.error(gitHubUser.error.formErrors.fieldErrors); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to parse the GitHub user', + }); + } + + return { + id: gitHubUser.data.id.toString(), + name: gitHubUser.data.name, + email: primaryEmail?.email ?? gitHubUser.data.email, + isEmailVerified: !!primaryEmail?.verified, + }; + }, +}; diff --git a/src/server/config/oauth/providers/google.ts b/src/server/config/oauth/providers/google.ts new file mode 100644 index 000000000..8b8dce53c --- /dev/null +++ b/src/server/config/oauth/providers/google.ts @@ -0,0 +1,88 @@ +import { TRPCError } from '@trpc/server'; +import { Google } from 'arctic'; +import { z } from 'zod'; + +import { env } from '@/env.mjs'; +import { OAuthClient, getOAuthCallbackUrl } from '@/server/config/oauth/utils'; + +const zGoogleUser = () => + z.object({ + sub: z.string(), + name: z.string().nullish(), + email: z.string().email().nullish(), + email_verified: z.boolean().nullish(), + }); + +const googleClient = + env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ? new Google( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + getOAuthCallbackUrl('google') + ) + : null; + +export const google: OAuthClient = { + shouldUseCodeVerifier: true, + createAuthorizationUrl: async (state, codeVerifier) => { + if (!googleClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing Google environnement variables', + }); + } + if (!codeVerifier) throw new Error('Missing codeVerifier'); + return await googleClient.createAuthorizationURL(state, codeVerifier, { + scopes: ['email', 'profile'], + }); + }, + validateAuthorizationCode: async (code, codeVerifier) => { + if (!googleClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing Google environnement variables', + }); + } + if (!codeVerifier) throw new Error('Missing codeVerifier'); + return googleClient.validateAuthorizationCode(code, codeVerifier); + }, + getUser: async ({ accessToken, ctx }) => { + ctx.logger.info('Get the user from Google'); + + const userResponse = await fetch( + 'https://openidconnect.googleapis.com/v1/userinfo', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + if (!userResponse.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to retrieve the Google user', + }); + } + + const userData = await userResponse.json(); + ctx.logger.debug(userData); + + ctx.logger.info('Parse the Google user'); + const googleUser = zGoogleUser().safeParse(userData); + + if (googleUser.error) { + ctx.logger.error(googleUser.error.formErrors.fieldErrors); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to parse the Google user', + }); + } + + return { + id: googleUser.data.sub, + name: googleUser.data.name, + email: googleUser.data.email, + isEmailVerified: !!googleUser.data.email_verified, + }; + }, +}; diff --git a/src/server/config/oauth/utils.ts b/src/server/config/oauth/utils.ts new file mode 100644 index 000000000..a699bae81 --- /dev/null +++ b/src/server/config/oauth/utils.ts @@ -0,0 +1,25 @@ +import { env } from '@/env.mjs'; +import { OAuthProvider } from '@/lib/oauth/config'; +import { AppContext } from '@/server/config/trpc'; + +export type OAuthClient = { + shouldUseCodeVerifier: boolean; + createAuthorizationUrl: ( + state: string, + codeVerifier?: string + ) => Promise; + validateAuthorizationCode: ( + code: string, + codeVerifier?: string + ) => Promise<{ accessToken: string; refreshToken?: string | null }>; + getUser: (params: { accessToken: string; ctx: AppContext }) => Promise<{ + id: string; + name?: string | null; + email?: string | null; + isEmailVerified: boolean; + language?: string | null; + }>; +}; + +export const getOAuthCallbackUrl = (provider: OAuthProvider) => + `${env.NEXT_PUBLIC_BASE_URL}/oauth/${provider}`; diff --git a/src/server/router.ts b/src/server/router.ts index 3f34e0d24..3ce18c905 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,6 +1,7 @@ import { createTRPCRouter } from '@/server/config/trpc'; import { accountRouter } from '@/server/routers/account'; import { authRouter } from '@/server/routers/auth'; +import { oauthRouter } from '@/server/routers/oauth'; import { repositoriesRouter } from '@/server/routers/repositories'; import { usersRouter } from '@/server/routers/users'; @@ -12,6 +13,7 @@ import { usersRouter } from '@/server/routers/users'; export const appRouter = createTRPCRouter({ account: accountRouter, auth: authRouter, + oauth: oauthRouter, repositories: repositoriesRouter, users: usersRouter, }); diff --git a/src/server/routers/oauth.tsx b/src/server/routers/oauth.tsx new file mode 100644 index 000000000..d24d45075 --- /dev/null +++ b/src/server/routers/oauth.tsx @@ -0,0 +1,231 @@ +import { User } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; +import { generateCodeVerifier, generateState } from 'arctic'; +import { cookies } from 'next/headers'; +import { keys } from 'remeda'; +import { z } from 'zod'; + +import { env } from '@/env.mjs'; +import { zUserAccount } from '@/features/account/schemas'; +import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; +import { OAUTH_PROVIDERS, zOAuthProvider } from '@/lib/oauth/config'; +import locales from '@/locales'; +import { createSession } from '@/server/config/auth'; +import { oAuthProvider } from '@/server/config/oauth'; +import { createTRPCRouter, publicProcedure } from '@/server/config/trpc'; + +export const oauthRouter = createTRPCRouter({ + createAuthorizationUrl: publicProcedure() + .input( + z.object({ + provider: zOAuthProvider(), + }) + ) + .output(z.object({ url: z.string().url() })) + .mutation(async ({ input }) => { + if (!OAUTH_PROVIDERS[input.provider].isEnabled) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: `${input.provider} provider is not enabled`, + }); + } + + const state = generateState(); + const codeVerifier = generateCodeVerifier(); + const url = await oAuthProvider(input.provider).createAuthorizationUrl( + state, + codeVerifier + ); + + cookies().set(`${input.provider}_oauth_state`, state, { + httpOnly: true, + secure: env.NODE_ENV === 'production', + maxAge: 60 * 10, // 10 minutes + path: '/', + }); + + cookies().set(`${input.provider}_oauth_codeVerifier`, codeVerifier, { + httpOnly: true, + secure: env.NODE_ENV === 'production', + maxAge: 60 * 10, // 10 minutes + path: '/', + }); + + return { + url: url.toString(), + }; + }), + + validateLogin: publicProcedure() + .input( + z.object({ + provider: zOAuthProvider(), + state: z.string().min(1), + code: z.string().min(1), + language: z.string().optional(), + }) + ) + .output(z.object({ token: z.string(), account: zUserAccount() })) + .mutation(async ({ ctx, input }) => { + if (!OAUTH_PROVIDERS[input.provider].isEnabled) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: `${input.provider} provider is not enabled`, + }); + } + + const stateFromCookie = z + .string() + .safeParse(cookies().get(`${input.provider}_oauth_state`)?.value); + + const codeVerifierFromCookie = z + .string() + .safeParse( + cookies().get(`${input.provider}_oauth_codeVerifier`)?.value + ); + + if (!stateFromCookie.success || stateFromCookie.data !== input.state) { + ctx.logger.warn('Wrong oAuth state'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Wrong oAuth state', + }); + } + + if ( + oAuthProvider(input.provider).shouldUseCodeVerifier && + !codeVerifierFromCookie.data + ) { + ctx.logger.warn('Missing oAuth codeVerifier'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Missing oAuth codeVerifier', + }); + } + + let accessToken: string; + try { + ctx.logger.info(`Validate the ${input.provider} code`); + const tokens = await oAuthProvider( + input.provider + ).validateAuthorizationCode(input.code, codeVerifierFromCookie.data); + accessToken = tokens.accessToken; + } catch { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to validate the ${input.provider} authorization code`, + }); + } + + let providerUser; + try { + providerUser = await oAuthProvider(input.provider).getUser({ + accessToken, + ctx, + }); + ctx.logger.debug(providerUser); + } catch (e) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to retrieve the ${input.provider} user`, + }); + } + + let existingUser: User | undefined; + + ctx.logger.info('Check existing oAuth account'); + const existingOAuthAccount = await ctx.db.oAuthAccount.findFirst({ + where: { + provider: input.provider, + providerUserId: providerUser.id, + }, + include: { + user: true, + }, + }); + + if (existingOAuthAccount?.user) { + ctx.logger.info('OAuth account found'); + existingUser = existingOAuthAccount.user; + } else { + ctx.logger.info( + 'OAuth account not found, checking for existing user by email (verified)' + ); + const existingUserByEmail = + providerUser.email && providerUser.isEmailVerified + ? await ctx.db.user.findFirst({ + where: { + email: providerUser.email, + isEmailVerified: true, + }, + }) + : undefined; + + if (existingUserByEmail) { + ctx.logger.info('User found with email, creating the OAuth account'); + await ctx.db.oAuthAccount.create({ + data: { + provider: input.provider, + providerUserId: providerUser.id, + userId: existingUserByEmail.id, + }, + }); + + existingUser = existingUserByEmail; + } + } + + if (existingUser?.accountStatus === 'DISABLED') { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Account is disabled', + }); + } + + if (existingUser?.accountStatus === 'NOT_VERIFIED') { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Account should not be NOT_VERIFIED at this point', + }); + } + + if (existingUser) { + ctx.logger.info('Create the session for the existing user'); + const sessionId = await createSession(existingUser.id); + + return { + account: existingUser, + token: sessionId, + }; + } + + ctx.logger.info('Creating the new user'); + + const newUser = await ctx.db.user.create({ + data: { + email: providerUser.email ?? undefined, + name: providerUser.name ?? undefined, + language: + keys(locales).find((key) => + (providerUser.language ?? input.language)?.startsWith(key) + ) ?? DEFAULT_LANGUAGE_KEY, + accountStatus: 'ENABLED', + isEmailVerified: providerUser.isEmailVerified, + oauth: { + create: { + provider: input.provider, + providerUserId: providerUser.id, + }, + }, + }, + }); + + ctx.logger.info('Create the session for the new user'); + const sessionId = await createSession(newUser.id); + + return { + account: newUser, + token: sessionId, + }; + }), +}); From 5313c3517844936c3766876ea61ba41dacf6173f Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 7 Aug 2024 13:55:31 +0200 Subject: [PATCH 2/7] fix: auth flows --- src/server/routers/oauth.tsx | 50 +++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/src/server/routers/oauth.tsx b/src/server/routers/oauth.tsx index d24d45075..9cfc9c9ef 100644 --- a/src/server/routers/oauth.tsx +++ b/src/server/routers/oauth.tsx @@ -199,8 +199,56 @@ export const oauthRouter = createTRPCRouter({ }; } - ctx.logger.info('Creating the new user'); + const emailAlreadyExistsUser = providerUser.email + ? await ctx.db.user.findFirst({ + where: { email: providerUser.email }, + }) + : null; + + if (emailAlreadyExistsUser?.accountStatus === 'NOT_VERIFIED') { + ctx.logger.info('Email already exists with an NOT_VERIFIED account'); + ctx.logger.info('Update the NOT_VERIFIED user'); + const updatedUser = await ctx.db.user.update({ + where: { + id: emailAlreadyExistsUser.id, + }, + data: { + name: providerUser.name ?? null, + language: + keys(locales).find((key) => + (providerUser.language ?? input.language)?.startsWith(key) + ) ?? DEFAULT_LANGUAGE_KEY, + accountStatus: 'ENABLED', + isEmailVerified: providerUser.isEmailVerified, + oauth: { + create: { + provider: input.provider, + providerUserId: providerUser.id, + }, + }, + }, + }); + + ctx.logger.info('Create the session for the updated user'); + const sessionId = await createSession(updatedUser.id); + return { + account: updatedUser, + token: sessionId, + }; + } + + if (emailAlreadyExistsUser) { + ctx.logger.warn( + 'The email already exists but we cannot safely take over the account (probably because the email was not verified but the account is enabled). Silent error for security reasons' + ); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create the account', + }); + } + + ctx.logger.info('Creating the new user'); const newUser = await ctx.db.user.create({ data: { email: providerUser.email ?? undefined, From 93b21412c6b57ff89691c936bf4219dabc3b85e8 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 30 Sep 2024 13:42:13 +0200 Subject: [PATCH 3/7] fix: pull request feedbacks --- src/features/auth/PageOAuthCallback.tsx | 21 +++++++++++++++----- src/server/config/oauth/providers/discord.ts | 9 +++++---- src/server/config/oauth/providers/github.ts | 8 ++++---- src/server/config/oauth/providers/google.ts | 13 ++++++++---- src/server/routers/oauth.tsx | 12 ++++++----- 5 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/features/auth/PageOAuthCallback.tsx b/src/features/auth/PageOAuthCallback.tsx index d312a5295..44ddb612a 100644 --- a/src/features/auth/PageOAuthCallback.tsx +++ b/src/features/auth/PageOAuthCallback.tsx @@ -1,6 +1,11 @@ import React, { useEffect, useRef } from 'react'; -import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { + notFound, + useParams, + useRouter, + useSearchParams, +} from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; @@ -17,7 +22,9 @@ export default function PageOAuthCallback() { const toastError = useToastError(); const router = useRouter(); const isTriggeredRef = useRef(false); - const params = z.object({ provider: zOAuthProvider() }).parse(useParams()); + const params = z + .object({ provider: zOAuthProvider() }) + .safeParse(useParams()); const searchParams = z .object({ code: z.string(), state: z.string() }) .safeParse({ @@ -43,10 +50,14 @@ export default function PageOAuthCallback() { if (isTriggeredRef.current) return; isTriggeredRef.current = true; + if (!(params.success && searchParams.success)) { + notFound(); + } + validateLogin.mutate({ - provider: params.provider, - code: searchParams.data?.code ?? '', - state: searchParams.data?.state ?? '', + provider: params.data.provider, + code: searchParams.data.code, + state: searchParams.data.state, language: i18n.language, }); }; diff --git a/src/server/config/oauth/providers/discord.ts b/src/server/config/oauth/providers/discord.ts index a088de80e..6936ccb27 100644 --- a/src/server/config/oauth/providers/discord.ts +++ b/src/server/config/oauth/providers/discord.ts @@ -8,6 +8,7 @@ import { OAuthClient, getOAuthCallbackUrl } from '@/server/config/oauth/utils'; const zDiscordUser = () => z.object({ id: z.string(), + username: z.string().nullish(), global_name: z.string().nullish(), email: z.string().email().nullish(), verified: z.boolean().nullish(), @@ -29,7 +30,7 @@ export const discord: OAuthClient = { if (!discordClient) { throw new TRPCError({ code: 'NOT_IMPLEMENTED', - message: 'Missing Discord environnement variables', + message: 'Missing Discord environment variables', }); } return await discordClient.createAuthorizationURL(state, { @@ -40,7 +41,7 @@ export const discord: OAuthClient = { if (!discordClient) { throw new TRPCError({ code: 'NOT_IMPLEMENTED', - message: 'Missing Discord environnement variables', + message: 'Missing Discord environment variables', }); } return discordClient.validateAuthorizationCode(code); @@ -61,7 +62,7 @@ export const discord: OAuthClient = { } const userData = await userResponse.json(); - ctx.logger.debug(userData); + ctx.logger.info('User data retrieved from Discord'); ctx.logger.info('Parse the Discord user'); const discordUser = zDiscordUser().safeParse(userData); @@ -76,7 +77,7 @@ export const discord: OAuthClient = { return { id: discordUser.data.id, - name: discordUser.data.global_name, + name: discordUser.data.global_name ?? discordUser.data.username, email: discordUser.data.email, isEmailVerified: !!discordUser.data.verified, language: discordUser.data.locale, diff --git a/src/server/config/oauth/providers/github.ts b/src/server/config/oauth/providers/github.ts index bc8f4a0bc..3bec9af32 100644 --- a/src/server/config/oauth/providers/github.ts +++ b/src/server/config/oauth/providers/github.ts @@ -34,7 +34,7 @@ export const github: OAuthClient = { if (!githubClient) { throw new TRPCError({ code: 'NOT_IMPLEMENTED', - message: 'Missing GitHub environnement variables', + message: 'Missing GitHub environment variables', }); } return await githubClient.createAuthorizationURL(state, { @@ -45,7 +45,7 @@ export const github: OAuthClient = { if (!githubClient) { throw new TRPCError({ code: 'NOT_IMPLEMENTED', - message: 'Missing GitHub environnement variables', + message: 'Missing GitHub environment variables', }); } return githubClient.validateAuthorizationCode(code); @@ -80,7 +80,7 @@ export const github: OAuthClient = { } const emailsData = await emailsResponse.json(); - ctx.logger.debug(emailsData); + ctx.logger.info('Retrieved emails from GitHub'); ctx.logger.info('Parse the GitHub user emails'); const emails = zGitHubEmails().safeParse(emailsData); @@ -98,7 +98,7 @@ export const github: OAuthClient = { const primaryEmail = emails.data?.find((email) => email.primary) ?? null; const userData = await userResponse.json(); - ctx.logger.debug(userData); + ctx.logger.info('User data retrieved from GitHub'); ctx.logger.info('Parse the GitHub user'); const gitHubUser = zGitHubUser().safeParse(userData); diff --git a/src/server/config/oauth/providers/google.ts b/src/server/config/oauth/providers/google.ts index 8b8dce53c..f96ac3b18 100644 --- a/src/server/config/oauth/providers/google.ts +++ b/src/server/config/oauth/providers/google.ts @@ -28,10 +28,15 @@ export const google: OAuthClient = { if (!googleClient) { throw new TRPCError({ code: 'NOT_IMPLEMENTED', - message: 'Missing Google environnement variables', + message: 'Missing Google environment variables', + }); + } + if (!codeVerifier) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Missing codeVerifier', }); } - if (!codeVerifier) throw new Error('Missing codeVerifier'); return await googleClient.createAuthorizationURL(state, codeVerifier, { scopes: ['email', 'profile'], }); @@ -40,7 +45,7 @@ export const google: OAuthClient = { if (!googleClient) { throw new TRPCError({ code: 'NOT_IMPLEMENTED', - message: 'Missing Google environnement variables', + message: 'Missing Google environment variables', }); } if (!codeVerifier) throw new Error('Missing codeVerifier'); @@ -65,7 +70,7 @@ export const google: OAuthClient = { } const userData = await userResponse.json(); - ctx.logger.debug(userData); + ctx.logger.info('User data retrieved from Google'); ctx.logger.info('Parse the Google user'); const googleUser = zGoogleUser().safeParse(userData); diff --git a/src/server/routers/oauth.tsx b/src/server/routers/oauth.tsx index 9cfc9c9ef..38dc2db4a 100644 --- a/src/server/routers/oauth.tsx +++ b/src/server/routers/oauth.tsx @@ -96,10 +96,10 @@ export const oauthRouter = createTRPCRouter({ oAuthProvider(input.provider).shouldUseCodeVerifier && !codeVerifierFromCookie.data ) { - ctx.logger.warn('Missing oAuth codeVerifier'); + ctx.logger.warn('Invalid or expired authorization request'); throw new TRPCError({ code: 'BAD_REQUEST', - message: 'Missing oAuth codeVerifier', + message: 'Invalid or expired authorization request', }); } @@ -176,16 +176,18 @@ export const oauthRouter = createTRPCRouter({ } if (existingUser?.accountStatus === 'DISABLED') { + ctx.logger.info('Account is disabled'); throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'Account is disabled', + message: 'Please verify your account to proceed', }); } if (existingUser?.accountStatus === 'NOT_VERIFIED') { + ctx.logger.error('Account should not be NOT_VERIFIED at this point'); throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Account should not be NOT_VERIFIED at this point', + code: 'UNAUTHORIZED', + message: 'Please verify your account to proceed', }); } From 62e3b68200844f1d080a421d3d9a8c976c9da811 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:21:25 +0200 Subject: [PATCH 4/7] fix: add production verification for REPLACE ME values in env vars --- src/env.mjs | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/env.mjs b/src/env.mjs index 9e860f2e1..7fa706a2c 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -3,15 +3,6 @@ import { createEnv } from '@t3-oss/env-nextjs'; import { z } from 'zod'; -const zNodeEnv = () => - z.enum(['development', 'test', 'production']).default('development'); - -const zOptionalWithReplaceMe = () => - z - .string() - .optional() - .transform((value) => (value === 'REPLACE ME' ? undefined : value)); - export const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app @@ -116,3 +107,22 @@ export const env = createEnv({ */ skipValidation: !!process.env.SKIP_ENV_VALIDATION, }); + +function zNodeEnv() { + return z.enum(['development', 'test', 'production']).default('development'); +} + +function zOptionalWithReplaceMe() { + return z + .string() + .optional() + .refine( + (value) => + // Check in prodution if the value is not REPLACE ME + process.env.NODE_ENV !== 'production' || value !== 'REPLACE ME', + { + message: 'Update the value "REPLACE ME" or remove the variable', + } + ) + .transform((value) => (value === 'REPLACE ME' ? undefined : value)); +} From ec17a23892e129a025a092a66dbff02761556856 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:22:28 +0200 Subject: [PATCH 5/7] fix: remove unused variable --- src/server/routers/oauth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routers/oauth.tsx b/src/server/routers/oauth.tsx index 38dc2db4a..17da76002 100644 --- a/src/server/routers/oauth.tsx +++ b/src/server/routers/oauth.tsx @@ -124,7 +124,7 @@ export const oauthRouter = createTRPCRouter({ ctx, }); ctx.logger.debug(providerUser); - } catch (e) { + } catch { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Failed to retrieve the ${input.provider} user`, From f73b9fbbc31f3402a8bc47cced72db92cc44a4a0 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:23:57 +0200 Subject: [PATCH 6/7] Update src/server/routers/oauth.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/server/routers/oauth.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routers/oauth.tsx b/src/server/routers/oauth.tsx index 17da76002..601a8cc01 100644 --- a/src/server/routers/oauth.tsx +++ b/src/server/routers/oauth.tsx @@ -179,7 +179,7 @@ export const oauthRouter = createTRPCRouter({ ctx.logger.info('Account is disabled'); throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'Please verify your account to proceed', + message: 'Unable to authenticate. Please contact support if this issue persists.', }); } From 38a7f10fb6ff25148ba11fdca5e5904348441fd0 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:35:40 +0200 Subject: [PATCH 7/7] fix: PR feedbacks --- src/features/auth/OAuthLogin.tsx | 2 +- src/server/config/oauth/providers/google.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/features/auth/OAuthLogin.tsx b/src/features/auth/OAuthLogin.tsx index b1eac1b3e..5876aa788 100644 --- a/src/features/auth/OAuthLogin.tsx +++ b/src/features/auth/OAuthLogin.tsx @@ -54,7 +54,7 @@ export const OAuthLoginButton = ({ }; export const OAuthLoginButtonsGrid = () => { - if (!OAUTH_PROVIDERS_ENABLED_ARRAY.some((p) => p.isEnabled)) return null; + if (!OAUTH_PROVIDERS_ENABLED_ARRAY.length) return null; return ( {OAUTH_PROVIDERS_ENABLED_ARRAY.map(({ provider }) => { diff --git a/src/server/config/oauth/providers/google.ts b/src/server/config/oauth/providers/google.ts index f96ac3b18..8b727f4bd 100644 --- a/src/server/config/oauth/providers/google.ts +++ b/src/server/config/oauth/providers/google.ts @@ -48,7 +48,12 @@ export const google: OAuthClient = { message: 'Missing Google environment variables', }); } - if (!codeVerifier) throw new Error('Missing codeVerifier'); + if (!codeVerifier) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Missing codeVerifier', + }); + } return googleClient.validateAuthorizationCode(code, codeVerifier); }, getUser: async ({ accessToken, ctx }) => {