import 'server-only'; import { AuthError, type EmailOtpType, SupabaseClient, User, } from '@supabase/supabase-js'; /** * @name createAuthCallbackService * @description Creates an instance of the AuthCallbackService * @param client */ export function createAuthCallbackService(client: SupabaseClient) { return new AuthCallbackService(client); } /** * @name AuthCallbackService * @description Service for handling auth callbacks in Supabase */ class AuthCallbackService { constructor(private readonly client: SupabaseClient) { } /** * @name verifyTokenHash * @description Verifies the token hash and type and redirects the user to the next page * This should be used when using a token hash to verify the user's email * @param request * @param params */ async verifyTokenHash( request: Request, params: { joinTeamPath: string; redirectPath: string; errorPath?: string; }, ): Promise { const url = new URL(request.url); const searchParams = url.searchParams; const host = request.headers.get('host'); // set the host to the request host since outside of Vercel it gets set as "localhost" or "0.0.0.0" this.adjustUrlHostForLocalDevelopment(url, host); url.pathname = params.redirectPath; const token_hash = searchParams.get('token_hash'); const type = searchParams.get('type') as EmailOtpType | null; const callbackParam = searchParams.get('next') ?? searchParams.get('callback'); let nextPath: string | null = null; const callbackUrl = callbackParam ? new URL(callbackParam) : null; // if we have a callback url, we check if it has a next path if (callbackUrl) { // if we have a callback url, we check if it has a next path const callbackNextPath = callbackUrl.searchParams.get('next'); // if we have a next path in the callback url, we use that if (callbackNextPath) { nextPath = callbackNextPath; } else { nextPath = callbackUrl.pathname; } } const inviteToken = callbackUrl?.searchParams.get('invite_token'); const errorPath = params.errorPath ?? '/auth/callback/error'; // remove the query params from the url searchParams.delete('token_hash'); searchParams.delete('type'); searchParams.delete('next'); // if we have a next path, we redirect to that path if (nextPath) { url.pathname = nextPath; } // if we have an invite token, we append it to the redirect url if (inviteToken) { // if we have an invite token, we redirect to the join team page // instead of the default next url. This is because the user is trying // to join a team and we want to make sure they are redirected to the // correct page. url.pathname = params.joinTeamPath; searchParams.set('invite_token', inviteToken); const emailParam = callbackUrl?.searchParams.get('email'); if (emailParam) { searchParams.set('email', emailParam); } } if (token_hash && type) { const { error } = await this.client.auth.verifyOtp({ type, token_hash, }); if (!error) { return url; } if (error.code) { url.searchParams.set('code', error.code); } const errorMessage = getAuthErrorMessage({ error: error.message, code: error.code, }); url.searchParams.set('error', errorMessage); } // return the user to an error page with some instructions url.pathname = errorPath; return url; } /** * @name exchangeCodeForSession * @description Exchanges the auth code for a session and redirects the user to the next page or an error page * @param authCode */ async exchangeCodeForSession(authCode: string): Promise<{ isSuccess: boolean; user: User; } | ErrorURLParameters> { let user: User; try { const { data, error } = await this.client.auth.exchangeCodeForSession(authCode); // if we have an error, we redirect to the error page if (error) { return getErrorURLParameters({ code: error.code, error: error.message, }); } // Handle Keycloak users - set up Medusa integration if (data?.user && this.isKeycloakUser(data.user)) { await this.setupMedusaUserForKeycloak(data.user); } user = data.user; } catch (error) { console.error( { error, name: `auth.callback`, }, `An error occurred while exchanging code for session`, ); const message = error instanceof Error ? error.message : error; return getErrorURLParameters({ code: (error as AuthError)?.code, error: message as string, }); } return { isSuccess: true, user, }; } /** * Check if user is from Keycloak provider */ private isKeycloakUser(user: any): boolean { return user?.app_metadata?.provider === 'keycloak' || user?.app_metadata?.providers?.includes('keycloak'); } private async setupMedusaUserForKeycloak(user: any): Promise { if (!user.email) { console.warn('Keycloak user has no email, skipping Medusa setup'); return; } try { // Check if user already has medusa_account_id const { data: accountData, error: fetchError } = await this.client .schema('medreport') .from('accounts') .select('medusa_account_id, name, last_name') .eq('primary_owner_user_id', user.id) .eq('is_personal_account', true) .single(); if (fetchError && fetchError.code !== 'PGRST116') { console.error('Error fetching account data for Keycloak user:', fetchError); return; } const { medusaLoginOrRegister } = await import('../../features/medusa-storefront/src/lib/data/customer'); const medusaAccountId = await medusaLoginOrRegister({ email: user.email, supabaseUserId: user.id, name: accountData?.name ?? '-', lastName: accountData?.last_name ?? '-', }); const currentMedusaAccountId = accountData?.medusa_account_id; if (!currentMedusaAccountId || currentMedusaAccountId !== medusaAccountId) { const { error: updateError } = await this.client .schema('medreport') .from('accounts') .update({ medusa_account_id: medusaAccountId }) .eq('primary_owner_user_id', user.id) .eq('is_personal_account', true); if (updateError) { console.error('Error updating account with Medusa ID:', updateError); return; } console.log('Successfully set up Medusa account for Keycloak user:', medusaAccountId); } else { console.log('Keycloak user already has Medusa account:', accountData.medusa_account_id); } } catch (error) { console.error('Error setting up Medusa account for Keycloak user:', error); } } private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) { if (this.isLocalhost(url.host) && !this.isLocalhost(host)) { url.host = host as string; url.port = ''; } } private isLocalhost(host: string | null) { if (!host) { return false; } return ( host.includes('localhost:') || host.includes('0.0.0.0:') || host.includes('127.0.0.1:') ); } } interface ErrorURLParameters { error: string; code?: string; searchParams: string; } export function getErrorURLParameters({ error, code, }: { error: string; code?: string; }): ErrorURLParameters { const errorMessage = getAuthErrorMessage({ error, code }); console.error( { error, name: `auth.callback`, }, `An error occurred while signing user in`, ); const searchParams = new URLSearchParams({ error: errorMessage, code: code ?? '', }); return { error: errorMessage, code: code ?? '', searchParams: searchParams.toString(), }; } /** * Checks if the given error message indicates a verifier error. * We check for this specific error because it's highly likely that the * user is trying to sign in using a different browser than the one they * used to request the sign in link. This is a common mistake, so we * want to provide a helpful error message. */ function isVerifierError(error: string) { return error.includes('both auth code and code verifier should be non-empty'); } function getAuthErrorMessage(params: { error: string; code?: string }) { // this error arises when the user tries to sign in with an expired email link if (params.code) { if (params.code === 'otp_expired') { return 'auth:errors.otp_expired'; } } // this error arises when the user is trying to sign in with a different // browser than the one they used to request the sign in link if (isVerifierError(params.error)) { return 'auth:errors.codeVerifierMismatch'; } // fallback to the default error message return `auth:authenticationErrorAlertBody`; }