import { Body, Controller, Get, HttpCode, HttpStatus, Inject, Post, Req, Res, UseGuards, } from "@nestjs/common"; import type { Request, Response } from "express"; import { parse as parseCookie } from "cookie"; import { ErrorCode } from "@drift/contracts"; import { DomainException } from "../common/domain.exception.js"; import { PrismaService } from "../database/prisma.service.js"; import { AuthGuard } from "./auth.guard.js"; import { AuthService, type TokenPair } from "./auth.service.js"; import { CurrentUser } from "./current-user.decorator.js"; import type { AccessClaims } from "./token.service.js"; import { LoginDto, SendSmsDto } from "./dto.js"; const COOKIE = "refresh_token"; @Controller() export class AuthController { constructor( @Inject(AuthService) private readonly auth: AuthService, @Inject(PrismaService) private readonly prisma: PrismaService, ) {} @Post("auth/sms/send") send(@Body() dto: SendSmsDto, @Req() req: Request) { return this.auth.sendSms(dto.phone, dto.deviceId, this.ip(req)); } @Post("auth/sms/login") async login( @Body() dto: LoginDto, @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { const pair = await this.auth.login( dto.phone, dto.code, dto.deviceId, this.ip(req), ); this.cookie(res, pair.refreshToken); return this.publicPair(pair); } @Post("auth/token/refresh") @HttpCode(HttpStatus.OK) async refresh( @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { this.origin(req); const pair = await this.auth.refresh(this.readCookie(req)); this.cookie(res, pair.refreshToken); return this.publicPair(pair); } @Post("auth/logout") @HttpCode(HttpStatus.OK) async logout( @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { this.origin(req); await this.auth.logout(this.readCookieOptional(req)); res.clearCookie(COOKIE, { httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV === "production", path: "/api/v1/auth", }); return { loggedOut: true }; } @Get("me") @UseGuards(AuthGuard) async me(@CurrentUser() user: AccessClaims) { const account = await this.prisma.account.findUniqueOrThrow({ where: { id: user.sub }, include: { anonymousProfile: true }, }); return { accountId: account.id, publicId: account.anonymousProfile?.publicId ?? null, nickname: account.anonymousProfile?.nickname ?? null, }; } private publicPair(pair: TokenPair) { return { accessToken: pair.accessToken, expiresIn: pair.expiresIn, tokenType: "Bearer", }; } private cookie(res: Response, value: string) { res.cookie(COOKIE, value, { httpOnly: true, sameSite: "strict", secure: process.env.NODE_ENV === "production", path: "/api/v1/auth", maxAge: Number(process.env.REFRESH_TOKEN_TTL_SECONDS ?? 2592000) * 1000, }); } private origin(req: Request) { const origin = req.headers.origin; if (origin !== process.env.WEB_ORIGIN) throw new DomainException( ErrorCode.AUTH_ORIGIN_FORBIDDEN, "Forbidden origin", HttpStatus.FORBIDDEN, ); } private readCookieOptional(req: Request) { const raw = req.headers.cookie; if (!raw) return undefined; try { return parseCookie(raw)[COOKIE]; } catch { return undefined; } } private readCookie(req: Request) { const value = this.readCookieOptional(req); if (!value) throw new DomainException( ErrorCode.AUTH_INVALID_CREDENTIALS, "Invalid credentials", HttpStatus.UNAUTHORIZED, ); return value; } private ip(req: Request) { return req.ip || req.socket.remoteAddress || "unknown"; } } Reflect.defineMetadata( "design:paramtypes", [SendSmsDto, Object], AuthController.prototype, "send", ); Reflect.defineMetadata( "design:paramtypes", [LoginDto, Object, Object], AuthController.prototype, "login", );