From e0e61c17a3dca9a2af7d6de33a7e488e389c7183 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 22 Jul 2024 17:40:39 +0200 Subject: [PATCH 01/11] 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 | 10 +- 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, 860 insertions(+), 9 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 f7845561a..bab93be07 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 c16fb57b7..9662f17c2 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) @@ -4455,6 +4458,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'} @@ -15331,6 +15337,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 0a32efa05..1955ada92 100644 --- a/src/features/auth/PageRegister.tsx +++ b/src/features/auth/PageRegister.tsx @@ -13,7 +13,11 @@ import { FormFieldController, FormFieldLabel, } from '@/components/Form'; -import { toastCustom } from '@/components/Toast'; +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 3f98185029837f64fdb76edc49dffc471c814227 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Wed, 7 Aug 2024 13:55:31 +0200 Subject: [PATCH 02/11] 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 c3486f747542afb727ac6e8587df1d6420d07120 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 30 Sep 2024 13:42:13 +0200 Subject: [PATCH 03/11] 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 6b8ee880cafa3521ff645252fa51fd273520a47e Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:21:25 +0200 Subject: [PATCH 04/11] 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 51d801da505d6c0fed058fec74224a770786315c Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:22:28 +0200 Subject: [PATCH 05/11] 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 c9ba9fc3b2eb3941f30bf3195026fdf64ea702c4 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:23:57 +0200 Subject: [PATCH 06/11] 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 7cbdbfece771481b5a8ef0952f633583f4070d71 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 14 Oct 2024 17:35:40 +0200 Subject: [PATCH 07/11] 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 }) => { From 2f8c7df70bf526a487db67c096e660d111801e3d Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 21 Oct 2024 13:33:02 +0200 Subject: [PATCH 08/11] fix: move oauth config file --- src/features/auth/OAuthLogin.tsx | 2 +- src/features/auth/PageOAuthCallback.tsx | 2 +- src/{lib/oauth/config.ts => features/auth/oauth-config.ts} | 0 src/server/config/oauth/index.ts | 2 +- src/server/config/oauth/utils.ts | 2 +- src/server/routers/oauth.tsx | 5 +++-- 6 files changed, 7 insertions(+), 6 deletions(-) rename src/{lib/oauth/config.ts => features/auth/oauth-config.ts} (100%) diff --git a/src/features/auth/OAuthLogin.tsx b/src/features/auth/OAuthLogin.tsx index 5876aa788..ee7c2fbbe 100644 --- a/src/features/auth/OAuthLogin.tsx +++ b/src/features/auth/OAuthLogin.tsx @@ -15,7 +15,7 @@ import { OAUTH_PROVIDERS, OAUTH_PROVIDERS_ENABLED_ARRAY, OAuthProvider, -} from '@/lib/oauth/config'; +} from '@/features/auth/oauth-config'; import { trpc } from '@/lib/trpc/client'; export const OAuthLoginButton = ({ diff --git a/src/features/auth/PageOAuthCallback.tsx b/src/features/auth/PageOAuthCallback.tsx index 44ddb612a..12ecb1d9e 100644 --- a/src/features/auth/PageOAuthCallback.tsx +++ b/src/features/auth/PageOAuthCallback.tsx @@ -13,8 +13,8 @@ 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 { zOAuthProvider } from '@/features/auth/oauth-config'; import { ROUTES_AUTH } from '@/features/auth/routes'; -import { zOAuthProvider } from '@/lib/oauth/config'; import { trpc } from '@/lib/trpc/client'; export default function PageOAuthCallback() { diff --git a/src/lib/oauth/config.ts b/src/features/auth/oauth-config.ts similarity index 100% rename from src/lib/oauth/config.ts rename to src/features/auth/oauth-config.ts diff --git a/src/server/config/oauth/index.ts b/src/server/config/oauth/index.ts index 1cd17addf..9e577221c 100644 --- a/src/server/config/oauth/index.ts +++ b/src/server/config/oauth/index.ts @@ -1,6 +1,6 @@ import { match } from 'ts-pattern'; -import { OAuthProvider } from '@/lib/oauth/config'; +import { OAuthProvider } from '@/features/auth/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'; diff --git a/src/server/config/oauth/utils.ts b/src/server/config/oauth/utils.ts index a699bae81..eb7f4ffe4 100644 --- a/src/server/config/oauth/utils.ts +++ b/src/server/config/oauth/utils.ts @@ -1,5 +1,5 @@ import { env } from '@/env.mjs'; -import { OAuthProvider } from '@/lib/oauth/config'; +import { OAuthProvider } from '@/features/auth/oauth-config'; import { AppContext } from '@/server/config/trpc'; export type OAuthClient = { diff --git a/src/server/routers/oauth.tsx b/src/server/routers/oauth.tsx index 601a8cc01..1cd731ee6 100644 --- a/src/server/routers/oauth.tsx +++ b/src/server/routers/oauth.tsx @@ -7,8 +7,8 @@ import { z } from 'zod'; import { env } from '@/env.mjs'; import { zUserAccount } from '@/features/account/schemas'; +import { OAUTH_PROVIDERS, zOAuthProvider } from '@/features/auth/oauth-config'; 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'; @@ -179,7 +179,8 @@ export const oauthRouter = createTRPCRouter({ ctx.logger.info('Account is disabled'); throw new TRPCError({ code: 'UNAUTHORIZED', - message: 'Unable to authenticate. Please contact support if this issue persists.', + message: + 'Unable to authenticate. Please contact support if this issue persists.', }); } From a9ed445ae8313032cd9a8514d5cb17cf3b5b1ba0 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 21 Oct 2024 13:38:27 +0200 Subject: [PATCH 09/11] fix: toasts --- src/features/auth/OAuthLogin.tsx | 6 +++--- src/features/auth/PageOAuthCallback.tsx | 8 +++++--- src/features/auth/PageRegister.tsx | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/features/auth/OAuthLogin.tsx b/src/features/auth/OAuthLogin.tsx index ee7c2fbbe..070fdcae5 100644 --- a/src/features/auth/OAuthLogin.tsx +++ b/src/features/auth/OAuthLogin.tsx @@ -10,7 +10,7 @@ import { useRouter } from 'next/navigation'; import { useTranslation } from 'react-i18next'; import { Icon } from '@/components/Icons'; -import { useToastError } from '@/components/Toast'; +import { toastCustom } from '@/components/Toast'; import { OAUTH_PROVIDERS, OAUTH_PROVIDERS_ENABLED_ARRAY, @@ -26,13 +26,13 @@ export const OAuthLoginButton = ({ } & 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({ + toastCustom({ + status: 'error', title: t('auth:login.feedbacks.oAuthError.title', { provider: OAUTH_PROVIDERS[provider].label, }), diff --git a/src/features/auth/PageOAuthCallback.tsx b/src/features/auth/PageOAuthCallback.tsx index 12ecb1d9e..3cf830074 100644 --- a/src/features/auth/PageOAuthCallback.tsx +++ b/src/features/auth/PageOAuthCallback.tsx @@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { LoaderFull } from '@/components/LoaderFull'; -import { useToastError } from '@/components/Toast'; +import { toastCustom } from '@/components/Toast'; import { ROUTES_ADMIN } from '@/features/admin/routes'; import { ROUTES_APP } from '@/features/app/routes'; import { zOAuthProvider } from '@/features/auth/oauth-config'; @@ -19,7 +19,6 @@ 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 @@ -40,7 +39,10 @@ export default function PageOAuthCallback() { router.replace(ROUTES_APP.root()); }, onError: () => { - toastError({ title: t('auth:login.feedbacks.loginError.title') }); + toastCustom({ + status: 'error', + title: t('auth:login.feedbacks.loginError.title'), + }); router.replace(ROUTES_AUTH.login()); }, }); diff --git a/src/features/auth/PageRegister.tsx b/src/features/auth/PageRegister.tsx index 1955ada92..43eb33230 100644 --- a/src/features/auth/PageRegister.tsx +++ b/src/features/auth/PageRegister.tsx @@ -13,7 +13,7 @@ import { FormFieldController, FormFieldLabel, } from '@/components/Form'; -import { useToastError } from '@/components/Toast'; +import { toastCustom } from '@/components/Toast'; import { OAuthLoginButtonsGrid, OAuthLoginDivider, From 83d3cbf1e1b69717019ada420d9f8fb6cc6e3784 Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 21 Oct 2024 13:46:08 +0200 Subject: [PATCH 10/11] docs: add comment for artic documentation link --- src/features/auth/oauth-config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/auth/oauth-config.ts b/src/features/auth/oauth-config.ts index 8bfaa49b1..c616ffccd 100644 --- a/src/features/auth/oauth-config.ts +++ b/src/features/auth/oauth-config.ts @@ -4,6 +4,8 @@ import { FaDiscord, FaGithub, FaGoogle } from 'react-icons/fa6'; import { entries } from 'remeda'; import { z } from 'zod'; +// See Artic documentation to setup and/or add more providers https://arcticjs.dev/ + export type OAuthProvider = z.infer>; export const zOAuthProvider = () => z.enum(['github', 'google', 'discord']); From 1a50628d3c6ece09f5d0822e51a8c004d8af702a Mon Sep 17 00:00:00 2001 From: Ivan Dalmet Date: Mon, 21 Oct 2024 13:54:27 +0200 Subject: [PATCH 11/11] fix: remove unused some --- src/features/auth/OAuthLogin.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/auth/OAuthLogin.tsx b/src/features/auth/OAuthLogin.tsx index 070fdcae5..461733fee 100644 --- a/src/features/auth/OAuthLogin.tsx +++ b/src/features/auth/OAuthLogin.tsx @@ -77,7 +77,7 @@ export const OAuthLoginButtonsGrid = () => { export const OAuthLoginDivider = () => { const { t } = useTranslation(['common']); - if (!OAUTH_PROVIDERS_ENABLED_ARRAY.some((p) => p.isEnabled)) return null; + if (!OAUTH_PROVIDERS_ENABLED_ARRAY.length) return null; return (