feat: implement trusted device functionality for enhanced login experience
This commit is contained in:
parent
60849b59a8
commit
4c724da7ae
60
apps/bff/src/infra/cache/cache.service.ts
vendored
60
apps/bff/src/infra/cache/cache.service.ts
vendored
@ -287,6 +287,66 @@ export class CacheService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Redis SET Operations (Atomic)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Add one or more members to a Redis set (SADD)
|
||||
* This is atomic and safe for concurrent access.
|
||||
*
|
||||
* @param key Redis set key
|
||||
* @param members Members to add to the set
|
||||
* @returns Number of members added (excludes already existing members)
|
||||
*/
|
||||
async setAdd(key: string, ...members: string[]): Promise<number> {
|
||||
if (members.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return this.redis.sadd(key, ...members);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one or more members from a Redis set (SREM)
|
||||
* This is atomic and safe for concurrent access.
|
||||
*
|
||||
* @param key Redis set key
|
||||
* @param members Members to remove from the set
|
||||
* @returns Number of members removed
|
||||
*/
|
||||
async setRemove(key: string, ...members: string[]): Promise<number> {
|
||||
if (members.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return this.redis.srem(key, ...members);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of a Redis set (SMEMBERS)
|
||||
*
|
||||
* @param key Redis set key
|
||||
* @returns Array of set members
|
||||
*/
|
||||
async setMembers(key: string): Promise<string[]> {
|
||||
return this.redis.smembers(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a set and set TTL on the set key
|
||||
* Combines SADD and EXPIRE in a pipeline for efficiency.
|
||||
*
|
||||
* @param key Redis set key
|
||||
* @param member Member to add
|
||||
* @param ttlSeconds TTL in seconds to set/refresh on the key
|
||||
*/
|
||||
async setAddWithTtl(key: string, member: string, ttlSeconds: number): Promise<void> {
|
||||
const ttl = Math.max(1, Math.floor(ttlSeconds));
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.sadd(key, member);
|
||||
pipeline.expire(key, ttl);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan keys matching a pattern and invoke callback for each batch
|
||||
* Uses cursor-based iteration for safe operation on large datasets
|
||||
|
||||
@ -35,6 +35,8 @@ import { WorkflowModule } from "@bff/modules/shared/workflow/index.js";
|
||||
// Login OTP flow
|
||||
import { LoginSessionService } from "./infra/login/login-session.service.js";
|
||||
import { LoginOtpWorkflowService } from "./infra/workflows/login-otp-workflow.service.js";
|
||||
// Trusted device
|
||||
import { TrustedDeviceService } from "./infra/trusted-device/trusted-device.service.js";
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, MappingsModule, IntegrationsModule, CacheModule, WorkflowModule],
|
||||
@ -67,6 +69,8 @@ import { LoginOtpWorkflowService } from "./infra/workflows/login-otp-workflow.se
|
||||
// Login OTP flow services
|
||||
LoginSessionService,
|
||||
LoginOtpWorkflowService,
|
||||
// Trusted device
|
||||
TrustedDeviceService,
|
||||
// Guards and interceptors
|
||||
FailedLoginThrottleGuard,
|
||||
AuthRateLimitService,
|
||||
|
||||
2
apps/bff/src/modules/auth/infra/trusted-device/index.ts
Normal file
2
apps/bff/src/modules/auth/infra/trusted-device/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { TrustedDeviceService } from "./trusted-device.service.js";
|
||||
export type { TrustedDeviceValidationResult } from "./trusted-device.service.js";
|
||||
@ -0,0 +1,300 @@
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
import { CacheService } from "@/infra/cache/cache.service.js";
|
||||
import { JoseJwtService } from "../token/jose-jwt.service.js";
|
||||
|
||||
/**
|
||||
* Trusted device token payload stored in JWT
|
||||
* Extends JWTPayload to satisfy jose library constraints
|
||||
*/
|
||||
interface TrustedDeviceTokenPayload {
|
||||
/** JWT subject - user ID */
|
||||
sub: string;
|
||||
/** Unique device identifier */
|
||||
deviceId: string;
|
||||
/** Purpose claim to distinguish from other token types */
|
||||
purpose: "trusted_device";
|
||||
/** Index signature for JWTPayload compatibility */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device record stored in Redis
|
||||
*/
|
||||
interface TrustedDeviceRecord {
|
||||
/** User ID this device belongs to */
|
||||
userId: string;
|
||||
/** Unique device identifier */
|
||||
deviceId: string;
|
||||
/** When the device was trusted */
|
||||
createdAt: string;
|
||||
/** User agent string for audit/display */
|
||||
userAgent?: string;
|
||||
/** Whether this device trust is still valid */
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validating a trusted device token
|
||||
*/
|
||||
export interface TrustedDeviceValidationResult {
|
||||
/** Whether the device is trusted and valid */
|
||||
valid: boolean;
|
||||
/** User ID if valid */
|
||||
userId?: string;
|
||||
/** Device ID if valid */
|
||||
deviceId?: string;
|
||||
/** Reason for invalid result */
|
||||
reason?: "missing" | "expired" | "invalid_signature" | "revoked" | "user_mismatch";
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted Device Service
|
||||
*
|
||||
* Manages trusted device tokens for "Remember this device" functionality.
|
||||
* When a user successfully completes OTP verification with "Remember this device"
|
||||
* checked, a trusted device token is issued as a signed JWT stored in a cookie.
|
||||
*
|
||||
* On subsequent logins, if a valid trusted device cookie exists for the same user,
|
||||
* OTP verification is skipped.
|
||||
*
|
||||
* Security:
|
||||
* - Tokens are signed JWTs with purpose claim
|
||||
* - Device records stored in Redis with TTL
|
||||
* - Devices can be revoked via logout or manual revocation
|
||||
* - User binding: token only valid for the user who created it
|
||||
*/
|
||||
@Injectable()
|
||||
export class TrustedDeviceService {
|
||||
private readonly DEVICE_PREFIX = "trusted-device:";
|
||||
private readonly USER_DEVICES_PREFIX = "user-devices:";
|
||||
private readonly ttlSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly cache: CacheService,
|
||||
private readonly jwtService: JoseJwtService,
|
||||
private readonly config: ConfigService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {
|
||||
// Default: 7 days (168 hours)
|
||||
const ttlHours = this.config.get<number>("TRUSTED_DEVICE_TTL_HOURS", 168);
|
||||
this.ttlSeconds = ttlHours * 60 * 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a trusted device token and store the device record
|
||||
*
|
||||
* @param userId - The user ID to associate with the device
|
||||
* @param userAgent - Optional user agent for audit purposes
|
||||
* @returns The signed JWT token to set in the cookie
|
||||
*/
|
||||
async createTrustedDevice(userId: string, userAgent?: string): Promise<string> {
|
||||
const deviceId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// Store device record in Redis
|
||||
const deviceRecord: TrustedDeviceRecord = {
|
||||
userId,
|
||||
deviceId,
|
||||
createdAt,
|
||||
valid: true,
|
||||
...(userAgent !== undefined && { userAgent }),
|
||||
};
|
||||
|
||||
await this.cache.set(this.buildDeviceKey(deviceId), deviceRecord, this.ttlSeconds);
|
||||
|
||||
// Add to user's device set for bulk operations
|
||||
await this.addToUserDeviceSet(userId, deviceId);
|
||||
|
||||
// Create signed JWT token
|
||||
const token = await this.jwtService.sign(
|
||||
{
|
||||
sub: userId,
|
||||
deviceId,
|
||||
purpose: "trusted_device",
|
||||
} satisfies TrustedDeviceTokenPayload,
|
||||
this.ttlSeconds
|
||||
);
|
||||
|
||||
this.logger.debug({ userId, deviceId }, "Trusted device created");
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a trusted device token
|
||||
*
|
||||
* @param token - The JWT token from the cookie
|
||||
* @param expectedUserId - The user ID that should match the token (from credentials)
|
||||
* @returns Validation result with user info if valid
|
||||
*/
|
||||
async validateTrustedDevice(
|
||||
token: string | undefined,
|
||||
expectedUserId: string
|
||||
): Promise<TrustedDeviceValidationResult> {
|
||||
if (!token) {
|
||||
return { valid: false, reason: "missing" };
|
||||
}
|
||||
|
||||
// Verify JWT signature and decode payload
|
||||
let payload: TrustedDeviceTokenPayload;
|
||||
try {
|
||||
payload = await this.jwtService.verify<TrustedDeviceTokenPayload>(token);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
// Check if it's an expiry error
|
||||
if (errorMessage.includes("exp") || errorMessage.includes("expired")) {
|
||||
this.logger.debug({ error: errorMessage }, "Trusted device token expired");
|
||||
return { valid: false, reason: "expired" };
|
||||
}
|
||||
this.logger.debug({ error: errorMessage }, "Trusted device token invalid");
|
||||
return { valid: false, reason: "invalid_signature" };
|
||||
}
|
||||
|
||||
// Validate purpose claim
|
||||
if (payload.purpose !== "trusted_device") {
|
||||
this.logger.warn({ purpose: payload.purpose }, "Invalid token purpose for trusted device");
|
||||
return { valid: false, reason: "invalid_signature" };
|
||||
}
|
||||
|
||||
// Check user binding - token must be for the same user attempting login
|
||||
if (payload.sub !== expectedUserId) {
|
||||
this.logger.debug(
|
||||
{ tokenUserId: payload.sub, expectedUserId },
|
||||
"Trusted device token user mismatch"
|
||||
);
|
||||
return { valid: false, reason: "user_mismatch" };
|
||||
}
|
||||
|
||||
// Check if device is still valid in Redis (not revoked)
|
||||
const deviceRecord = await this.cache.get<TrustedDeviceRecord>(
|
||||
this.buildDeviceKey(payload.deviceId)
|
||||
);
|
||||
|
||||
if (!deviceRecord) {
|
||||
this.logger.debug({ deviceId: payload.deviceId }, "Trusted device not found in cache");
|
||||
return { valid: false, reason: "revoked" };
|
||||
}
|
||||
|
||||
if (!deviceRecord.valid) {
|
||||
this.logger.debug({ deviceId: payload.deviceId }, "Trusted device has been revoked");
|
||||
return { valid: false, reason: "revoked" };
|
||||
}
|
||||
|
||||
// Verify user ID matches (defense in depth)
|
||||
if (deviceRecord.userId !== expectedUserId) {
|
||||
this.logger.warn(
|
||||
{ recordUserId: deviceRecord.userId, expectedUserId },
|
||||
"Trusted device record user mismatch"
|
||||
);
|
||||
return { valid: false, reason: "user_mismatch" };
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{ userId: payload.sub, deviceId: payload.deviceId },
|
||||
"Trusted device validated successfully"
|
||||
);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
userId: payload.sub,
|
||||
deviceId: payload.deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific trusted device
|
||||
*
|
||||
* @param deviceId - The device ID to revoke
|
||||
*/
|
||||
async revokeDevice(deviceId: string): Promise<void> {
|
||||
const deviceRecord = await this.cache.get<TrustedDeviceRecord>(this.buildDeviceKey(deviceId));
|
||||
|
||||
if (deviceRecord) {
|
||||
// Mark as invalid rather than delete (for audit trail)
|
||||
deviceRecord.valid = false;
|
||||
await this.cache.set(this.buildDeviceKey(deviceId), deviceRecord, this.ttlSeconds);
|
||||
|
||||
// Remove from user's device set
|
||||
await this.removeFromUserDeviceSet(deviceRecord.userId, deviceId);
|
||||
|
||||
this.logger.debug({ deviceId, userId: deviceRecord.userId }, "Trusted device revoked");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all trusted devices for a user
|
||||
* Called on logout or password change
|
||||
*
|
||||
* @param userId - The user ID whose devices should be revoked
|
||||
*/
|
||||
async revokeAllUserDevices(userId: string): Promise<void> {
|
||||
const deviceIds = await this.getUserDeviceIds(userId);
|
||||
|
||||
for (const deviceId of deviceIds) {
|
||||
// eslint-disable-next-line no-await-in-loop -- Sequential deletion ensures clean revocation
|
||||
await this.cache.del(this.buildDeviceKey(deviceId));
|
||||
}
|
||||
|
||||
// Clear the user's device set
|
||||
await this.cache.del(this.buildUserDevicesKey(userId));
|
||||
|
||||
this.logger.debug(
|
||||
{ userId, deviceCount: deviceIds.length },
|
||||
"All trusted devices revoked for user"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TTL in milliseconds (for cookie maxAge)
|
||||
*/
|
||||
getTtlMs(): number {
|
||||
return this.ttlSeconds * 1000;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Private Helper Methods
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build Redis key for device record
|
||||
*/
|
||||
private buildDeviceKey(deviceId: string): string {
|
||||
return `${this.DEVICE_PREFIX}${deviceId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Redis key for user's device set
|
||||
*/
|
||||
private buildUserDevicesKey(userId: string): string {
|
||||
return `${this.USER_DEVICES_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add device to user's device set (atomic operation using Redis SADD)
|
||||
*/
|
||||
private async addToUserDeviceSet(userId: string, deviceId: string): Promise<void> {
|
||||
const key = this.buildUserDevicesKey(userId);
|
||||
await this.cache.setAddWithTtl(key, deviceId, this.ttlSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove device from user's device set (atomic operation using Redis SREM)
|
||||
*/
|
||||
private async removeFromUserDeviceSet(userId: string, deviceId: string): Promise<void> {
|
||||
const key = this.buildUserDevicesKey(userId);
|
||||
await this.cache.setRemove(key, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all device IDs for a user (using Redis SMEMBERS)
|
||||
*/
|
||||
private async getUserDeviceIds(userId: string): Promise<string[]> {
|
||||
const key = this.buildUserDevicesKey(userId);
|
||||
return this.cache.setMembers(key);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { AuthTokenService } from "../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js";
|
||||
import { PasswordResetTokenService } from "../token/password-reset-token.service.js";
|
||||
import { TrustedDeviceService } from "../trusted-device/trusted-device.service.js";
|
||||
import {
|
||||
type ChangePasswordRequest,
|
||||
changePasswordRequestSchema,
|
||||
@ -34,6 +35,7 @@ export class PasswordWorkflowService {
|
||||
private readonly passwordResetTokenService: PasswordResetTokenService,
|
||||
private readonly tokenService: AuthTokenService,
|
||||
private readonly authRateLimitService: AuthRateLimitService,
|
||||
private readonly trustedDeviceService: TrustedDeviceService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
@ -163,6 +165,8 @@ export class PasswordWorkflowService {
|
||||
}
|
||||
// Force re-login everywhere after password reset
|
||||
await this.tokenService.revokeAllUserTokens(freshUser.id);
|
||||
// Revoke all trusted devices - attacker could have set up a trusted device
|
||||
await this.trustedDeviceService.revokeAllUserDevices(freshUser.id);
|
||||
},
|
||||
this.logger,
|
||||
{
|
||||
@ -228,6 +232,8 @@ export class PasswordWorkflowService {
|
||||
|
||||
// Revoke existing refresh tokens before issuing new pair (logout other sessions)
|
||||
await this.tokenService.revokeAllUserTokens(userProfile.id);
|
||||
// Revoke all trusted devices - user is changing password, should re-verify devices
|
||||
await this.trustedDeviceService.revokeAllUserDevices(userProfile.id);
|
||||
|
||||
const tokens = await this.tokenService.generateTokenPair({
|
||||
id: userProfile.id,
|
||||
|
||||
@ -36,7 +36,13 @@ import {
|
||||
REFRESH_COOKIE_PATH,
|
||||
TOKEN_TYPE,
|
||||
} from "./utils/auth-cookie.util.js";
|
||||
import {
|
||||
setTrustedDeviceCookie,
|
||||
clearTrustedDeviceCookie,
|
||||
getTrustedDeviceToken,
|
||||
} from "./utils/trusted-device-cookie.util.js";
|
||||
import { devAuthConfig } from "@bff/core/config/auth-dev.config.js";
|
||||
import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.service.js";
|
||||
|
||||
// Import Zod schemas from domain
|
||||
import {
|
||||
@ -82,7 +88,8 @@ export class AuthController {
|
||||
constructor(
|
||||
private authOrchestrator: AuthOrchestrator,
|
||||
private readonly jwtService: JoseJwtService,
|
||||
private readonly loginOtpWorkflow: LoginOtpWorkflowService
|
||||
private readonly loginOtpWorkflow: LoginOtpWorkflowService,
|
||||
private readonly trustedDeviceService: TrustedDeviceService
|
||||
) {}
|
||||
|
||||
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
||||
@ -119,16 +126,18 @@ export class AuthController {
|
||||
* POST /auth/login - Initiate login with credentials
|
||||
*
|
||||
* After valid credential check:
|
||||
* 1. Generates OTP and sends to user's email
|
||||
* 2. Returns session token for OTP verification
|
||||
* 3. User must call /auth/login/verify-otp to complete login
|
||||
* 1. Check if device is trusted (has valid trusted device cookie for this user)
|
||||
* 2. If trusted, skip OTP and complete login directly
|
||||
* 3. Otherwise, generate OTP and send to user's email
|
||||
* 4. Return session token for OTP verification
|
||||
* 5. User must call /auth/login/verify-otp to complete login
|
||||
*/
|
||||
@Public()
|
||||
@UseGuards(LocalAuthGuard, FailedLoginThrottleGuard)
|
||||
@UseInterceptors(LoginResultInterceptor)
|
||||
@Post("login")
|
||||
async login(
|
||||
@Req() req: RequestWithUser & RequestWithRateLimit,
|
||||
@Req() req: RequestWithUser & RequestWithRateLimit & RequestWithCookies,
|
||||
@Res({ passthrough: true }) res: Response
|
||||
) {
|
||||
this.applyAuthRateLimitHeaders(req, res);
|
||||
@ -146,6 +155,26 @@ export class AuthController {
|
||||
};
|
||||
}
|
||||
|
||||
// Check if this is a trusted device for the authenticated user
|
||||
const trustedDeviceToken = getTrustedDeviceToken(req);
|
||||
const trustedDeviceResult = await this.trustedDeviceService.validateTrustedDevice(
|
||||
trustedDeviceToken,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
// If device is trusted for this user, skip OTP and complete login
|
||||
if (trustedDeviceResult.valid) {
|
||||
const loginResult = await this.authOrchestrator.completeLogin(
|
||||
{ id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" },
|
||||
req
|
||||
);
|
||||
setAuthCookies(res, loginResult.tokens);
|
||||
return {
|
||||
user: loginResult.user,
|
||||
session: buildSessionInfo(loginResult.tokens),
|
||||
};
|
||||
}
|
||||
|
||||
// Credentials validated by LocalAuthGuard - now initiate OTP
|
||||
const fingerprint = getRequestFingerprint(req);
|
||||
const otpResult = await this.loginOtpWorkflow.initiateOtp(
|
||||
@ -170,6 +199,7 @@ export class AuthController {
|
||||
* POST /auth/login/verify-otp - Complete login with OTP verification
|
||||
*
|
||||
* Verifies the OTP code and issues auth tokens on success.
|
||||
* If rememberDevice is true, sets a trusted device cookie to skip OTP on future logins.
|
||||
*/
|
||||
@Public()
|
||||
@Post("login/verify-otp")
|
||||
@ -196,6 +226,17 @@ export class AuthController {
|
||||
|
||||
setAuthCookies(res, loginResult.tokens);
|
||||
|
||||
// If user wants to remember this device, create and set trusted device cookie
|
||||
if (body.rememberDevice) {
|
||||
const rawUserAgent = req.headers["user-agent"];
|
||||
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
|
||||
const trustedDeviceToken = await this.trustedDeviceService.createTrustedDevice(
|
||||
result.userId,
|
||||
userAgent
|
||||
);
|
||||
setTrustedDeviceCookie(res, trustedDeviceToken, this.trustedDeviceService.getTtlMs());
|
||||
}
|
||||
|
||||
return {
|
||||
user: loginResult.user,
|
||||
session: buildSessionInfo(loginResult.tokens),
|
||||
@ -218,10 +259,24 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke trusted device for this user if they have one
|
||||
const trustedDeviceToken = getTrustedDeviceToken(req);
|
||||
if (trustedDeviceToken && userId) {
|
||||
// Validate to get device ID, then revoke
|
||||
const validation = await this.trustedDeviceService.validateTrustedDevice(
|
||||
trustedDeviceToken,
|
||||
userId
|
||||
);
|
||||
if (validation.deviceId) {
|
||||
await this.trustedDeviceService.revokeDevice(validation.deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.authOrchestrator.logout(userId, token, req as Request);
|
||||
|
||||
// Always clear cookies, even if session expired
|
||||
clearAuthCookies(res);
|
||||
clearTrustedDeviceCookie(res);
|
||||
return { message: "Logout successful" };
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
import type { Request, Response, CookieOptions } from "express";
|
||||
|
||||
/**
|
||||
* Cookie name for trusted device token
|
||||
*/
|
||||
export const TRUSTED_DEVICE_COOKIE_NAME = "trusted_device";
|
||||
|
||||
/**
|
||||
* Cookie path - applies to all auth endpoints
|
||||
*/
|
||||
export const TRUSTED_DEVICE_COOKIE_PATH = "/api/auth";
|
||||
|
||||
/**
|
||||
* Custom setSecureCookie function signature
|
||||
* This is added by our security middleware
|
||||
*/
|
||||
type SetSecureCookieFn = (name: string, value: string, options?: CookieOptions) => void;
|
||||
|
||||
/**
|
||||
* Get setSecureCookie function from response if available
|
||||
* Returns null if the custom helper is not present
|
||||
*/
|
||||
function getSecureCookie(res: Response): SetSecureCookieFn | null {
|
||||
const maybeSecure = res as Response & { setSecureCookie?: unknown };
|
||||
if (typeof maybeSecure.setSecureCookie === "function") {
|
||||
return maybeSecure.setSecureCookie.bind(res) as SetSecureCookieFn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the trusted device cookie on the response
|
||||
*
|
||||
* @param res - Express response object
|
||||
* @param token - The signed JWT token
|
||||
* @param ttlMs - Time to live in milliseconds
|
||||
*/
|
||||
export function setTrustedDeviceCookie(res: Response, token: string, ttlMs: number): void {
|
||||
const setSecureCookie = getSecureCookie(res);
|
||||
|
||||
if (setSecureCookie) {
|
||||
// Use the custom setSecureCookie helper if available
|
||||
setSecureCookie(TRUSTED_DEVICE_COOKIE_NAME, token, {
|
||||
maxAge: ttlMs,
|
||||
path: TRUSTED_DEVICE_COOKIE_PATH,
|
||||
});
|
||||
} else {
|
||||
// Fallback to standard cookie with secure options
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
res.cookie(TRUSTED_DEVICE_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: TRUSTED_DEVICE_COOKIE_PATH,
|
||||
maxAge: ttlMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the trusted device cookie
|
||||
*
|
||||
* @param res - Express response object
|
||||
*/
|
||||
export function clearTrustedDeviceCookie(res: Response): void {
|
||||
const setSecureCookie = getSecureCookie(res);
|
||||
|
||||
if (setSecureCookie) {
|
||||
setSecureCookie(TRUSTED_DEVICE_COOKIE_NAME, "", {
|
||||
maxAge: 0,
|
||||
path: TRUSTED_DEVICE_COOKIE_PATH,
|
||||
});
|
||||
} else {
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
res.cookie(TRUSTED_DEVICE_COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "lax",
|
||||
path: TRUSTED_DEVICE_COOKIE_PATH,
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request type with cookies
|
||||
*/
|
||||
type RequestWithCookies = Omit<Request, "cookies"> & {
|
||||
cookies?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the trusted device token from the request cookies
|
||||
*
|
||||
* @param req - Express request object
|
||||
* @returns The token string or undefined if not present
|
||||
*/
|
||||
export function getTrustedDeviceToken(req: RequestWithCookies): string | undefined {
|
||||
return req.cookies?.[TRUSTED_DEVICE_COOKIE_NAME];
|
||||
}
|
||||
@ -103,13 +103,13 @@ export function LoginForm({
|
||||
);
|
||||
|
||||
const handleOtpVerify = useCallback(
|
||||
async (code: string) => {
|
||||
async (code: string, rememberDevice: boolean) => {
|
||||
if (!otpState) return;
|
||||
|
||||
// Note: Don't call clearError() here - the store already sets error: null
|
||||
// at the start of verifyLoginOtp, which ensures atomic state updates
|
||||
try {
|
||||
await verifyOtp(otpState.sessionToken, code);
|
||||
await verifyOtp(otpState.sessionToken, code, rememberDevice);
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Verification failed";
|
||||
|
||||
@ -16,7 +16,7 @@ interface LoginOtpStepProps {
|
||||
sessionToken: string;
|
||||
maskedEmail: string;
|
||||
expiresAt: string;
|
||||
onVerify: (code: string) => Promise<void>;
|
||||
onVerify: (code: string, rememberDevice: boolean) => Promise<void>;
|
||||
onBack: () => void;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
@ -43,6 +43,7 @@ export function LoginOtpStep({
|
||||
const [code, setCode] = useState("");
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||
const [rememberDevice, setRememberDevice] = useState(false);
|
||||
|
||||
// Calculate initial time remaining with validation
|
||||
useEffect(() => {
|
||||
@ -82,11 +83,11 @@ export function LoginOtpStep({
|
||||
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await onVerify(code);
|
||||
await onVerify(code, rememberDevice);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}, [code, isVerifying, loading, onVerify]);
|
||||
}, [code, isVerifying, loading, onVerify, rememberDevice]);
|
||||
|
||||
const handleComplete = useCallback(
|
||||
(completedCode: string) => {
|
||||
@ -96,14 +97,14 @@ export function LoginOtpStep({
|
||||
void (async () => {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
await onVerify(completedCode);
|
||||
await onVerify(completedCode, rememberDevice);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
},
|
||||
[isVerifying, loading, onVerify]
|
||||
[isVerifying, loading, onVerify, rememberDevice]
|
||||
);
|
||||
|
||||
const isExpired = timeRemaining !== null && timeRemaining <= 0;
|
||||
@ -148,6 +149,22 @@ export function LoginOtpStep({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Device Checkbox */}
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
id="remember-device"
|
||||
name="remember-device"
|
||||
type="checkbox"
|
||||
checked={rememberDevice}
|
||||
onChange={e => setRememberDevice(e.target.checked)}
|
||||
disabled={isSubmitting || isExpired}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-border rounded transition-colors accent-primary"
|
||||
/>
|
||||
<label htmlFor="remember-device" className="ml-2 block text-sm text-muted-foreground">
|
||||
Remember this device for 7 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Verify Button */}
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@ -135,8 +135,8 @@ export function useLoginWithOtp(options?: { redirectTo?: string } | undefined) {
|
||||
);
|
||||
|
||||
const handleVerifyOtp = useCallback(
|
||||
async (sessionToken: string, code: string) => {
|
||||
await verifyLoginOtp(sessionToken, code);
|
||||
async (sessionToken: string, code: string, rememberDevice?: boolean) => {
|
||||
await verifyLoginOtp(sessionToken, code, rememberDevice);
|
||||
// Login successful - redirect
|
||||
const redirectTo = getPostLoginRedirect(searchParams, options?.redirectTo);
|
||||
router.push(redirectTo);
|
||||
|
||||
@ -45,7 +45,7 @@ export interface AuthState {
|
||||
initiateLogin: (
|
||||
credentials: LoginRequest
|
||||
) => Promise<LoginOtpRequiredResponse | { requiresOtp: false }>;
|
||||
verifyLoginOtp: (sessionToken: string, code: string) => Promise<void>;
|
||||
verifyLoginOtp: (sessionToken: string, code: string, rememberDevice?: boolean) => Promise<void>;
|
||||
// Legacy login (kept for backward compatibility during migration)
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
signup: (data: SignupRequest) => Promise<void>;
|
||||
@ -230,11 +230,16 @@ export const useAuthStore = create<AuthState>()((set, get) => {
|
||||
|
||||
/**
|
||||
* Step 2 of two-step login: Verify OTP and complete login
|
||||
* @param rememberDevice - If true, server will set a trusted device cookie to skip OTP on future logins
|
||||
*/
|
||||
verifyLoginOtp: async (sessionToken, code) => {
|
||||
verifyLoginOtp: async (sessionToken, code, rememberDevice) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const body: LoginVerifyOtpRequest = { sessionToken, code };
|
||||
const body: LoginVerifyOtpRequest = {
|
||||
sessionToken,
|
||||
code,
|
||||
...(rememberDevice !== undefined && { rememberDevice }),
|
||||
};
|
||||
const response = await apiClient.POST("/api/auth/login/verify-otp", {
|
||||
body,
|
||||
disableCsrf: true, // Public auth endpoint, exempt from CSRF
|
||||
|
||||
@ -303,6 +303,8 @@ export const loginVerifyOtpRequestSchema = z.object({
|
||||
.string()
|
||||
.length(6, "Code must be 6 digits")
|
||||
.regex(/^\d{6}$/, "Code must be 6 digits"),
|
||||
/** If true, remember this device and skip OTP on future logins for 7 days */
|
||||
rememberDevice: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user