Add authentication runtime pipeline

This commit is contained in:
diyaa 2026-06-28 03:16:10 +02:00
parent e4edf71955
commit 2ccb54f978
5 changed files with 452 additions and 0 deletions

View File

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

View File

@ -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<string, any>([
[
userId,
{
id: userId,
accountStatus: UserAccountStatus.ACTIVE,
},
],
[
otherUserId,
{
id: otherUserId,
accountStatus: UserAccountStatus.ACTIVE,
},
],
]);
const sessions = new Map<string, any>();
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);
});
});

View File

@ -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<AuthenticatedSessionTokens> {
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<RefreshedAuthenticatedSessionTokens> {
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<void> {
await this.sessionService.revokeSession(sessionId);
}
revokeAllAuthenticatedSessionsForUser(userId: string): Promise<number> {
return this.sessionService.revokeAllSessionsForUser(userId);
}
async validateAccessToken(
accessToken: string,
): Promise<AuthenticatedAccountContext> {
const claims =
await this.accessTokenService.verifyAccessToken(accessToken);
return {
userId: claims.sub,
sessionId: claims.sid,
accessTokenVersion: claims.ver,
};
}
}

View File

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

View File

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