Files
medreport_mrb2b/packages/supabase/src/auth-callback.service.ts
Danel Kungla 9ed52dcf02 add readme
delete unrequired configs
2025-09-19 18:07:31 +03:00

347 lines
9.2 KiB
TypeScript

import 'server-only';
import getBaseWebpackConfig from 'next/dist/build/webpack-config';
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<URL> {
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<void> {
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`;
}