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 3c44f15fa..0286126b2 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 e03350036..76957849e 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) @@ -4489,6 +4492,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'} @@ -15564,6 +15570,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..7fa706a2c 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -3,9 +3,6 @@ import { createEnv } from '@t3-oss/env-nextjs'; import { z } from 'zod'; -const zNodeEnv = () => - z.enum(['development', 'test', 'production']).default('development'); - export const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app @@ -15,6 +12,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 +83,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, @@ -92,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)); +} diff --git a/src/features/auth/OAuthLogin.tsx b/src/features/auth/OAuthLogin.tsx new file mode 100644 index 000000000..461733fee --- /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 { toastCustom } from '@/components/Toast'; +import { + OAUTH_PROVIDERS, + OAUTH_PROVIDERS_ENABLED_ARRAY, + OAuthProvider, +} from '@/features/auth/oauth-config'; +import { trpc } from '@/lib/trpc/client'; + +export const OAuthLoginButton = ({ + provider, + ...rest +}: { + provider: OAuthProvider; +} & ButtonProps) => { + const { t } = useTranslation(['auth']); + const router = useRouter(); + const loginWith = trpc.oauth.createAuthorizationUrl.useMutation({ + onSuccess: (data) => { + router.push(data.url); + }, + onError: (error) => { + toastCustom({ + status: 'error', + title: t('auth:login.feedbacks.oAuthError.title', { + provider: OAUTH_PROVIDERS[provider].label, + }), + description: error.message, + }); + }, + }); + + return ( + + ); +}; + +export const OAuthLoginButtonsGrid = () => { + if (!OAUTH_PROVIDERS_ENABLED_ARRAY.length) return null; + return ( + + {OAUTH_PROVIDERS_ENABLED_ARRAY.map(({ provider }) => { + return ( + + ); + })} + + ); +}; + +export const OAuthLoginDivider = () => { + const { t } = useTranslation(['common']); + if (!OAUTH_PROVIDERS_ENABLED_ARRAY.length) 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..3cf830074 --- /dev/null +++ b/src/features/auth/PageOAuthCallback.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef } from 'react'; + +import { + notFound, + useParams, + useRouter, + useSearchParams, +} from 'next/navigation'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; + +import { LoaderFull } from '@/components/LoaderFull'; +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'; +import { ROUTES_AUTH } from '@/features/auth/routes'; +import { trpc } from '@/lib/trpc/client'; + +export default function PageOAuthCallback() { + const { i18n, t } = useTranslation(['auth']); + const router = useRouter(); + const isTriggeredRef = useRef(false); + const params = z + .object({ provider: zOAuthProvider() }) + .safeParse(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: () => { + toastCustom({ + status: 'error', + title: t('auth:login.feedbacks.loginError.title'), + }); + router.replace(ROUTES_AUTH.login()); + }, + }); + + useEffect(() => { + const trigger = () => { + if (isTriggeredRef.current) return; + isTriggeredRef.current = true; + + if (!(params.success && searchParams.success)) { + notFound(); + } + + validateLogin.mutate({ + provider: params.data.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..43eb33230 100644 --- a/src/features/auth/PageRegister.tsx +++ b/src/features/auth/PageRegister.tsx @@ -14,6 +14,10 @@ import { FormFieldLabel, } from '@/components/Form'; import { toastCustom } 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/features/auth/oauth-config.ts b/src/features/auth/oauth-config.ts new file mode 100644 index 000000000..c616ffccd --- /dev/null +++ b/src/features/auth/oauth-config.ts @@ -0,0 +1,42 @@ +import { FC } from 'react'; + +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']); + +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..9e577221c --- /dev/null +++ b/src/server/config/oauth/index.ts @@ -0,0 +1,14 @@ +import { match } from 'ts-pattern'; + +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'; + +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..6936ccb27 --- /dev/null +++ b/src/server/config/oauth/providers/discord.ts @@ -0,0 +1,86 @@ +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(), + username: z.string().nullish(), + 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 environment variables', + }); + } + return await discordClient.createAuthorizationURL(state, { + scopes: ['identify', 'email'], + }); + }, + validateAuthorizationCode: async (code) => { + if (!discordClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing Discord environment 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.info('User data retrieved from Discord'); + + 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 ?? 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 new file mode 100644 index 000000000..3bec9af32 --- /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 environment variables', + }); + } + return await githubClient.createAuthorizationURL(state, { + scopes: ['user:email'], + }); + }, + validateAuthorizationCode: async (code: string) => { + if (!githubClient) { + throw new TRPCError({ + code: 'NOT_IMPLEMENTED', + message: 'Missing GitHub environment 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.info('Retrieved emails from GitHub'); + + 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.info('User data retrieved from GitHub'); + + 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..8b727f4bd --- /dev/null +++ b/src/server/config/oauth/providers/google.ts @@ -0,0 +1,98 @@ +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 environment variables', + }); + } + if (!codeVerifier) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '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 environment variables', + }); + } + if (!codeVerifier) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '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.info('User data retrieved from Google'); + + 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..eb7f4ffe4 --- /dev/null +++ b/src/server/config/oauth/utils.ts @@ -0,0 +1,25 @@ +import { env } from '@/env.mjs'; +import { OAuthProvider } from '@/features/auth/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..1cd731ee6 --- /dev/null +++ b/src/server/routers/oauth.tsx @@ -0,0 +1,282 @@ +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 { OAUTH_PROVIDERS, zOAuthProvider } from '@/features/auth/oauth-config'; +import { DEFAULT_LANGUAGE_KEY } from '@/lib/i18n/constants'; +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('Invalid or expired authorization request'); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid or expired authorization request', + }); + } + + 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 { + 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') { + ctx.logger.info('Account is disabled'); + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: + 'Unable to authenticate. Please contact support if this issue persists.', + }); + } + + if (existingUser?.accountStatus === 'NOT_VERIFIED') { + ctx.logger.error('Account should not be NOT_VERIFIED at this point'); + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Please verify your account to proceed', + }); + } + + if (existingUser) { + ctx.logger.info('Create the session for the existing user'); + const sessionId = await createSession(existingUser.id); + + return { + account: existingUser, + token: sessionId, + }; + } + + 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, + 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, + }; + }), +});