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>
99 lines
2.8 KiB
TypeScript
99 lines
2.8 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { DataSource } from 'typeorm';
|
|
import { randomBytes, createHash } from 'crypto';
|
|
|
|
@Injectable()
|
|
export class RefreshTokenService {
|
|
private readonly logger = new Logger(RefreshTokenService.name);
|
|
|
|
constructor(private dataSource: DataSource) {}
|
|
|
|
/**
|
|
* Create a new refresh token for a user.
|
|
* Returns the raw (unhashed) token to be sent as an httpOnly cookie.
|
|
*/
|
|
async createRefreshToken(userId: string): Promise<string> {
|
|
const rawToken = randomBytes(64).toString('base64url');
|
|
const tokenHash = this.hashToken(rawToken);
|
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
|
|
|
await this.dataSource.query(
|
|
`INSERT INTO shared.refresh_tokens (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)`,
|
|
[userId, tokenHash, expiresAt],
|
|
);
|
|
|
|
return rawToken;
|
|
}
|
|
|
|
/**
|
|
* Validate a refresh token. Returns the user_id if valid, null otherwise.
|
|
*/
|
|
async validateRefreshToken(rawToken: string): Promise<string | null> {
|
|
const tokenHash = this.hashToken(rawToken);
|
|
|
|
const rows = await this.dataSource.query(
|
|
`SELECT user_id, expires_at, revoked_at
|
|
FROM shared.refresh_tokens
|
|
WHERE token_hash = $1`,
|
|
[tokenHash],
|
|
);
|
|
|
|
if (rows.length === 0) return null;
|
|
|
|
const { user_id, expires_at, revoked_at } = rows[0];
|
|
|
|
// Check if revoked
|
|
if (revoked_at) return null;
|
|
|
|
// Check if expired
|
|
if (new Date(expires_at) < new Date()) return null;
|
|
|
|
return user_id;
|
|
}
|
|
|
|
/**
|
|
* Revoke a single refresh token.
|
|
*/
|
|
async revokeToken(rawToken: string): Promise<void> {
|
|
const tokenHash = this.hashToken(rawToken);
|
|
|
|
await this.dataSource.query(
|
|
`UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`,
|
|
[tokenHash],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Revoke all refresh tokens for a user ("log out everywhere").
|
|
*/
|
|
async revokeAllUserTokens(userId: string): Promise<void> {
|
|
await this.dataSource.query(
|
|
`UPDATE shared.refresh_tokens SET revoked_at = NOW()
|
|
WHERE user_id = $1 AND revoked_at IS NULL`,
|
|
[userId],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Remove expired / revoked tokens older than 7 days.
|
|
* Called periodically to keep the table clean.
|
|
*/
|
|
async cleanupExpired(): Promise<number> {
|
|
const result = await this.dataSource.query(
|
|
`DELETE FROM shared.refresh_tokens
|
|
WHERE (expires_at < NOW() - INTERVAL '7 days')
|
|
OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')`,
|
|
);
|
|
const deleted = result?.[1] ?? 0;
|
|
if (deleted > 0) {
|
|
this.logger.log(`Cleaned up ${deleted} expired/revoked refresh tokens`);
|
|
}
|
|
return deleted;
|
|
}
|
|
|
|
private hashToken(rawToken: string): string {
|
|
return createHash('sha256').update(rawToken).digest('hex');
|
|
}
|
|
}
|