mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
✨ 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:
parent
3df7157389
commit
2ccd063628
21 changed files with 285 additions and 30 deletions
|
|
@ -140,7 +140,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -139,7 +139,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -139,7 +139,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password, referralCode);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, referralCode, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { ManaUser } from '$lib/types/auth';
|
||||
import { authService, tokenManager } from '$lib/auth';
|
||||
import type { UserData } from '$lib/auth';
|
||||
|
|
@ -95,7 +96,9 @@ export const authStore = {
|
|||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
if (result.success && !result.needsVerification) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
|
|
|
|||
|
|
@ -127,7 +127,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -124,7 +124,9 @@ export const authStore = {
|
|||
|
||||
try {
|
||||
loading = true;
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (result.success) {
|
||||
// Auto-login after signup
|
||||
|
|
|
|||
|
|
@ -125,7 +125,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -115,7 +115,9 @@ export const auth = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -95,7 +95,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -152,7 +152,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ export const authStore = {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -107,13 +107,22 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
* @param email User email
|
||||
* @param password User password
|
||||
* @param referralCode Optional referral code for bonus credits
|
||||
* @param sourceAppUrl Optional URL of the app where the user is registering
|
||||
*/
|
||||
async signUp(email: string, password: string, referralCode?: string): Promise<AuthResult> {
|
||||
async signUp(
|
||||
email: string,
|
||||
password: string,
|
||||
referralCode?: string,
|
||||
sourceAppUrl?: string
|
||||
): Promise<AuthResult> {
|
||||
try {
|
||||
const body: Record<string, string> = { email, password };
|
||||
if (referralCode) {
|
||||
body.referralCode = referralCode;
|
||||
}
|
||||
if (sourceAppUrl) {
|
||||
body.sourceAppUrl = sourceAppUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export class AuthController {
|
|||
email: registerDto.email,
|
||||
password: registerDto.password,
|
||||
name: registerDto.name || '',
|
||||
sourceAppUrl: registerDto.sourceAppUrl,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
104
services/mana-core-auth/src/auth/stores/source-app.store.ts
Normal file
104
services/mana-core-auth/src/auth/stores/source-app.store.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
|
|
@ -376,6 +376,7 @@ export interface RegisterB2CDto {
|
|||
name: string;
|
||||
referralCode?: string;
|
||||
sourceAppId?: string;
|
||||
sourceAppUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue