feat: SaaS onboarding, Stripe billing, MFA, SSO, passkeys, refresh tokens

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>
This commit is contained in:
2026-03-16 21:12:35 -04:00
parent 17bdebfb52
commit dfcd172ef3
39 changed files with 4673 additions and 82 deletions

View File

@@ -0,0 +1,112 @@
import {
Controller,
Post,
Get,
Delete,
Param,
Body,
UseGuards,
Request,
Res,
BadRequestException,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { Response } from 'express';
import { PasskeyService } from './passkey.service';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
const COOKIE_NAME = 'ledgeriq_rt';
const isProduction = process.env.NODE_ENV === 'production';
@ApiTags('auth')
@Controller('auth/passkeys')
export class PasskeyController {
constructor(
private passkeyService: PasskeyService,
private authService: AuthService,
private usersService: UsersService,
) {}
@Post('register-options')
@ApiOperation({ summary: 'Get passkey registration options' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async getRegistrationOptions(@Request() req: any) {
return this.passkeyService.generateRegistrationOptions(req.user.sub);
}
@Post('register')
@ApiOperation({ summary: 'Register a new passkey' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async register(
@Request() req: any,
@Body() body: { response: any; deviceName?: string },
) {
if (!body.response) throw new BadRequestException('Attestation response required');
return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName);
}
@Post('login-options')
@ApiOperation({ summary: 'Get passkey login options' })
@Throttle({ default: { limit: 10, ttl: 60000 } })
async getLoginOptions(@Body() body: { email?: string }) {
return this.passkeyService.generateAuthenticationOptions(body.email);
}
@Post('login')
@ApiOperation({ summary: 'Authenticate with passkey' })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(
@Body() body: { response: any; challenge: string },
@Res({ passthrough: true }) res: Response,
) {
if (!body.response || !body.challenge) {
throw new BadRequestException('Assertion response and challenge required');
}
const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge);
// Get user with orgs and generate session
const user = await this.usersService.findByIdWithOrgs(userId);
if (!user) throw new BadRequestException('User not found');
await this.usersService.updateLastLogin(userId);
const result = await this.authService.generateTokenResponse(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,
});
}
const { refreshToken: _rt, ...response } = result;
return response;
}
@Get()
@ApiOperation({ summary: 'List registered passkeys' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@AllowViewer()
async list(@Request() req: any) {
return this.passkeyService.listPasskeys(req.user.sub);
}
@Delete(':id')
@ApiOperation({ summary: 'Remove a passkey' })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async remove(@Request() req: any, @Param('id') passkeyId: string) {
await this.passkeyService.removePasskey(req.user.sub, passkeyId);
return { success: true };
}
}