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 userId String
ipHash String ipHash String
userAgentHash String userAgentHash String
nonceHash String @default("")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
lastSeenAt DateTime @default(now()) lastSeenAt DateTime @default(now())
expiresAt DateTime expiresAt DateTime

View File

@ -1,6 +1,7 @@
export type RequestContext = { export type RequestContext = {
ipAddress?: string; ipAddress?: string;
userAgent?: string; userAgent?: string;
sessionNonce?: string;
}; };
export function requestContextFrom(req: { ip?: string; headers?: { [key: string]: unknown } }): RequestContext { 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 { return {
ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip, ipAddress: typeof forwardedFor === "string" ? forwardedFor.split(",")[0].trim() : req.ip,
userAgent: typeof userAgent === "string" ? userAgent : undefined, 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); 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 { return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken, accessToken,
refreshToken, refreshToken,
sessionNonce,
message: "Registration successful. Please verify your email.", 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 } } }); 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 { return {
user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified }, user: { id: user.id, email: user.email, fullName: user.fullName, emailVerified: user.emailVerified },
accessToken, accessToken,
refreshToken, refreshToken,
sessionNonce,
}; };
} }
@ -110,21 +112,22 @@ export class AuthService {
throw new UnauthorizedException("Invalid or expired refresh token."); throw new UnauthorizedException("Invalid or expired refresh token.");
} }
this.assertSessionMatches(record.session, context); 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.refreshToken.update({ where: { id: record.id }, data: { revokedAt: new Date() } });
await this.prisma.session.update({ await this.prisma.session.update({
where: { id: record.sessionId! }, 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 accessToken = this.signAccessToken(record.userId, record.sessionId!);
const refreshToken = await this.createRefreshToken(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) { 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 accessToken = this.signAccessToken(userId, session.id);
const refreshToken = await this.createRefreshToken(userId, session.id); const refreshToken = await this.createRefreshToken(userId, session.id);
return { accessToken, refreshToken }; return { accessToken, refreshToken, sessionNonce };
} }
async logout(rawRefreshToken: string) { async logout(rawRefreshToken: string) {
@ -267,18 +270,21 @@ export class AuthService {
private async createSession(userId: string, context?: RequestContext) { private async createSession(userId: string, context?: RequestContext) {
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 86400 * 1000); 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: { data: {
userId, userId,
ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"), ipHash: this.hashBindingValue(context?.ipAddress ?? "unknown-ip"),
userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"), userAgentHash: this.hashBindingValue(context?.userAgent ?? "unknown-user-agent"),
nonceHash: this.hashToken(sessionNonce),
expiresAt, expiresAt,
}, },
}); });
return { session, sessionNonce };
} }
private assertSessionMatches( 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, context?: RequestContext,
) { ) {
if (session.revokedAt || session.expiresAt < new Date()) { if (session.revokedAt || session.expiresAt < new Date()) {
@ -289,6 +295,13 @@ export class AuthService {
if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) { if (session.ipHash !== ipHash || session.userAgentHash !== userAgentHash) {
throw new UnauthorizedException("Session binding mismatch."); 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> { 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."); throw new UnauthorizedException("Session-bound token required.");
} }
const session = await this.prisma.session.findUnique({ where: { id: payload.sid } }); const session = await this.prisma.session.findUnique({ where: { id: payload.sid } });
const context = requestContextFrom(request); const requestContext = requestContextFrom(request);
if ( if (
!session || !session ||
session.userId !== payload.sub || session.userId !== payload.sub ||
session.revokedAt || session.revokedAt ||
session.expiresAt < new Date() || session.expiresAt < new Date() ||
session.ipHash !== this.hashBindingValue(context.ipAddress ?? "unknown-ip") || session.ipHash !== this.hashBindingValue(requestContext.ipAddress ?? "unknown-ip") ||
session.userAgentHash !== this.hashBindingValue(context.userAgent ?? "unknown-user-agent") 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."); 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({ await this.prisma.session.update({
where: { id: session.id }, 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 }; (request as Request & { user: { sub: string; sid: string } }).user = { sub: payload.sub, sid: payload.sid };
return true; return true;
} catch { } catch {

View File

@ -127,12 +127,13 @@ describe("AuthService session-bound tokens", () => {
userAgent: "jest", 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({ expect((prisma as any).session.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
userId: "user_1", userId: "user_1",
ipHash: expect.any(String), ipHash: expect.any(String),
userAgentHash: expect.any(String), userAgentHash: expect.any(String),
nonceHash: expect.stringMatching(/^[a-f0-9]{64}$/),
}), }),
}); });
expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" }); expect(jwt.sign).toHaveBeenCalledWith({ sub: "user_1", sid: "session_1" });