From d1b6b2669a67373d13e8dba9acf3a77aa43f8d53 Mon Sep 17 00:00:00 2001 From: mikkyvans0-source Date: Mon, 1 Jun 2026 17:27:21 +0100 Subject: [PATCH 1/2] feat: implement wallet signature authentication using Redis for nonce challenge storage --- src/auth/auth.service.spec.ts | 160 +++++++++++++++++++++++++++------- src/auth/auth.service.ts | 39 +++++++-- src/redis/redis.service.ts | 17 ++++ 3 files changed, 177 insertions(+), 39 deletions(-) diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index 0815fb0..c757999 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -7,6 +7,12 @@ jest.mock('ethers', () => ({ import { verifyMessage } from 'ethers'; +const NONCE_TTL_SECONDS = 300; // must mirror AuthService.NONCE_TTL_SECONDS + +function makeRecord(nonce: string, ageSeconds = 0): string { + return JSON.stringify({ nonce, issuedAt: Date.now() - ageSeconds * 1000 }); +} + describe('AuthService', () => { let authService: AuthService; let jwtService: any; @@ -37,26 +43,77 @@ describe('AuthService', () => { jest.clearAllMocks(); }); - it('generates a fixed-format challenge and persists the nonce with the configured TTL', async () => { + // ── generateChallenge ──────────────────────────────────────────────────── + + it('generates a fixed-format challenge and persists a JSON record with the configured TTL', async () => { const address = '0xAbCd'; const message = await authService.generateChallenge(address); expect(message).toMatch(/^Sign in to TruthBounty: [A-Za-z0-9]{32}$/); + expect(redisService.set).toHaveBeenCalledWith( 'auth:nonce:0xabcd', - expect.stringMatching(/^[A-Za-z0-9]{32}$/), - 300, + expect.stringMatching(/^\{.*"nonce":"[A-Za-z0-9]{32}".*"issuedAt":\d+.*\}$/s), + NONCE_TTL_SECONDS, + ); + + // issuedAt must be a recent Unix timestamp (within 2 s of now) + const [, rawJson] = redisService.set.mock.calls[0]; + const parsed = JSON.parse(rawJson); + expect(typeof parsed.issuedAt).toBe('number'); + expect(Date.now() - parsed.issuedAt).toBeLessThan(2000); + }); + + it('fails challenge generation when Redis rejects the nonce write', async () => { + redisService.set.mockResolvedValueOnce(false); + + await expect(authService.generateChallenge('0xAbCd')).rejects.toBeInstanceOf( + InternalServerErrorException, ); }); + // ── login — happy path ─────────────────────────────────────────────────── + + it('logs in with an exact challenge message, deletes the nonce, and issues a JWT', async () => { + const address = '0xAaBbCc'; + const lower = address.toLowerCase(); + const storedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + + redisService.get.mockResolvedValueOnce(makeRecord(storedNonce, 0)); + prisma.wallet.findFirst.mockResolvedValueOnce({ + address: lower, + user: { id: 'user-123' }, + } as any); + (verifyMessage as jest.Mock).mockReturnValue(address); + + const result = await authService.login({ + address, + signature: '0xsig', + message: `Sign in to TruthBounty: ${storedNonce}`, + } as any); + + expect(result).toEqual({ + accessToken: 'signed-token', + user: { id: 'user-123', address: lower }, + }); + expect(redisService.del).toHaveBeenCalledWith(`auth:nonce:${lower}`); + expect(jwtService.sign).toHaveBeenCalledWith({ + address: lower, + userId: 'user-123', + sub: 'user-123', + }); + }); + + // ── login — nonce mismatch ─────────────────────────────────────────────── + it('rejects a challenge response when the signed message does not exactly match the stored nonce', async () => { const address = '0xAaBbCc'; const lower = address.toLowerCase(); const storedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; const tamperedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123457'; - redisService.get.mockResolvedValueOnce(storedNonce); + redisService.get.mockResolvedValueOnce(makeRecord(storedNonce, 0)); (verifyMessage as jest.Mock).mockReturnValue(address); await expect( @@ -73,45 +130,84 @@ describe('AuthService', () => { expect(redisService.get).toHaveBeenCalledWith(`auth:nonce:${lower}`); }); - it('logs in with an exact challenge message, deletes the nonce, and issues a JWT', async () => { - const address = '0xAaBbCc'; + // ── login — TTL desync scenarios (BE-182) ─────────────────────────────── + + it('rejects login when the challenge was issued exactly at the TTL boundary (app-layer expiry)', async () => { + const address = '0xDeAdBeEf'; + const nonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + + // issuedAt is exactly NONCE_TTL_SECONDS ago — considered expired + redisService.get.mockResolvedValueOnce(makeRecord(nonce, NONCE_TTL_SECONDS)); + (verifyMessage as jest.Mock).mockReturnValue(address); + + await expect( + authService.login({ + address, + signature: '0xsig', + message: `Sign in to TruthBounty: ${nonce}`, + } as any), + ).rejects.toThrow('Challenge expired'); + + expect(redisService.del).toHaveBeenCalledWith(`auth:nonce:${address.toLowerCase()}`); + expect(jwtService.sign).not.toHaveBeenCalled(); + }); + + it('rejects login when the challenge is older than the TTL (Redis TTL would have matched but app clock says expired)', async () => { + const address = '0xDeAdBeEf'; + const nonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; + + // Simulate Redis TTL desync: key still present in Redis but issuedAt is stale + redisService.get.mockResolvedValueOnce(makeRecord(nonce, NONCE_TTL_SECONDS + 30)); + (verifyMessage as jest.Mock).mockReturnValue(address); + + await expect( + authService.login({ + address, + signature: '0xsig', + message: `Sign in to TruthBounty: ${nonce}`, + } as any), + ).rejects.toThrow('Challenge expired'); + + expect(redisService.del).toHaveBeenCalledWith(`auth:nonce:${address.toLowerCase()}`); + expect(jwtService.sign).not.toHaveBeenCalled(); + }); + + it('accepts login when the challenge is just inside the TTL window', async () => { + const address = '0xDeAdBeEf'; const lower = address.toLowerCase(); - const storedNonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; - const challengeMessage = `Sign in to TruthBounty: ${storedNonce}`; + const nonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; - redisService.get.mockResolvedValueOnce(storedNonce); - prisma.wallet.findFirst.mockResolvedValueOnce({ - address: lower, - user: { id: 'user-123' }, - } as any); + // 1 second before the boundary + redisService.get.mockResolvedValueOnce(makeRecord(nonce, NONCE_TTL_SECONDS - 1)); + prisma.wallet.findFirst.mockResolvedValueOnce({ address: lower, user: { id: 'uid-1' } } as any); (verifyMessage as jest.Mock).mockReturnValue(address); const result = await authService.login({ address, signature: '0xsig', - message: challengeMessage, + message: `Sign in to TruthBounty: ${nonce}`, } as any); - expect(result).toEqual({ - accessToken: 'signed-token', - user: { - id: 'user-123', - address: lower, - }, - }); - expect(redisService.del).toHaveBeenCalledWith(`auth:nonce:${lower}`); - expect(jwtService.sign).toHaveBeenCalledWith({ - address: lower, - userId: 'user-123', - sub: 'user-123', - }); + expect(result.accessToken).toBe('signed-token'); }); - it('fails challenge generation when Redis rejects the nonce write', async () => { - redisService.set.mockResolvedValueOnce(false); + it('rejects login when the stored value is not valid JSON (corrupt / legacy nonce)', async () => { + const address = '0xDeAdBeEf'; + const nonce = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ123456'; - await expect(authService.generateChallenge('0xAbCd')).rejects.toBeInstanceOf( - InternalServerErrorException, - ); + // A legacy raw-string nonce (pre-fix format) is invalid JSON + redisService.get.mockResolvedValueOnce(nonce); + (verifyMessage as jest.Mock).mockReturnValue(address); + + await expect( + authService.login({ + address, + signature: '0xsig', + message: `Sign in to TruthBounty: ${nonce}`, + } as any), + ).rejects.toBeInstanceOf(UnauthorizedException); + + expect(redisService.del).toHaveBeenCalledWith(`auth:nonce:${address.toLowerCase()}`); + expect(jwtService.sign).not.toHaveBeenCalled(); }); }); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 821f9f3..ec25661 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -6,9 +6,14 @@ import { PrismaService } from '../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RedisService } from '../redis/redis.service'; +interface ChallengeRecord { + nonce: string; + issuedAt: number; // Unix ms — app-layer TTL source of truth +} + @Injectable() export class AuthService { - private readonly NONCE_TTL_SECONDS = 5 * 60; // 5 minutes + private readonly NONCE_TTL_SECONDS = 5 * 60; // 5 minutes — kept in sync with Redis SETEX private readonly logger = new Logger(AuthService.name); @@ -23,11 +28,13 @@ export class AuthService { */ async generateChallenge(address: string): Promise { const nonce = this.generateRandomNonce(); - // Persist nonce to Redis with TTL to allow scaling across instances const key = `auth:nonce:${address.toLowerCase()}`; + const record: ChallengeRecord = { nonce, issuedAt: Date.now() }; try { - const ok = await this.redisService.set(key, nonce, this.NONCE_TTL_SECONDS); + // Store nonce + issuedAt together so the login path can enforce expiry + // independently of Redis TTL, eliminating Redis-vs-app clock desync. + const ok = await this.redisService.set(key, JSON.stringify(record), this.NONCE_TTL_SECONDS); if (!ok) { this.logger.error(`Failed to persist nonce for ${address}`); throw new InternalServerErrorException('Failed to generate challenge. Please try again later.'); @@ -59,14 +66,32 @@ export class AuthService { throw new UnauthorizedException('Signature verification failed. Address mismatch.'); } - // 3. Verify the message contains a valid nonce + // 3. Verify the message contains a valid, non-expired nonce const key = `auth:nonce:${address.toLowerCase()}`; - const stored = await this.redisService.get(key); - if (!stored) { + const raw = await this.redisService.get(key); + if (!raw) { + throw new UnauthorizedException('No challenge found or challenge expired. Please request a challenge first.'); + } + + let record: ChallengeRecord; + try { + record = JSON.parse(raw) as ChallengeRecord; + } catch { + // Stored value is not a valid record — treat as expired/invalid + await this.redisService.del(key).catch(() => null); throw new UnauthorizedException('No challenge found or challenge expired. Please request a challenge first.'); } - const expectedMessage = `Sign in to TruthBounty: ${stored}`; + // App-layer TTL check: enforce expiry independently of Redis to prevent + // Redis-vs-app clock desync (BE-182). Redis TTL is the backstop; + // this check is the authoritative gate. + const elapsedSeconds = (Date.now() - record.issuedAt) / 1000; + if (elapsedSeconds > this.NONCE_TTL_SECONDS) { + await this.redisService.del(key).catch(() => null); + throw new UnauthorizedException('Challenge expired. Please request a new challenge.'); + } + + const expectedMessage = `Sign in to TruthBounty: ${record.nonce}`; // Compare the full challenge message in constant time to avoid timing attacks. if (!this.constantTimeEquals(message, expectedMessage)) { diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index d4a0078..e7921a1 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -137,6 +137,23 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { } } + /** + * Return remaining TTL in seconds for a key (-1 = no TTL, -2 = missing, null = unavailable) + */ + async ttl(key: string): Promise { + if (!this.client || !this.isConnected) { + this.logger.debug(`Redis unavailable, skipping TTL for key: ${key}`); + return null; + } + + try { + return await this.client.ttl(key); + } catch (error) { + this.logger.error(`Redis TTL error for key ${key}: ${error.message}`); + return null; + } + } + /** * Check if Redis is healthy and connected */ From 4d372b930a80a02b55cdd12cf80fe894d914dafd Mon Sep 17 00:00:00 2001 From: mikkyvans0-source Date: Mon, 1 Jun 2026 17:38:14 +0100 Subject: [PATCH 2/2] feat: implement wallet signature authentication with nonce-based challenge verification and JWT issuance --- package-lock.json | 79 +++++++++------------------------------- src/auth/auth.service.ts | 2 +- 2 files changed, 18 insertions(+), 63 deletions(-) diff --git a/package-lock.json b/package-lock.json index d6b28c2..9a6a093 100644 --- a/package-lock.json +++ b/package-lock.json @@ -265,7 +265,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -766,7 +765,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/api/-/api-7.1.5.tgz", "integrity": "sha512-EW0sbTtGIysu9vipdVpPQeToPqOpPgVZTt+pn1Ut3gbSS/GLWbEgIfFtMmSQDUoSL9WH00RzjgUY5K+43nWh0A==", "license": "MIT", - "peer": true, "dependencies": { "redis-info": "^3.1.0" }, @@ -805,7 +803,6 @@ "resolved": "https://registry.npmjs.org/@bull-board/ui/-/ui-7.1.5.tgz", "integrity": "sha512-2IkatKwNRx/1M9/lAZIptcxS1FPNq6icpp2M46Upwd4olVxs/ujF9Kvs+Ff9ExtIO/OgYfwx7mG2IprGZ+nQCg==", "license": "MIT", - "peer": true, "dependencies": { "@bull-board/api": "7.1.5" } @@ -901,8 +898,7 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "devOptional": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2714,7 +2710,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.4.tgz", "integrity": "sha512-VBJcDHSAzxQnpcDfA0kt9MTGUD1XZzfByV70su0W0eDCQ9aqIEBlzWRW21tv9FG9dIut22ysgDidshdjlnczLw==", "license": "MIT", - "peer": true, "dependencies": { "tslib": "2.8.1" }, @@ -2789,7 +2784,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2960,7 +2954,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", "license": "MIT", - "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -3020,7 +3013,6 @@ "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3104,7 +3096,6 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz", "integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==", "license": "MIT", - "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3804,7 +3795,6 @@ "integrity": "sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^13.0.5", @@ -3887,10 +3877,9 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -3930,7 +3919,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3947,7 +3935,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3964,7 +3951,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3981,7 +3967,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3998,7 +3983,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4015,7 +3999,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4032,7 +4015,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4049,7 +4031,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4066,7 +4047,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4083,7 +4063,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4097,14 +4076,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -4263,7 +4242,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4425,7 +4403,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4595,7 +4572,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5446,7 +5422,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5542,7 +5517,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6152,7 +6126,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6241,7 +6214,6 @@ "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.77.6.tgz", "integrity": "sha512-WCpSoCD4vWyRD+btOsFrO7iBGInrTgG155gTZCV8qY0Yex2KtsbVtFERx6V1WZ2xWl/5ZxnLar8Z8ufnS4f5jg==", "license": "MIT", - "peer": true, "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.10.1", @@ -6601,7 +6573,6 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6668,15 +6639,13 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", - "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -7163,7 +7132,8 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -7724,7 +7694,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7785,7 +7754,6 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8155,7 +8123,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9244,7 +9211,6 @@ "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -9502,7 +9468,6 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", "license": "MIT", - "peer": true, "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", @@ -9894,7 +9859,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11657,7 +11621,6 @@ "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -12215,7 +12178,6 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", - "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -12361,7 +12323,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -12471,7 +12432,6 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "license": "MIT", - "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -12503,7 +12463,6 @@ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", "license": "MIT", - "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^9.0.0", @@ -12798,7 +12757,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12857,7 +12815,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "7.4.1", "@prisma/dev": "0.20.0", @@ -13249,8 +13206,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regexp-to-ast": { "version": "0.5.0", @@ -13501,7 +13457,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13563,7 +13518,8 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/schema-utils": { "version": "3.3.0", @@ -14074,7 +14030,6 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", - "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -14622,7 +14577,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15006,7 +14960,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -15179,7 +15132,6 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", - "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15397,7 +15349,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16179,6 +16130,7 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -16197,6 +16149,7 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -16210,6 +16163,7 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -16224,6 +16178,7 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -16233,7 +16188,8 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -16241,6 +16197,7 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16393,7 +16350,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -16528,7 +16484,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ec25661..d772421 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -86,7 +86,7 @@ export class AuthService { // Redis-vs-app clock desync (BE-182). Redis TTL is the backstop; // this check is the authoritative gate. const elapsedSeconds = (Date.now() - record.issuedAt) / 1000; - if (elapsedSeconds > this.NONCE_TTL_SECONDS) { + if (elapsedSeconds >= this.NONCE_TTL_SECONDS) { await this.redisService.del(key).catch(() => null); throw new UnauthorizedException('Challenge expired. Please request a new challenge.'); }