feat: implement trusted device functionality for enhanced login experience

This commit is contained in:
barsa 2026-02-03 19:21:48 +09:00
parent 60849b59a8
commit 4c724da7ae
12 changed files with 570 additions and 17 deletions

View File

@ -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

View File

@ -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,

View File

@ -0,0 +1,2 @@
export { TrustedDeviceService } from "./trusted-device.service.js";
export type { TrustedDeviceValidationResult } from "./trusted-device.service.js";

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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" };
}

View File

@ -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];
}

View File

@ -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";

View File

@ -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"

View File

@ -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);

View File

@ -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

View File

@ -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(),
});
/**