From 6395b63098ff4f0785af79103360b9333189617c Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 22:17:20 +0530 Subject: [PATCH] Add rotating session nonce binding --- .../migration.sql | 1 + prisma/schema.prisma | 1 + src/abuse/abuse.types.ts | 4 +++ src/auth/auth.service.ts | 29 ++++++++++++++----- src/common/guards/jwt-auth.guard.ts | 17 ++++++++--- test/auth.service.spec.ts | 3 +- 6 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260716001600_session_nonce_binding/migration.sql diff --git a/prisma/migrations/20260716001600_session_nonce_binding/migration.sql b/prisma/migrations/20260716001600_session_nonce_binding/migration.sql new file mode 100644 index 0000000..8691726 --- /dev/null +++ b/prisma/migrations/20260716001600_session_nonce_binding/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Session" ADD COLUMN "nonceHash" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 47e9023..fa3b7ee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -549,6 +549,7 @@ model Session { userId String ipHash String userAgentHash String + nonceHash String @default("") createdAt DateTime @default(now()) lastSeenAt DateTime @default(now()) expiresAt DateTime diff --git a/src/abuse/abuse.types.ts b/src/abuse/abuse.types.ts index ec01aff..fe2351b 100644 --- a/src/abuse/abuse.types.ts +++ b/src/abuse/abuse.types.ts @@ -1,6 +1,7 @@ export type RequestContext = { ipAddress?: string; userAgent?: string; + sessionNonce?: string; }; export function requestContextFrom(req: { ip?: string; headers?: { [key: string]: unknown } }): RequestContext { @@ -9,5 +10,8 @@ export function requestContextFrom(req: { ip?: string; headers?: { [key: string] return { ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip, userAgent: typeof userAgent === "string" ? userAgent : undefined, + sessionNonce: typeof req.headers?.["x-ledgerone-session-nonce"] === "string" + ? req.headers["x-ledgerone-session-nonce"] + : undefined, }; } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 0b1880e..2b3a45b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -52,11 +52,12 @@ export class AuthService { }); await this.emailService.sendVerificationEmail(email, verifyToken); - const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context); + const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context); return { user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, accessToken, refreshToken, + sessionNonce, message: "Registration successful. Please verify your email.", }; } @@ -82,11 +83,12 @@ export class AuthService { } await this.prisma.auditLog.create({ data: { userId: user.id, action: "auth.login", metadata: { email } } }); - const { accessToken, refreshToken } = await this.issueTokensForUser(user.id, context); + const { accessToken, refreshToken, sessionNonce } = await this.issueTokensForUser(user.id, context); return { user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, accessToken, refreshToken, + sessionNonce, }; } @@ -110,21 +112,22 @@ export class AuthService { throw new UnauthorizedException("Invalid or expired refresh token."); } this.assertSessionMatches(record.session, context); + const sessionNonce = this.createSessionNonce(); await this.prisma.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } }); await this.prisma.session.update({ where: { id: record.sessionId! }, - data: { lastSeenAt: new Date() }, + data: { lastSeenAt: new Date(), nonceHash: this.hashToken(sessionNonce) }, }); const accessToken = this.signAccessToken(record.userId, record.sessionId!); const refreshToken = await this.createRefreshToken(record.userId, record.sessionId!); - return { accessToken, refreshToken }; + return { accessToken, refreshToken, sessionNonce }; } async issueTokensForUser(userId: string, context?: RequestContext) { - const session = await this.createSession(userId, context); + const { session, sessionNonce } = await this.createSession(userId, context); const accessToken = this.signAccessToken(userId, session.id); const refreshToken = await this.createRefreshToken(userId, session.id); - return { accessToken, refreshToken }; + return { accessToken, refreshToken, sessionNonce }; } async logout(rawRefreshToken: string) { @@ -267,18 +270,21 @@ export class AuthService { private async createSession(userId: string, context?: RequestContext) { const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000); - return this.prisma.session.create({ + const sessionNonce = this.createSessionNonce(); + const session = await this.prisma.session.create({ data: { userId, ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"), userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"), + nonceHash: this.hashToken(sessionNonce), expiresAt, }, }); + return { session, sessionNonce }; } private assertSessionMatches( - session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string }, + session: { revokedAt: Date | null; expiresAt: Date; ipHash: string; userAgentHash: string; nonceHash?: string | null }, context?: RequestContext, ) { if (session.revokedAt || session.expiresAt < new Date()) { @@ -289,6 +295,13 @@ export class AuthService { if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) { throw new UnauthorizedException("Session binding mismatch."); } + if (!session.nonceHash || !context?.sessionNonce || session.nonceHash !== this.hashToken(context.sessionNonce)) { + throw new UnauthorizedException("Session nonce mismatch."); + } + } + + private createSessionNonce(): string { + return crypto.randomBytes(32).toString("base64url"); } private async createRefreshToken(userId: string, sessionId: string): Promise { diff --git a/src/common/guards/jwt-auth.guard.ts b/src/common/guards/jwt-auth.guard.ts index efae9bf..457cb47 100644 --- a/src/common/guards/jwt-auth.guard.ts +++ b/src/common/guards/jwt-auth.guard.ts @@ -46,21 +46,30 @@ export class JwtAuthGuard implements CanActivate { throw new UnauthorizedException("Session-bound token required."); } const session = await this.prisma.session.findUnique({ where: { id: payload.sid } }); - const context = requestContextFrom(request); + const requestContext = requestContextFrom(request); if ( !session || session.userId !== payload.sub || session.revokedAt || session.expiresAt < new Date() || - session.ipHash !== this.hashBindingValue(context.ipAddress ?? "unknown-ip") || - session.userAgentHash !== this.hashBindingValue(context.userAgent ?? "unknown-user-agent") + session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") || + session.userAgentHash !== this.hashBindingValue(requestContext.userAgent ?? "unknown-user-agent") || + !session.nonceHash || + !requestContext.sessionNonce || + session.nonceHash !== this.hashBindingValue(requestContext.sessionNonce) ) { throw new UnauthorizedException("Invalid session binding."); } + const nextNonce = crypto.randomBytes(32).toString("base64url"); + const response = context.switchToHttp().getResponse<{ setHeader(name: string, value: string): void }>(); await this.prisma.session.update({ where: { id: session.id }, - data: { lastSeenAt: new Date() }, + data: { + lastSeenAt: new Date(), + nonceHash: this.hashBindingValue(nextNonce), + }, }); + response.setHeader("X-LedgerOne-Next-Nonce", nextNonce); (request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid }; return true; } catch { diff --git a/test/auth.service.spec.ts b/test/auth.service.spec.ts index 36bc94e..dea81e7 100644 --- a/test/auth.service.spec.ts +++ b/test/auth.service.spec.ts @@ -127,12 +127,13 @@ describe("AuthService session-bound tokens", () => { userAgent: "jest", }); - expect(result).toEqual({ accessToken: "access_token", refreshToken: expect.any(String) }); + expect(result).toEqual({ accessToken: "access_token", refreshToken: expect.any(String), sessionNonce: expect.any(String) }); expect((prisma as any).session.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: "user_1", ipHash: expect.any(String), userAgentHash: expect.any(String), + nonceHash: expect.stringMatching(/^[a-f0-9]{64}$/), }), }); expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });