The refresh cookie had no Max-Age, so browsers dropped it on close while the Session stayed valid for 7 days. Set maxAge from REFRESH_TTL_MS, the same constant behind Session.expiresAt.
141 lines
3.6 KiB
TypeScript
141 lines
3.6 KiB
TypeScript
import type { Request, Response } from 'express';
|
|
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
HttpCode,
|
|
HttpStatus,
|
|
Post,
|
|
Req,
|
|
Res,
|
|
UnauthorizedException,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import {
|
|
ApiBearerAuth,
|
|
ApiNoContentResponse,
|
|
ApiOkResponse,
|
|
ApiProperty,
|
|
ApiTags,
|
|
} from '@nestjs/swagger';
|
|
|
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
|
import { HttpErrorCode } from '../../../common/error-code';
|
|
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
|
import { LoginDto, RegisterDto } from './auth.dto';
|
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
|
|
|
const REFRESH_COOKIE = 'refresh_token';
|
|
const COOKIE_PATH = '/api/v1/auth';
|
|
|
|
class UserDto {
|
|
@ApiProperty()
|
|
declare id: string;
|
|
|
|
@ApiProperty()
|
|
declare email: string;
|
|
}
|
|
|
|
class AuthenticationDto {
|
|
@ApiProperty()
|
|
declare accessToken: string;
|
|
|
|
@ApiProperty({ type: UserDto })
|
|
declare user: UserDto;
|
|
}
|
|
|
|
@ApiTags('auth')
|
|
@ApiProblemResponses()
|
|
@Controller('auth')
|
|
export class AuthController {
|
|
constructor(private readonly auth: AuthService) {}
|
|
|
|
@Post('register')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOkResponse({ type: AuthenticationDto })
|
|
async register(
|
|
@Body() credentials: RegisterDto,
|
|
@Res({ passthrough: true }) response: Response,
|
|
) {
|
|
return this.respond(
|
|
response,
|
|
await this.auth.register(credentials.email, credentials.password),
|
|
);
|
|
}
|
|
|
|
@Post('login')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOkResponse({ type: AuthenticationDto })
|
|
async login(
|
|
@Body() credentials: LoginDto,
|
|
@Res({ passthrough: true }) response: Response,
|
|
) {
|
|
return this.respond(
|
|
response,
|
|
await this.auth.login(credentials.email, credentials.password),
|
|
);
|
|
}
|
|
|
|
@Post('refresh')
|
|
@HttpCode(HttpStatus.OK)
|
|
@ApiOkResponse({ type: AuthenticationDto })
|
|
async refresh(
|
|
@Req() request: Request,
|
|
@Res({ passthrough: true }) response: Response,
|
|
) {
|
|
const token = this.refreshToken(request);
|
|
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
|
return this.respond(response, await this.auth.refresh(token));
|
|
}
|
|
|
|
@Post('logout')
|
|
@HttpCode(HttpStatus.NO_CONTENT)
|
|
@ApiNoContentResponse()
|
|
async logout(
|
|
@Req() request: Request,
|
|
@Res({ passthrough: true }) response: Response,
|
|
): Promise<void> {
|
|
await this.auth.logout(this.refreshToken(request));
|
|
this.clearCookie(response);
|
|
}
|
|
|
|
@Get('me')
|
|
@UseGuards(JwtAuthGuard)
|
|
@ApiBearerAuth()
|
|
@ApiOkResponse({ type: UserDto })
|
|
async me(@Req() request: Request & { user: AccessPayload }) {
|
|
const user = await this.auth.userById(request.user.sub);
|
|
return this.auth.toUser(user);
|
|
}
|
|
|
|
private respond(
|
|
response: Response,
|
|
result: AuthenticationResult,
|
|
) {
|
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
|
return { user: result.user, accessToken: result.accessToken };
|
|
}
|
|
|
|
private clearCookie(response: Response): void {
|
|
response.clearCookie(REFRESH_COOKIE, this.cookieOptions());
|
|
}
|
|
|
|
private refreshToken(request: Request): string | undefined {
|
|
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
|
|
const token: unknown = request.cookies?.[REFRESH_COOKIE];
|
|
return typeof token === 'string' ? token : undefined;
|
|
}
|
|
|
|
private cookieOptions() {
|
|
return {
|
|
httpOnly: true,
|
|
sameSite: 'lax' as const,
|
|
secure: process.env['NODE_ENV'] !== 'development',
|
|
path: COOKIE_PATH,
|
|
maxAge: REFRESH_TTL_MS,
|
|
};
|
|
}
|
|
}
|