347 lines
9.2 KiB
TypeScript
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`;
|
|
}
|