Add SSO handoff tickets and expand seeded countries for address forms.

Support one-time Redis-backed dashboard sign-in handoff and seed European, Middle East, and Far East countries with major cities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-19 19:46:57 +03:30
co-authored by Cursor
parent 7277817b6e
commit f61bba317e
6 changed files with 319 additions and 1 deletions
+12
View File
@@ -13,6 +13,7 @@ import { CurrentUser } from './decorators/current-user.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { LoginOtpDto } from './dto/login-otp.dto';
import { ConsumeHandoffDto } from './dto/consume-handoff.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
@@ -60,6 +61,17 @@ export class AuthController {
return this.authService.refresh(dto.refreshToken);
}
@Post('handoff')
@UseGuards(JwtAuthGuard)
createHandoff(@CurrentUser() user: AuthUser) {
return this.authService.createHandoff(user);
}
@Post('handoff/consume')
consumeHandoff(@Body() dto: ConsumeHandoffDto) {
return this.authService.consumeHandoff(dto.ticket);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
+68
View File
@@ -1,6 +1,7 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
Injectable,
ServiceUnavailableException,
@@ -9,6 +10,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { TenantService } from '../tenant/tenant.service';
@@ -28,6 +30,9 @@ import { parseUserProfile } from './profile.util';
import { SmsService } from './sms.service';
const OTP_TTL_SECONDS = 300;
const HANDOFF_TTL_SECONDS = 60;
const HANDOFF_RATE_LIMIT = 10;
const HANDOFF_RATE_WINDOW_SECONDS = 60;
@Injectable()
export class AuthService {
@@ -216,6 +221,62 @@ export class AuthService {
};
}
async createHandoff(user: AuthUser) {
if (!this.canAccessBusinessDashboard(user)) {
throw new ForbiddenException(
'This account cannot open the business dashboard.',
);
}
const allowed = await this.redis.incrementWithLimit(
`sso:handoff:rate:${user.id.toString()}`,
HANDOFF_RATE_LIMIT,
HANDOFF_RATE_WINDOW_SECONDS,
);
if (!allowed) {
throw new HttpException('Too many dashboard handoff requests. Try again shortly.', 429);
}
const ticket = randomBytes(32).toString('hex');
await this.redis.setHandoffTicket(
ticket,
user.id.toString(),
HANDOFF_TTL_SECONDS,
);
return {
ticket,
expiresInSeconds: HANDOFF_TTL_SECONDS,
};
}
async consumeHandoff(ticket: string) {
const userId = await this.redis.consumeHandoffTicket(ticket);
if (!userId) {
throw new UnauthorizedException('Invalid or expired dashboard handoff');
}
const authUser = await this.getAuthUser(BigInt(userId));
if (!authUser || !this.canAccessBusinessDashboard(authUser)) {
throw new ForbiddenException(
'This account cannot access the business dashboard.',
);
}
await this.prisma.user.update({
where: { id: authUser.id },
data: { lastLoginAt: new Date() },
});
const tokens = await this.issueTokens(authUser);
return {
message: 'Login successful',
user: this.serializeUser(authUser),
...tokens,
};
}
async me(user: AuthUser) {
return { user: this.serializeUser(user) };
}
@@ -526,6 +587,13 @@ export class AuthService {
return 'customer';
}
private canAccessBusinessDashboard(user: AuthUser): boolean {
if (user.roles.includes('super_admin')) {
return true;
}
return user.businesses.length > 0;
}
private async issueTokens(user: AuthUser) {
const accessPayload: AuthJwtPayload = {
sub: user.id.toString(),
+9
View File
@@ -0,0 +1,9 @@
import { IsString, Matches } from 'class-validator';
export class ConsumeHandoffDto {
@IsString()
@Matches(/^[a-f0-9]{64}$/, {
message: 'ticket must be a 64-character hex string',
})
ticket!: string;
}