Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
106 lines
3.1 KiB
TypeScript
106 lines
3.1 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Delete,
|
|
Param,
|
|
UseGuards,
|
|
Request,
|
|
Res,
|
|
BadRequestException,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { Response } from 'express';
|
|
import { SsoService } from './sso.service';
|
|
import { AuthService } from './auth.service';
|
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
|
|
const COOKIE_NAME = 'ledgeriq_rt';
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
@ApiTags('auth')
|
|
@Controller('auth')
|
|
export class SsoController {
|
|
constructor(
|
|
private ssoService: SsoService,
|
|
private authService: AuthService,
|
|
) {}
|
|
|
|
@Get('sso/providers')
|
|
@ApiOperation({ summary: 'Get available SSO providers' })
|
|
getProviders() {
|
|
return this.ssoService.getAvailableProviders();
|
|
}
|
|
|
|
// Google OAuth routes would be:
|
|
// GET /auth/google → passport.authenticate('google')
|
|
// GET /auth/google/callback → passport callback
|
|
// These are registered conditionally in auth.module.ts if env vars are set.
|
|
// For now, we'll add the callback handler:
|
|
|
|
@Get('google/callback')
|
|
@ApiOperation({ summary: 'Google OAuth callback' })
|
|
async googleCallback(@Request() req: any, @Res() res: Response) {
|
|
if (!req.user) {
|
|
return res.redirect('/login?error=sso_failed');
|
|
}
|
|
|
|
const result = await this.authService.generateTokenResponse(req.user);
|
|
|
|
// Set refresh token cookie
|
|
if (result.refreshToken) {
|
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'strict',
|
|
path: '/api/auth',
|
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
// Redirect to app with access token in URL fragment (for SPA to pick up)
|
|
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
|
}
|
|
|
|
@Get('azure/callback')
|
|
@ApiOperation({ summary: 'Azure AD OAuth callback' })
|
|
async azureCallback(@Request() req: any, @Res() res: Response) {
|
|
if (!req.user) {
|
|
return res.redirect('/login?error=sso_failed');
|
|
}
|
|
|
|
const result = await this.authService.generateTokenResponse(req.user);
|
|
|
|
if (result.refreshToken) {
|
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'strict',
|
|
path: '/api/auth',
|
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
return res.redirect(`/sso-callback?token=${result.accessToken}`);
|
|
}
|
|
|
|
@Post('sso/link')
|
|
@ApiOperation({ summary: 'Link SSO provider to current user' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async linkAccount(@Request() req: any) {
|
|
// This would typically be done via the OAuth redirect flow
|
|
// For now, it's a placeholder
|
|
throw new BadRequestException('Use the OAuth redirect flow to link accounts');
|
|
}
|
|
|
|
@Delete('sso/unlink/:provider')
|
|
@ApiOperation({ summary: 'Unlink SSO provider from current user' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async unlinkAccount(@Request() req: any, @Param('provider') provider: string) {
|
|
await this.ssoService.unlinkSsoAccount(req.user.sub, provider);
|
|
return { success: true };
|
|
}
|
|
}
|