feat(auth): redirect users to source app after email verification

Add sourceAppUrl tracking during registration to redirect users back
to the app they registered from after email verification. Includes
URL validation for security (only *.mana.how, mana.how, localhost).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-27 01:31:31 +01:00
parent 3df7157389
commit 2ccd063628
21 changed files with 285 additions and 30 deletions

View file

@ -62,6 +62,7 @@ export class AuthController {
email: registerDto.email,
password: registerDto.password,
name: registerDto.name || '',
sourceAppUrl: registerDto.sourceAppUrl,
});
}

View file

@ -17,40 +17,103 @@ import { BetterAuthService } from './services/better-auth.service';
@Controller('api/auth')
export class BetterAuthPassthroughController {
private readonly defaultFrontendUrl = 'https://mana.how';
constructor(private readonly betterAuthService: BetterAuthService) {}
/**
* Validate redirect URL for security
*
* Only allows redirects to:
* - *.mana.how domains
* - mana.how (main domain)
* - localhost (for development)
*
* @param redirectTo - URL to validate
* @returns Validated origin URL or null if invalid
*/
private validateRedirectUrl(redirectTo?: string): string | null {
if (!redirectTo) return null;
try {
const url = new URL(redirectTo);
// Allow *.mana.how, mana.how, and localhost
if (
url.hostname.endsWith('.mana.how') ||
url.hostname === 'mana.how' ||
url.hostname === 'localhost'
) {
return url.origin;
}
} catch {
// Invalid URL, return null
}
return null;
}
/**
* Handle email verification
*
* Better Auth sends verification emails with links to:
* {baseURL}/api/auth/verify-email?token=...
* {baseURL}/api/auth/verify-email?token=...&redirectTo=...
*
* This endpoint calls Better Auth's verifyEmail API and redirects
* the user to the appropriate page.
* This endpoint:
* 1. Calls Better Auth's verifyEmail API
* 2. Gets the source app URL from the store (set during registration)
* 3. Redirects the user to the app's login page with verified=true and email
*/
@Get('verify-email')
async verifyEmail(@Query('token') token: string, @Res() res: Response) {
async verifyEmail(
@Query('token') token: string,
@Query('redirectTo') redirectTo: string | undefined,
@Res() res: Response
) {
const fallbackUrl = process.env.FRONTEND_URL || this.defaultFrontendUrl;
try {
if (!token) {
return res.redirect('/verification-failed?error=missing_token');
return res.redirect(`${fallbackUrl}/verification-failed?error=missing_token`);
}
// Call Better Auth's verifyEmail API
const result = await this.betterAuthService.verifyEmail(token);
if (result.success) {
// Redirect to success page (frontend should handle this)
const frontendUrl = process.env.FRONTEND_URL || 'https://mana.how';
return res.redirect(`${frontendUrl}/email-verified`);
const email = result.email || '';
// Determine redirect URL:
// 1. First try the redirectTo query param (passed through URL)
// 2. Then try the sourceAppStore (set during registration)
// 3. Finally fall back to default frontend URL
let baseUrl = this.validateRedirectUrl(redirectTo);
if (!baseUrl && email) {
// Try to get source app URL from store (set during registration)
const storedUrl = this.betterAuthService.getSourceAppUrl(email);
baseUrl = this.validateRedirectUrl(storedUrl || undefined);
}
if (!baseUrl) {
baseUrl = fallbackUrl;
}
// Redirect to app's login page with verified=true and email
const loginUrl = new URL('/login', baseUrl);
loginUrl.searchParams.set('verified', 'true');
if (email) {
loginUrl.searchParams.set('email', email);
}
return res.redirect(loginUrl.toString());
} else {
// Redirect to error page
const frontendUrl = process.env.FRONTEND_URL || 'https://mana.how';
return res.redirect(`${frontendUrl}/verification-failed?error=${result.error}`);
return res.redirect(`${fallbackUrl}/verification-failed?error=${result.error}`);
}
} catch (error) {
console.error('[verify-email] Error:', error);
const frontendUrl = process.env.FRONTEND_URL || 'https://mana.how';
return res.redirect(`${frontendUrl}/verification-failed?error=verification_failed`);
return res.redirect(`${fallbackUrl}/verification-failed?error=verification_failed`);
}
}
}

View file

@ -27,6 +27,7 @@ import {
sendInvitationEmail,
sendVerificationEmail,
} from '../email/email.service';
import { sourceAppStore } from './stores/source-app.store';
/**
* JWT Custom Payload Interface
@ -117,6 +118,9 @@ export function createBetterAuth(databaseUrl: string) {
*
* Sends verification email when user registers.
* User must verify email before they can log in.
*
* The verification URL is modified to include redirectTo parameter
* so users are redirected back to the app they registered from.
*/
emailVerification: {
sendOnSignUp: true,
@ -128,7 +132,20 @@ export function createBetterAuth(databaseUrl: string) {
user: { email: string; name: string };
url: string;
}) => {
await sendVerificationEmail(user.email, url, user.name);
// Check if we have a source app URL stored for this user
// Note: We get the URL without deleting it here since it might be needed
// during the verification process in the passthrough controller
const sourceAppUrl = sourceAppStore.get(user.email);
// Modify verification URL to include redirectTo parameter
let verificationUrl = url;
if (sourceAppUrl) {
const urlObj = new URL(url);
urlObj.searchParams.set('redirectTo', sourceAppUrl);
verificationUrl = urlObj.toString();
}
await sendVerificationEmail(user.email, verificationUrl, user.name);
},
},

View file

@ -1,4 +1,4 @@
import { IsEmail, IsString, MinLength, MaxLength, IsOptional } from 'class-validator';
import { IsEmail, IsString, MinLength, MaxLength, IsOptional, IsUrl } from 'class-validator';
export class RegisterDto {
@IsEmail()
@ -13,4 +13,10 @@ export class RegisterDto {
@IsOptional()
@MaxLength(255)
name?: string;
@IsString()
@IsOptional()
@IsUrl({ require_tld: false }) // Allow localhost URLs for development
@MaxLength(255)
sourceAppUrl?: string;
}

View file

@ -32,6 +32,7 @@ import { ReferralCodeService } from '../../referrals/services/referral-code.serv
import { ReferralTierService } from '../../referrals/services/referral-tier.service';
import { ReferralTrackingService } from '../../referrals/services/referral-tracking.service';
import { hasUser, hasToken, hasMember, hasMembers, hasSession } from '../types/better-auth.types';
import { sourceAppStore } from '../stores/source-app.store';
import type {
RegisterB2CDto,
RegisterB2BDto,
@ -125,6 +126,11 @@ export class BetterAuthService {
*/
async registerB2C(dto: RegisterB2CDto): Promise<RegisterB2CResult> {
try {
// Store source app URL before registration (for email verification redirect)
if (dto.sourceAppUrl) {
sourceAppStore.set(dto.email, dto.sourceAppUrl);
}
// Create user via Better Auth
const result = await this.auth.api.signUpEmail({
body: {
@ -945,9 +951,9 @@ export class BetterAuthService {
* Uses Better Auth's verifyEmail API.
*
* @param token - Verification token from email link
* @returns Success status
* @returns Success status and user email
*/
async verifyEmail(token: string): Promise<{ success: boolean; error?: string }> {
async verifyEmail(token: string): Promise<{ success: boolean; email?: string; error?: string }> {
try {
// Better Auth's verifyEmail method
// See: https://www.better-auth.com/docs/authentication/email-verification
@ -959,8 +965,12 @@ export class BetterAuthService {
console.log('[verifyEmail] Result:', result);
// Extract email from result if available
const email = result?.user?.email || result?.email;
return {
success: true,
email,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
@ -1020,6 +1030,23 @@ export class BetterAuthService {
}
}
// =========================================================================
// Source App URL Methods
// =========================================================================
/**
* Get and remove source app URL for an email
*
* Used after email verification to redirect user to the correct app.
* The entry is deleted after retrieval to prevent re-use.
*
* @param email - User's email address
* @returns Source app URL or null if not found
*/
getSourceAppUrl(email: string): string | null {
return sourceAppStore.getAndDelete(email);
}
// =========================================================================
// Private Helper Methods
// =========================================================================

View file

@ -0,0 +1,104 @@
/**
* Source App Store
*
* Temporary in-memory store for tracking which app a user registered from.
* This allows redirecting users back to the correct app's login page
* after email verification.
*
* TTL: 24 hours (matches verification token expiry)
*/
interface SourceAppEntry {
sourceAppUrl: string;
expiresAt: number;
}
// In-memory store: email -> { sourceAppUrl, expiresAt }
const store = new Map<string, SourceAppEntry>();
// TTL in milliseconds (24 hours)
const TTL_MS = 24 * 60 * 60 * 1000;
// Cleanup interval (every hour)
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
// Start cleanup interval
setInterval(() => {
const now = Date.now();
for (const [email, entry] of store.entries()) {
if (entry.expiresAt < now) {
store.delete(email);
}
}
}, CLEANUP_INTERVAL_MS);
export const sourceAppStore = {
/**
* Store the source app URL for an email
*/
set(email: string, sourceAppUrl: string): void {
const normalizedEmail = email.toLowerCase().trim();
store.set(normalizedEmail, {
sourceAppUrl,
expiresAt: Date.now() + TTL_MS,
});
},
/**
* Get the source app URL for an email
* Returns null if not found or expired
*/
get(email: string): string | null {
const normalizedEmail = email.toLowerCase().trim();
const entry = store.get(normalizedEmail);
if (!entry) {
return null;
}
// Check if expired
if (entry.expiresAt < Date.now()) {
store.delete(normalizedEmail);
return null;
}
return entry.sourceAppUrl;
},
/**
* Get and remove the source app URL for an email
* This is used after verification to prevent re-use
*/
getAndDelete(email: string): string | null {
const normalizedEmail = email.toLowerCase().trim();
const entry = store.get(normalizedEmail);
if (!entry) {
return null;
}
store.delete(normalizedEmail);
// Check if expired
if (entry.expiresAt < Date.now()) {
return null;
}
return entry.sourceAppUrl;
},
/**
* Remove entry for an email
*/
delete(email: string): void {
const normalizedEmail = email.toLowerCase().trim();
store.delete(normalizedEmail);
},
/**
* Clear all entries (for testing)
*/
clear(): void {
store.clear();
},
};

View file

@ -376,6 +376,7 @@ export interface RegisterB2CDto {
name: string;
referralCode?: string;
sourceAppId?: string;
sourceAppUrl?: string;
}
/**