diff --git a/backend/prisma/migrations/20260627190000_milestone1171_internal_session_foundation/migration.sql b/backend/prisma/migrations/20260627190000_milestone1171_internal_session_foundation/migration.sql new file mode 100644 index 0000000..f9b6f29 --- /dev/null +++ b/backend/prisma/migrations/20260627190000_milestone1171_internal_session_foundation/migration.sql @@ -0,0 +1,30 @@ +CREATE TYPE "AccountSessionStatus" AS ENUM ('ACTIVE', 'REVOKED', 'EXPIRED'); + +CREATE TABLE "account_sessions" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "refresh_token_hash" TEXT NOT NULL, + "status" "AccountSessionStatus" NOT NULL DEFAULT 'ACTIVE', + "access_token_version" INTEGER NOT NULL DEFAULT 1, + "expires_at" TIMESTAMP(3) NOT NULL, + "last_refreshed_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + CONSTRAINT "account_sessions_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "account_sessions_refresh_token_hash_key" +ON "account_sessions"("refresh_token_hash"); + +CREATE INDEX "account_sessions_user_id_idx" +ON "account_sessions"("user_id"); + +CREATE INDEX "account_sessions_status_expires_at_idx" +ON "account_sessions"("status", "expires_at"); + +ALTER TABLE "account_sessions" +ADD CONSTRAINT "account_sessions_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "users"("id") +ON DELETE CASCADE +ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e3c4880..9e263bb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -33,6 +33,7 @@ model User { outgoingTransfers OwnershipTransfer[] @relation("OwnershipTransferFromUser") incomingTransfers OwnershipTransfer[] @relation("OwnershipTransferToUser") initiatedTransfers OwnershipTransfer[] @relation("OwnershipTransferRequestedByUser") + accountSessions AccountSession[] @@map("users") } @@ -282,6 +283,24 @@ model OwnershipTransfer { @@map("ownership_transfers") } +model AccountSession { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + refreshTokenHash String @unique @map("refresh_token_hash") + status AccountSessionStatus @default(ACTIVE) + accessTokenVersion Int @default(1) @map("access_token_version") + expiresAt DateTime @map("expires_at") + lastRefreshedAt DateTime? @map("last_refreshed_at") + revokedAt DateTime? @map("revoked_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + + @@index([userId]) + @@index([status, expiresAt]) + @@map("account_sessions") +} + enum UserAccountKind { LEGACY_DEFAULT GUEST @@ -294,6 +313,12 @@ enum UserAccountStatus { DELETED } +enum AccountSessionStatus { + ACTIVE + REVOKED + EXPIRED +} + enum DevicePlatform { MACOS IPHONE diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 3e49c2b..3a56c26 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -6,6 +6,7 @@ import { DeviceAuthGuard } from './device-auth.guard'; import { DeviceAuthService } from './device-auth.service'; import { OptionalDeviceAuthGuard } from './optional-device-auth.guard'; import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middleware'; +import { SessionService } from './session.service'; @Module({ imports: [PrismaModule, RequestContextModule], @@ -14,12 +15,14 @@ import { ProtectedDeviceAuthMiddleware } from './protected-device-auth.middlewar DeviceAuthGuard, OptionalDeviceAuthGuard, ProtectedDeviceAuthMiddleware, + SessionService, ], exports: [ DeviceAuthService, DeviceAuthGuard, OptionalDeviceAuthGuard, ProtectedDeviceAuthMiddleware, + SessionService, ], }) export class AuthModule implements NestModule { diff --git a/backend/src/modules/auth/session.service.spec.ts b/backend/src/modules/auth/session.service.spec.ts new file mode 100644 index 0000000..7e65bd5 --- /dev/null +++ b/backend/src/modules/auth/session.service.spec.ts @@ -0,0 +1,389 @@ +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + UnauthorizedException, +} from '@nestjs/common'; +import { + AccountSessionStatus, + UserAccountStatus, +} from '@prisma/client'; +import { SessionService } from './session.service'; + +describe('SessionService', () => { + const now = new Date('2026-06-27T18:00:00.000Z'); + const future = new Date('2026-07-27T18:00:00.000Z'); + const activeUserId = randomUUID(); + const lockedUserId = randomUUID(); + const deletedUserId = randomUUID(); + + const buildService = () => { + const users = new Map([ + [ + activeUserId, + { + id: activeUserId, + accountStatus: UserAccountStatus.ACTIVE, + }, + ], + [ + lockedUserId, + { + id: lockedUserId, + accountStatus: UserAccountStatus.LOCKED, + }, + ], + [ + deletedUserId, + { + id: deletedUserId, + accountStatus: UserAccountStatus.DELETED, + }, + ], + ]); + const sessions = new Map(); + + const accountSession = { + create: jest.fn().mockImplementation(async ({ data }) => { + const session = { + id: randomUUID(), + userId: data.userId, + refreshTokenHash: data.refreshTokenHash, + status: data.status ?? AccountSessionStatus.ACTIVE, + accessTokenVersion: data.accessTokenVersion ?? 1, + expiresAt: data.expiresAt, + lastRefreshedAt: null, + revokedAt: null, + createdAt: now, + updatedAt: now, + }; + sessions.set(session.id, session); + return { ...session }; + }), + findUnique: jest.fn().mockImplementation(async ({ where, include }) => { + const session = sessions.get(where.id) ?? 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: now }); + 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 = now; + count += 1; + } + + return { count }; + }), + }; + + const user = { + findUnique: jest.fn().mockImplementation(async ({ where }) => + users.get(where.id) ?? null, + ), + }; + + const prismaService = { + user, + accountSession, + } as any; + const service = new SessionService(prismaService); + + const addSession = (overrides: Record = {}) => { + const session = { + id: randomUUID(), + userId: activeUserId, + refreshTokenHash: `refresh-${randomUUID()}`, + status: AccountSessionStatus.ACTIVE, + accessTokenVersion: 1, + expiresAt: future, + lastRefreshedAt: null, + revokedAt: null, + createdAt: now, + updatedAt: now, + ...overrides, + }; + sessions.set(session.id, session); + return session; + }; + + return { + service, + prismaService, + users, + sessions, + addSession, + }; + }; + + it('creates a session for an active user', async () => { + const { service, sessions } = buildService(); + + const result = await service.createSession( + { + userId: activeUserId, + refreshTokenHash: 'initial-refresh-token-hash', + expiresAt: future, + }, + now, + ); + + expect(result).toMatchObject({ + userId: activeUserId, + refreshTokenHash: 'initial-refresh-token-hash', + status: AccountSessionStatus.ACTIVE, + accessTokenVersion: 1, + expiresAt: future, + }); + expect(sessions.get(result.id)).toBeDefined(); + }); + + it('rotates the refresh token hash and access token version', async () => { + const { service, sessions, addSession } = buildService(); + const session = addSession({ + refreshTokenHash: 'old-refresh-token-hash', + accessTokenVersion: 4, + }); + + const result = await service.refreshSession( + { + sessionId: session.id, + refreshTokenHash: 'old-refresh-token-hash', + nextRefreshTokenHash: 'new-refresh-token-hash', + }, + now, + ); + + expect(result).toMatchObject({ + refreshTokenHash: 'new-refresh-token-hash', + accessTokenVersion: 5, + lastRefreshedAt: now, + }); + expect(sessions.get(session.id)?.refreshTokenHash).toBe( + 'new-refresh-token-hash', + ); + await expect( + service.refreshSession( + { + sessionId: session.id, + refreshTokenHash: 'old-refresh-token-hash', + nextRefreshTokenHash: 'another-refresh-token-hash', + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('revokes a session and prevents validation', async () => { + const { service, addSession } = buildService(); + const session = addSession(); + + const result = await service.revokeSession(session.id, now); + + expect(result.status).toBe(AccountSessionStatus.REVOKED); + expect(result.revokedAt).toEqual(now); + await expect( + service.validateSession( + { + sessionId: session.id, + accessTokenVersion: 1, + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('revokes all active sessions for a user only', async () => { + const { service, addSession } = buildService(); + const first = addSession(); + const second = addSession(); + const other = addSession({ userId: randomUUID() }); + + await expect( + service.revokeAllSessionsForUser(activeUserId, now), + ).resolves.toBe(2); + expect(first.status).toBe(AccountSessionStatus.REVOKED); + expect(second.status).toBe(AccountSessionStatus.REVOKED); + expect(other.status).toBe(AccountSessionStatus.ACTIVE); + }); + + it('rejects validation and refresh for an expired session', async () => { + const { service, addSession } = buildService(); + const session = addSession({ + expiresAt: new Date('2026-06-27T17:59:59.000Z'), + }); + + await expect( + service.validateSession( + { + sessionId: session.id, + accessTokenVersion: 1, + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + service.refreshSession( + { + sessionId: session.id, + refreshTokenHash: session.refreshTokenHash, + nextRefreshTokenHash: 'rotated-expired-hash', + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects refresh for a revoked session', async () => { + const { service, addSession } = buildService(); + const session = addSession({ + status: AccountSessionStatus.REVOKED, + revokedAt: now, + }); + + await expect( + service.refreshSession( + { + sessionId: session.id, + refreshTokenHash: session.refreshTokenHash, + nextRefreshTokenHash: 'rotated-revoked-hash', + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects session creation and validation for a locked user', async () => { + const { service, addSession } = buildService(); + + await expect( + service.createSession( + { + userId: lockedUserId, + refreshTokenHash: 'locked-user-refresh-hash', + expiresAt: future, + }, + now, + ), + ).rejects.toThrow(new BadRequestException('Account is not active')); + + const existingSession = addSession({ userId: lockedUserId }); + await expect( + service.validateSession( + { + sessionId: existingSession.id, + accessTokenVersion: 1, + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('rejects session creation and validation for a deleted user', async () => { + const { service, addSession } = buildService(); + + await expect( + service.createSession( + { + userId: deletedUserId, + refreshTokenHash: 'deleted-user-refresh-hash', + expiresAt: future, + }, + now, + ), + ).rejects.toThrow(new BadRequestException('Account is not active')); + + const existingSession = addSession({ userId: deletedUserId }); + await expect( + service.validateSession( + { + sessionId: existingSession.id, + accessTokenVersion: 1, + }, + now, + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('expires a session explicitly', async () => { + const { service, addSession } = buildService(); + const session = addSession(); + + const result = await service.expireSession(session.id, now); + + expect(result.status).toBe(AccountSessionStatus.EXPIRED); + }); + + it('validates by session identity and version without selecting email', async () => { + const { service, prismaService, addSession } = buildService(); + const session = addSession({ accessTokenVersion: 7 }); + + await expect( + service.validateSession( + { + sessionId: session.id, + accessTokenVersion: 7, + }, + now, + ), + ).resolves.toMatchObject({ id: session.id, userId: activeUserId }); + + expect(prismaService.accountSession.findUnique).toHaveBeenCalledWith({ + where: { + id: session.id, + }, + include: { + user: { + select: { + accountStatus: true, + }, + }, + }, + }); + }); +}); diff --git a/backend/src/modules/auth/session.service.ts b/backend/src/modules/auth/session.service.ts new file mode 100644 index 0000000..a610556 --- /dev/null +++ b/backend/src/modules/auth/session.service.ts @@ -0,0 +1,296 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { + AccountSession, + AccountSessionStatus, + UserAccountStatus, +} from '@prisma/client'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +export interface CreateSessionInput { + userId: string; + refreshTokenHash: string; + expiresAt: Date; +} + +export interface RefreshSessionInput { + sessionId: string; + refreshTokenHash: string; + nextRefreshTokenHash: string; + expiresAt?: Date; +} + +export interface ValidateSessionInput { + sessionId: string; + accessTokenVersion: number; +} + +@Injectable() +export class SessionService { + constructor(private readonly prismaService: PrismaService) {} + + async createSession( + input: CreateSessionInput, + now = new Date(), + ): Promise { + const user = await this.prismaService.user.findUnique({ + where: { + id: input.userId, + }, + select: { + id: true, + accountStatus: true, + }, + }); + + if (!user) { + throw new NotFoundException('Account not found'); + } + + if (user.accountStatus !== UserAccountStatus.ACTIVE) { + throw new BadRequestException('Account is not active'); + } + + this.assertUsableRefreshTokenHash(input.refreshTokenHash); + this.assertFutureExpiration(input.expiresAt, now); + + return this.prismaService.accountSession.create({ + data: { + userId: user.id, + refreshTokenHash: input.refreshTokenHash, + status: AccountSessionStatus.ACTIVE, + accessTokenVersion: 1, + expiresAt: input.expiresAt, + }, + }); + } + + async refreshSession( + input: RefreshSessionInput, + now = new Date(), + ): Promise { + this.assertUsableRefreshTokenHash(input.refreshTokenHash); + this.assertUsableRefreshTokenHash(input.nextRefreshTokenHash); + + if (input.refreshTokenHash === input.nextRefreshTokenHash) { + throw new BadRequestException('Refresh token hash must be rotated'); + } + + if (input.expiresAt) { + this.assertFutureExpiration(input.expiresAt, now); + } + + const session = await this.prismaService.accountSession.findUnique({ + where: { + id: input.sessionId, + }, + include: { + user: { + select: { + accountStatus: true, + }, + }, + }, + }); + + this.assertSessionCanBeUsed( + session, + input.refreshTokenHash, + now, + 'refresh', + ); + + const rotation = await this.prismaService.accountSession.updateMany({ + where: { + id: input.sessionId, + refreshTokenHash: input.refreshTokenHash, + status: AccountSessionStatus.ACTIVE, + expiresAt: { + gt: now, + }, + user: { + accountStatus: UserAccountStatus.ACTIVE, + }, + }, + data: { + refreshTokenHash: input.nextRefreshTokenHash, + accessTokenVersion: { + increment: 1, + }, + lastRefreshedAt: now, + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + }, + }); + + if (rotation.count !== 1) { + throw new UnauthorizedException('Session cannot be refreshed'); + } + + const refreshedSession = + await this.prismaService.accountSession.findUnique({ + where: { + id: input.sessionId, + }, + }); + + if (!refreshedSession) { + throw new UnauthorizedException('Session cannot be refreshed'); + } + + return refreshedSession; + } + + async validateSession( + input: ValidateSessionInput, + now = new Date(), + ): Promise { + const session = await this.prismaService.accountSession.findUnique({ + where: { + id: input.sessionId, + }, + include: { + user: { + select: { + accountStatus: true, + }, + }, + }, + }); + + this.assertSessionCanBeUsed(session, undefined, now, 'validate'); + + if (session.accessTokenVersion !== input.accessTokenVersion) { + throw new UnauthorizedException('Session is invalid'); + } + + const { user: _user, ...validatedSession } = session; + return validatedSession; + } + + async revokeSession( + sessionId: string, + now = new Date(), + ): Promise { + const session = await this.prismaService.accountSession.findUnique({ + where: { + id: sessionId, + }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + if (session.status === AccountSessionStatus.REVOKED) { + return session; + } + + return this.prismaService.accountSession.update({ + where: { + id: sessionId, + }, + data: { + status: AccountSessionStatus.REVOKED, + revokedAt: now, + }, + }); + } + + async revokeAllSessionsForUser( + userId: string, + now = new Date(), + ): Promise { + const result = await this.prismaService.accountSession.updateMany({ + where: { + userId, + status: AccountSessionStatus.ACTIVE, + }, + data: { + status: AccountSessionStatus.REVOKED, + revokedAt: now, + }, + }); + + return result.count; + } + + async expireSession( + sessionId: string, + now = new Date(), + ): Promise { + const session = await this.prismaService.accountSession.findUnique({ + where: { + id: sessionId, + }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + if (session.status === AccountSessionStatus.EXPIRED) { + return session; + } + + return this.prismaService.accountSession.update({ + where: { + id: sessionId, + }, + data: { + status: AccountSessionStatus.EXPIRED, + }, + }); + } + + private assertSessionCanBeUsed( + session: + | (AccountSession & { + user: { + accountStatus: UserAccountStatus; + }; + }) + | null, + refreshTokenHash: string | undefined, + now: Date, + operation: 'refresh' | 'validate', + ): asserts session is AccountSession & { + user: { + accountStatus: UserAccountStatus; + }; + } { + const message = + operation === 'refresh' + ? 'Session cannot be refreshed' + : 'Session is invalid'; + + if ( + !session || + session.status !== AccountSessionStatus.ACTIVE || + session.expiresAt.getTime() <= now.getTime() || + session.user.accountStatus !== UserAccountStatus.ACTIVE || + (refreshTokenHash !== undefined && + session.refreshTokenHash !== refreshTokenHash) + ) { + throw new UnauthorizedException(message); + } + } + + private assertUsableRefreshTokenHash(refreshTokenHash: string): void { + if (!refreshTokenHash.trim()) { + throw new BadRequestException('Refresh token hash is required'); + } + } + + private assertFutureExpiration(expiresAt: Date, now: Date): void { + if ( + Number.isNaN(expiresAt.getTime()) || + expiresAt.getTime() <= now.getTime() + ) { + throw new BadRequestException('Session expiration must be in the future'); + } + } +}