Add rotating session nonce binding

This commit is contained in:
MOHAN 2026-07-16 22:17:20 +05:30
parent d51fc3b954
commit 6395b63098
6 changed files with 42 additions and 13 deletions

View File

@ -0,0 +1 @@
ALTER TABLE "Session" ADD COLUMN "nonceHash" TEXT NOT NULL DEFAULT '';

View File

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

View File

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

View File

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

View File

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

View File

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