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>
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
interface SsoProfile {
|
|
provider: string;
|
|
providerId: string;
|
|
email: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SsoService {
|
|
private readonly logger = new Logger(SsoService.name);
|
|
|
|
constructor(
|
|
private dataSource: DataSource,
|
|
private usersService: UsersService,
|
|
) {}
|
|
|
|
/**
|
|
* Find existing user by SSO provider+id, or by email match, or create new.
|
|
*/
|
|
async findOrCreateSsoUser(profile: SsoProfile) {
|
|
// 1. Try to find by provider + provider ID
|
|
const byProvider = await this.dataSource.query(
|
|
`SELECT * FROM shared.users WHERE oauth_provider = $1 AND oauth_provider_id = $2`,
|
|
[profile.provider, profile.providerId],
|
|
);
|
|
if (byProvider.length > 0) {
|
|
return this.usersService.findByIdWithOrgs(byProvider[0].id);
|
|
}
|
|
|
|
// 2. Try to find by email match (link accounts)
|
|
const byEmail = await this.usersService.findByEmail(profile.email);
|
|
if (byEmail) {
|
|
// Link the SSO provider to existing account
|
|
await this.linkSsoAccount(byEmail.id, profile.provider, profile.providerId);
|
|
return this.usersService.findByIdWithOrgs(byEmail.id);
|
|
}
|
|
|
|
// 3. Create new user
|
|
const newUser = await this.dataSource.query(
|
|
`INSERT INTO shared.users (email, first_name, last_name, oauth_provider, oauth_provider_id, is_email_verified)
|
|
VALUES ($1, $2, $3, $4, $5, true)
|
|
RETURNING id`,
|
|
[profile.email, profile.firstName || '', profile.lastName || '', profile.provider, profile.providerId],
|
|
);
|
|
|
|
return this.usersService.findByIdWithOrgs(newUser[0].id);
|
|
}
|
|
|
|
/**
|
|
* Link an SSO provider to an existing user.
|
|
*/
|
|
async linkSsoAccount(userId: string, provider: string, providerId: string): Promise<void> {
|
|
await this.dataSource.query(
|
|
`UPDATE shared.users SET oauth_provider = $1, oauth_provider_id = $2, updated_at = NOW() WHERE id = $3`,
|
|
[provider, providerId, userId],
|
|
);
|
|
this.logger.log(`Linked ${provider} SSO to user ${userId}`);
|
|
}
|
|
|
|
/**
|
|
* Unlink SSO from a user (only if they have a password set).
|
|
*/
|
|
async unlinkSsoAccount(userId: string, provider: string): Promise<void> {
|
|
const rows = await this.dataSource.query(
|
|
`SELECT password_hash, oauth_provider FROM shared.users WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
if (rows.length === 0) throw new BadRequestException('User not found');
|
|
if (!rows[0].password_hash) {
|
|
throw new BadRequestException('Cannot unlink SSO — you must set a password first');
|
|
}
|
|
if (rows[0].oauth_provider !== provider) {
|
|
throw new BadRequestException('SSO provider mismatch');
|
|
}
|
|
|
|
await this.dataSource.query(
|
|
`UPDATE shared.users SET oauth_provider = NULL, oauth_provider_id = NULL, updated_at = NOW() WHERE id = $1`,
|
|
[userId],
|
|
);
|
|
this.logger.log(`Unlinked ${provider} SSO from user ${userId}`);
|
|
}
|
|
|
|
/**
|
|
* Get which SSO providers are configured.
|
|
*/
|
|
getAvailableProviders(): { google: boolean; azure: boolean } {
|
|
return {
|
|
google: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
|
azure: !!(process.env.AZURE_CLIENT_ID && process.env.AZURE_CLIENT_SECRET),
|
|
};
|
|
}
|
|
}
|