Files
myeasycms-v2/packages/supabase/src/auth-callback.service.ts
Giancarlo Buomprisco 7ebff31475 Next.js Supabase V3 (#463)
Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
2026-03-24 13:40:38 +08:00

321 lines
8.0 KiB
TypeScript

import 'server-only';
import {
AuthError,
type EmailOtpType,
SupabaseClient,
} from '@supabase/supabase-js';
import { isSafeRedirectPath } from '@kit/shared/utils';
/**
* @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 redirectInfo = this.parseRedirectDestination(
searchParams.get('next') ?? searchParams.get('callback'),
);
let nextPath: string | null = null;
// if we have a valid redirect destination
if (redirectInfo) {
nextPath = redirectInfo.path;
// preserve any query params from the redirect URL (e.g., invite_token)
// but exclude 'next' to avoid duplication
redirectInfo.params.forEach((value, key) => {
if (key !== 'next') {
url.searchParams.set(key, value);
}
});
}
const errorPath = params.errorPath ?? '/auth/callback/error';
// remove the auth-related query params from the url
searchParams.delete('token_hash');
searchParams.delete('type');
searchParams.delete('next');
searchParams.delete('callback');
// if we have a next path, we redirect to that path
if (nextPath) {
url.pathname = nextPath;
}
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 request
* @param params
*/
async exchangeCodeForSession(
request: Request,
params: {
joinTeamPath: string;
redirectPath: string;
errorPath?: string;
},
): Promise<{
nextPath: string;
}> {
const requestUrl = new URL(request.url);
const searchParams = requestUrl.searchParams;
const authCode = searchParams.get('code');
const error = searchParams.get('error');
const nextUrlPathFromParams = searchParams.get('next');
const errorPath = params.errorPath ?? '/auth/callback/error';
// Validate the next URL to prevent open redirect attacks
const nextUrl =
nextUrlPathFromParams && isSafeRedirectPath(nextUrlPathFromParams)
? nextUrlPathFromParams
: params.redirectPath;
if (authCode) {
try {
const { error } =
await this.client.auth.exchangeCodeForSession(authCode);
// if we have an error, we redirect to the error page
if (error) {
return onError({
code: error.code,
error: error.message,
path: errorPath,
});
}
} 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 onError({
code: (error as AuthError)?.code,
error: message as string,
path: errorPath,
});
}
}
if (error) {
return onError({
error,
path: errorPath,
});
}
return {
nextPath: nextUrl,
};
}
private adjustUrlHostForLocalDevelopment(url: URL, host: string | null) {
if (host && this.isLocalhost(url.host) && !this.isLocalhost(host)) {
url.host = host;
url.port = '';
}
}
/**
* Parses a redirect URL and extracts the destination path and query params
* Handles nested 'next' parameters for chained redirects
* Validates paths to prevent open redirect attacks
*/
private parseRedirectDestination(redirectParam: string | null): {
path: string;
params: URLSearchParams;
} | null {
if (!redirectParam) {
return null;
}
// First, try as a simple relative path with optional query string
const [pathPart, queryPart] = redirectParam.split('?') as [
string,
string | undefined,
];
if (isSafeRedirectPath(pathPart)) {
return {
path: pathPart,
params: new URLSearchParams(queryPart ?? ''),
};
}
// Handle full URLs (e.g., from Supabase callback parameter)
try {
const url = new URL(redirectParam);
// Check for nested 'next' parameter - this is the final destination
const nestedNext = url.searchParams.get('next');
if (nestedNext && isSafeRedirectPath(nestedNext)) {
return {
path: nestedNext,
params: url.searchParams,
};
}
// No nested next, use pathname if safe
if (isSafeRedirectPath(url.pathname)) {
return {
path: url.pathname,
params: url.searchParams,
};
}
} catch {
// Invalid URL, ignore
}
return null;
}
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:')
);
}
}
function onError({
error,
path,
code,
}: {
error: string;
path: string;
code?: string;
}) {
const errorMessage = getAuthErrorMessage({ error, code });
console.error(
{
error: JSON.stringify(error).replace(/["\\]/g, '\\$&'),
name: `auth.callback`,
},
`An error occurred while signing user in`,
);
const searchParams = new URLSearchParams({
error: errorMessage,
code: code ?? '',
});
const nextPath = `${path}?${searchParams.toString()}`;
return {
nextPath,
};
}
/**
* 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`;
}