feat(auth): add email verification endpoint for Better Auth

Better Auth generates verification URLs with /api/auth/verify-email path,
but NestJS uses /api/v1 prefix. This adds a passthrough controller to
handle the native Better Auth routes and properly verify user emails.

- Add BetterAuthPassthroughController for /api/auth/* routes
- Add verifyEmail method to BetterAuthService
- Exclude /api/auth/* from global prefix in main.ts
- Register passthrough controller in AuthModule

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-26 20:28:30 +01:00
parent def7249058
commit ad4ae93f29
4 changed files with 103 additions and 3 deletions

View file

@ -1,11 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { BetterAuthPassthroughController } from './better-auth-passthrough.controller';
import { BetterAuthService } from './services/better-auth.service';
import { ReferralsModule } from '../referrals/referrals.module';
@Module({
imports: [forwardRef(() => ReferralsModule)],
controllers: [AuthController],
controllers: [AuthController, BetterAuthPassthroughController],
providers: [BetterAuthService],
exports: [BetterAuthService],
})

View file

@ -0,0 +1,56 @@
/**
* Better Auth Passthrough Controller
*
* This controller handles Better Auth's native routes that are generated
* with the `/api/auth/*` prefix (without the NestJS `/api/v1` prefix).
*
* Routes handled:
* - GET /api/auth/verify-email - Email verification from verification emails
*
* This is necessary because Better Auth generates URLs with `/api/auth/*`
* but our NestJS API uses `/api/v1/*` as the global prefix.
*/
import { Controller, Get, Query, Res, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
import { BetterAuthService } from './services/better-auth.service';
@Controller('api/auth')
export class BetterAuthPassthroughController {
constructor(private readonly betterAuthService: BetterAuthService) {}
/**
* Handle email verification
*
* Better Auth sends verification emails with links to:
* {baseURL}/api/auth/verify-email?token=...
*
* This endpoint calls Better Auth's verifyEmail API and redirects
* the user to the appropriate page.
*/
@Get('verify-email')
async verifyEmail(@Query('token') token: string, @Res() res: Response) {
try {
if (!token) {
return res.redirect('/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`);
} else {
// Redirect to error page
const frontendUrl = process.env.FRONTEND_URL || 'https://mana.how';
return res.redirect(`${frontendUrl}/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`);
}
}
}

View file

@ -938,6 +938,48 @@ export class BetterAuthService {
}
}
/**
* Verify email with token
*
* Verifies the user's email using the token from the verification email.
* Uses Better Auth's verifyEmail API.
*
* @param token - Verification token from email link
* @returns Success status
*/
async verifyEmail(token: string): Promise<{ success: boolean; error?: string }> {
try {
// Better Auth's verifyEmail method
// See: https://www.better-auth.com/docs/authentication/email-verification
const api = this.auth.api as any;
const result = await api.verifyEmail({
query: { token },
});
console.log('[verifyEmail] Result:', result);
return {
success: true,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('[verifyEmail] Error:', errorMessage);
if (errorMessage.includes('invalid') || errorMessage.includes('expired')) {
return {
success: false,
error: 'invalid_or_expired_token',
};
}
return {
success: false,
error: 'verification_failed',
};
}
}
/**
* Get JWKS (JSON Web Key Set)
*

View file

@ -79,9 +79,10 @@ async function bootstrap() {
})
);
// Global prefix (exclude metrics endpoint)
// Global prefix (exclude metrics, health, and Better Auth native routes)
// Better Auth generates verification URLs with /api/auth/* prefix
app.setGlobalPrefix('api/v1', {
exclude: ['metrics', 'health'],
exclude: ['metrics', 'health', 'api/auth/(.*)'],
});
const port = configService.get<number>('port') || 3001;