From 2ccb54f9780888de4fe606b916d30f0fbb56a82b Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 28 Jun 2026 03:16:10 +0200 Subject: [PATCH] Add authentication runtime pipeline --- backend/src/modules/auth/auth.module.ts | 3 + .../authentication-runtime.service.spec.ts | 328 ++++++++++++++++++ .../auth/authentication-runtime.service.ts | 116 +++++++ .../src/modules/auth/session.service.spec.ts | 3 + backend/src/modules/auth/session.service.ts | 2 + 5 files changed, 452 insertions(+) create mode 100644 backend/src/modules/auth/authentication-runtime.service.spec.ts create mode 100644 backend/src/modules/auth/authentication-runtime.service.ts diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 629b729..2db5398 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -3,6 +3,7 @@ import { PrismaModule } from '../../infrastructure/database/prisma.module'; import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware'; import { RequestContextModule } from '../../infrastructure/request-context/request-context.module'; import { AccessTokenService } from './access-token.service'; +import { AuthenticationRuntimeService } from './authentication-runtime.service'; import { DeviceAuthGuard } from './device-auth.guard'; import { DeviceAuthService } from './device-auth.service'; import { OptionalDeviceAuthGuard } from './optional-device-auth.guard'; @@ -14,6 +15,7 @@ import { SessionService } from './session.service'; imports: [PrismaModule, RequestContextModule], providers: [ AccessTokenService, + AuthenticationRuntimeService, DeviceAuthService, DeviceAuthGuard, OptionalDeviceAuthGuard, @@ -23,6 +25,7 @@ import { SessionService } from './session.service'; ], exports: [ AccessTokenService, + AuthenticationRuntimeService, DeviceAuthService, DeviceAuthGuard, OptionalDeviceAuthGuard, diff --git a/backend/src/modules/auth/authentication-runtime.service.spec.ts b/backend/src/modules/auth/authentication-runtime.service.spec.ts new file mode 100644 index 0000000..6705bdf --- /dev/null +++ b/backend/src/modules/auth/authentication-runtime.service.spec.ts @@ -0,0 +1,328 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { UnauthorizedException } from '@nestjs/common'; +import { + AccountSessionStatus, + UserAccountStatus, +} from '@prisma/client'; +import { AppConfigService } from '../config/config.service'; +import { AccessTokenService } from './access-token.service'; +import { AuthenticationRuntimeService } from './authentication-runtime.service'; +import { RefreshTokenService } from './refresh-token.service'; +import { SessionService } from './session.service'; + +describe('AuthenticationRuntimeService', () => { + const userId = randomUUID(); + const otherUserId = randomUUID(); + const accessTokenTtlSeconds = 900; + const sessionExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + + const buildService = () => { + const users = new Map([ + [ + userId, + { + id: userId, + accountStatus: UserAccountStatus.ACTIVE, + }, + ], + [ + otherUserId, + { + id: otherUserId, + accountStatus: UserAccountStatus.ACTIVE, + }, + ], + ]); + const sessions = new Map(); + + const accountSession = { + create: jest.fn().mockImplementation(async ({ data }) => { + const now = new Date(); + const session = { + id: randomUUID(), + userId: data.userId, + refreshTokenHash: data.refreshTokenHash, + status: data.status ?? AccountSessionStatus.ACTIVE, + accessTokenVersion: data.accessTokenVersion ?? 1, + expiresAt: data.expiresAt, + lastRefreshedAt: null, + lastSeenAt: null, + revokedAt: null, + createdAt: now, + updatedAt: now, + }; + sessions.set(session.id, session); + return { ...session }; + }), + findUnique: jest.fn().mockImplementation(async ({ where, include }) => { + const session = + (where.id ? sessions.get(where.id) : undefined) ?? + Array.from(sessions.values()).find( + (candidate) => + where.refreshTokenHash !== undefined && + candidate.refreshTokenHash === where.refreshTokenHash, + ) ?? + null; + + if (!session) { + return null; + } + + if (include?.user) { + return { + ...session, + user: users.get(session.userId), + }; + } + + return { ...session }; + }), + update: jest.fn().mockImplementation(async ({ where, data }) => { + const session = sessions.get(where.id); + if (!session) { + return null; + } + + Object.assign(session, data, { updatedAt: new Date() }); + return { ...session }; + }), + updateMany: jest.fn().mockImplementation(async ({ where, data }) => { + let count = 0; + + for (const session of sessions.values()) { + const user = users.get(session.userId); + const matches = + (where.id === undefined || session.id === where.id) && + (where.userId === undefined || session.userId === where.userId) && + (where.refreshTokenHash === undefined || + session.refreshTokenHash === where.refreshTokenHash) && + (where.status === undefined || session.status === where.status) && + (where.expiresAt?.gt === undefined || + session.expiresAt.getTime() > where.expiresAt.gt.getTime()) && + (where.user?.accountStatus === undefined || + user?.accountStatus === where.user.accountStatus); + + if (!matches) { + continue; + } + + for (const [key, value] of Object.entries(data)) { + if (value && typeof value === 'object' && 'increment' in value) { + session[key] += (value as { increment: number }).increment; + } else { + session[key] = value; + } + } + session.updatedAt = new Date(); + count += 1; + } + + return { count }; + }), + }; + const prismaService = { + user: { + findUnique: jest.fn().mockImplementation(async ({ where }) => + users.get(where.id) ?? null, + ), + }, + accountSession, + } as any; + const configService = { + accountAccessTokenSecret: 'runtime-access-token-test-secret', + accountAccessTokenTtlSeconds: accessTokenTtlSeconds, + } as AppConfigService; + const sessionService = new SessionService(prismaService); + const refreshTokenService = new RefreshTokenService( + prismaService, + sessionService, + ); + const accessTokenService = new AccessTokenService( + configService, + sessionService, + ); + const service = new AuthenticationRuntimeService( + sessionService, + accessTokenService, + refreshTokenService, + ); + + return { + service, + sessions, + accessTokenService, + refreshTokenService, + }; + }; + + it('creates an authenticated session with access and refresh tokens', async () => { + const { service, accessTokenService } = buildService(); + + const result = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + const claims = accessTokenService.decodeAccessToken(result.accessToken); + + expect(result).toMatchObject({ + userId, + sessionId: expect.any(String), + accessToken: expect.any(String), + refreshToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + accessTokenExpiresAt: expect.any(Date), + sessionExpiresAt, + }); + expect(claims).toMatchObject({ + sub: userId, + sid: result.sessionId, + ver: 1, + typ: 'account_access', + }); + expect(result.accessTokenExpiresAt.toISOString()).toBe( + new Date((claims.exp as number) * 1000).toISOString(), + ); + }); + + it('returns no token hashes, password fields, or provider internals', async () => { + const { service } = buildService(); + + const result = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + + expect(Object.keys(result).sort()).toEqual( + [ + 'accessToken', + 'accessTokenExpiresAt', + 'refreshToken', + 'sessionExpiresAt', + 'sessionId', + 'userId', + ].sort(), + ); + expect(result).not.toHaveProperty('refreshTokenHash'); + expect(result).not.toHaveProperty('password'); + expect(result).not.toHaveProperty('passwordHash'); + expect(result).not.toHaveProperty('oauthProvider'); + expect(result).not.toHaveProperty('providerSubject'); + }); + + it('rotates the refresh token and issues a new versioned access token', async () => { + const { service, accessTokenService, refreshTokenService } = buildService(); + const created = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + + const refreshed = await service.refreshAuthenticatedSession( + created.sessionId, + created.refreshToken, + ); + const claims = accessTokenService.decodeAccessToken( + refreshed.accessToken, + ); + + expect(refreshed.accessToken).not.toBe(created.accessToken); + expect(refreshed.refreshToken).not.toBe(created.refreshToken); + expect(claims).toMatchObject({ + sub: userId, + sid: created.sessionId, + ver: 2, + }); + await expect( + refreshTokenService.verifyRefreshToken(created.refreshToken), + ).rejects.toThrow(UnauthorizedException); + await expect( + refreshTokenService.verifyRefreshToken(refreshed.refreshToken), + ).resolves.toMatchObject({ id: created.sessionId }); + }); + + it('rejects refresh after revoking one session and retains its history', async () => { + const { service, sessions } = buildService(); + const created = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + + await service.revokeAuthenticatedSession(created.sessionId); + + expect(sessions.get(created.sessionId)).toMatchObject({ + id: created.sessionId, + status: AccountSessionStatus.REVOKED, + refreshTokenHash: null, + }); + expect(sessions.has(created.sessionId)).toBe(true); + await expect( + service.refreshAuthenticatedSession( + created.sessionId, + created.refreshToken, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('revokes every account session for one user', async () => { + const { service, sessions } = buildService(); + const first = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + const second = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + const other = await service.createAuthenticatedSession(otherUserId, { + expiresAt: sessionExpiresAt, + }); + + await expect( + service.revokeAllAuthenticatedSessionsForUser(userId), + ).resolves.toBe(2); + + for (const created of [first, second]) { + expect(sessions.get(created.sessionId)).toMatchObject({ + status: AccountSessionStatus.REVOKED, + refreshTokenHash: null, + }); + await expect( + service.refreshAuthenticatedSession( + created.sessionId, + created.refreshToken, + ), + ).rejects.toThrow(UnauthorizedException); + } + await expect( + service.refreshAuthenticatedSession( + other.sessionId, + other.refreshToken, + ), + ).resolves.toEqual({ + accessToken: expect.any(String), + refreshToken: expect.any(String), + }); + }); + + it('validates an access token into account authentication context', async () => { + const { service } = buildService(); + const created = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + + await expect( + service.validateAccessToken(created.accessToken), + ).resolves.toEqual({ + userId, + sessionId: created.sessionId, + accessTokenVersion: 1, + }); + }); + + it('stores refresh tokens only as hashes', async () => { + const { service, sessions } = buildService(); + const created = await service.createAuthenticatedSession(userId, { + expiresAt: sessionExpiresAt, + }); + const session = sessions.get(created.sessionId); + + expect(session.refreshTokenHash).toBe( + createHash('sha256') + .update(created.refreshToken, 'utf8') + .digest('hex'), + ); + expect(session.refreshTokenHash).not.toContain(created.refreshToken); + }); +}); diff --git a/backend/src/modules/auth/authentication-runtime.service.ts b/backend/src/modules/auth/authentication-runtime.service.ts new file mode 100644 index 0000000..9bb722c --- /dev/null +++ b/backend/src/modules/auth/authentication-runtime.service.ts @@ -0,0 +1,116 @@ +import { Injectable } from '@nestjs/common'; +import { AccessTokenService } from './access-token.service'; +import { RefreshTokenService } from './refresh-token.service'; +import { SessionService } from './session.service'; + +export interface AuthenticationSessionMetadata { + expiresAt: Date; +} + +export interface AuthenticatedSessionTokens { + userId: string; + sessionId: string; + accessToken: string; + refreshToken: string; + accessTokenExpiresAt: Date; + sessionExpiresAt: Date; +} + +export interface RefreshedAuthenticatedSessionTokens { + accessToken: string; + refreshToken: string; +} + +export interface AuthenticatedAccountContext { + userId: string; + sessionId: string; + accessTokenVersion: number; +} + +@Injectable() +export class AuthenticationRuntimeService { + constructor( + private readonly sessionService: SessionService, + private readonly accessTokenService: AccessTokenService, + private readonly refreshTokenService: RefreshTokenService, + ) {} + + async createAuthenticatedSession( + userId: string, + metadata: AuthenticationSessionMetadata, + ): Promise { + const refreshToken = await this.refreshTokenService.issueRefreshToken({ + userId, + expiresAt: metadata.expiresAt, + }); + const session = + await this.refreshTokenService.verifyRefreshToken(refreshToken); + const accessToken = this.accessTokenService.issueAccessToken({ + userId: session.userId, + sessionId: session.id, + accessTokenVersion: session.accessTokenVersion, + }); + const accessTokenClaims = + this.accessTokenService.decodeAccessToken(accessToken); + + return { + userId: session.userId, + sessionId: session.id, + accessToken, + refreshToken, + accessTokenExpiresAt: new Date( + (accessTokenClaims.exp as number) * 1000, + ), + sessionExpiresAt: session.expiresAt, + }; + } + + async refreshAuthenticatedSession( + sessionId: string, + refreshToken: string, + ): Promise { + await this.refreshTokenService.assertRefreshTokenSession( + sessionId, + refreshToken, + ); + + const nextRefreshToken = + await this.refreshTokenService.rotateRefreshToken(refreshToken); + const session = + await this.refreshTokenService.assertRefreshTokenSession( + sessionId, + nextRefreshToken, + ); + const accessToken = this.accessTokenService.issueAccessToken({ + userId: session.userId, + sessionId: session.id, + accessTokenVersion: session.accessTokenVersion, + }); + + return { + accessToken, + refreshToken: nextRefreshToken, + }; + } + + async revokeAuthenticatedSession(sessionId: string): Promise { + await this.sessionService.revokeSession(sessionId); + } + + revokeAllAuthenticatedSessionsForUser(userId: string): Promise { + return this.sessionService.revokeAllSessionsForUser(userId); + } + + async validateAccessToken( + accessToken: string, + ): Promise { + const claims = + await this.accessTokenService.verifyAccessToken(accessToken); + + return { + userId: claims.sub, + sessionId: claims.sid, + accessTokenVersion: claims.ver, + }; + } +} diff --git a/backend/src/modules/auth/session.service.spec.ts b/backend/src/modules/auth/session.service.spec.ts index 7e65bd5..4a251f6 100644 --- a/backend/src/modules/auth/session.service.spec.ts +++ b/backend/src/modules/auth/session.service.spec.ts @@ -226,6 +226,7 @@ describe('SessionService', () => { const result = await service.revokeSession(session.id, now); expect(result.status).toBe(AccountSessionStatus.REVOKED); + expect(result.refreshTokenHash).toBeNull(); expect(result.revokedAt).toEqual(now); await expect( service.validateSession( @@ -248,7 +249,9 @@ describe('SessionService', () => { service.revokeAllSessionsForUser(activeUserId, now), ).resolves.toBe(2); expect(first.status).toBe(AccountSessionStatus.REVOKED); + expect(first.refreshTokenHash).toBeNull(); expect(second.status).toBe(AccountSessionStatus.REVOKED); + expect(second.refreshTokenHash).toBeNull(); expect(other.status).toBe(AccountSessionStatus.ACTIVE); }); diff --git a/backend/src/modules/auth/session.service.ts b/backend/src/modules/auth/session.service.ts index 12ece0b..22ddf25 100644 --- a/backend/src/modules/auth/session.service.ts +++ b/backend/src/modules/auth/session.service.ts @@ -195,6 +195,7 @@ export class SessionService { id: sessionId, }, data: { + refreshTokenHash: null, status: AccountSessionStatus.REVOKED, revokedAt: now, }, @@ -211,6 +212,7 @@ export class SessionService { status: AccountSessionStatus.ACTIVE, }, data: { + refreshTokenHash: null, status: AccountSessionStatus.REVOKED, revokedAt: now, },