Add internal session foundation

This commit is contained in:
diyaa 2026-06-28 00:41:20 +02:00
parent a479d0ddd3
commit 6bf096f6c2
5 changed files with 743 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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<string, any>([
[
activeUserId,
{
id: activeUserId,
accountStatus: UserAccountStatus.ACTIVE,
},
],
[
lockedUserId,
{
id: lockedUserId,
accountStatus: UserAccountStatus.LOCKED,
},
],
[
deletedUserId,
{
id: deletedUserId,
accountStatus: UserAccountStatus.DELETED,
},
],
]);
const sessions = new Map<string, any>();
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<string, unknown> = {}) => {
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,
},
},
},
});
});
});

View File

@ -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<AccountSession> {
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<AccountSession> {
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<AccountSession> {
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<AccountSession> {
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<number> {
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<AccountSession> {
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');
}
}
}