From 9012dde138f1507d5f624dbbb7760c07f217f1d4 Mon Sep 17 00:00:00 2001 From: diyaa Date: Sun, 28 Jun 2026 05:51:36 +0200 Subject: [PATCH] Add account auth guard foundation --- .../src/modules/auth/account-auth-context.ts | 11 ++ .../modules/auth/account-auth.guard.spec.ts | 153 ++++++++++++++++++ .../src/modules/auth/account-auth.guard.ts | 42 +++++ backend/src/modules/auth/auth.module.ts | 3 + .../auth/authentication-runtime.service.ts | 9 +- .../modules/auth/current-account.decorator.ts | 9 ++ 6 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 backend/src/modules/auth/account-auth-context.ts create mode 100644 backend/src/modules/auth/account-auth.guard.spec.ts create mode 100644 backend/src/modules/auth/account-auth.guard.ts create mode 100644 backend/src/modules/auth/current-account.decorator.ts diff --git a/backend/src/modules/auth/account-auth-context.ts b/backend/src/modules/auth/account-auth-context.ts new file mode 100644 index 0000000..1bcebb8 --- /dev/null +++ b/backend/src/modules/auth/account-auth-context.ts @@ -0,0 +1,11 @@ +import type { Request } from 'express'; + +export interface AccountAuthContext { + userId: string; + sessionId: string; + accessTokenVersion: number; +} + +export interface AccountAuthenticatedRequest extends Request { + accountAuthContext: AccountAuthContext; +} diff --git a/backend/src/modules/auth/account-auth.guard.spec.ts b/backend/src/modules/auth/account-auth.guard.spec.ts new file mode 100644 index 0000000..9714949 --- /dev/null +++ b/backend/src/modules/auth/account-auth.guard.spec.ts @@ -0,0 +1,153 @@ +import { UnauthorizedException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import type { Request } from 'express'; +import type { AccountAuthContext } from './account-auth-context'; +import { AccountAuthGuard } from './account-auth.guard'; +import { AuthenticationRuntimeService } from './authentication-runtime.service'; +import { CurrentAccount } from './current-account.decorator'; + +interface RouteArgumentMetadata { + factory: ( + data: unknown, + context: ExecutionContext, + ) => AccountAuthContext; +} + +type TestRequest = Request & { + accountAuthContext?: AccountAuthContext; +}; + +class CurrentAccountDecoratorHarness { + handle(@CurrentAccount() _account: AccountAuthContext): void {} +} + +describe('AccountAuthGuard', () => { + const accountAuthContext: AccountAuthContext = { + userId: 'user-id', + sessionId: 'session-id', + accessTokenVersion: 3, + }; + + const buildGuard = () => { + const authenticationRuntimeService = { + validateAccessToken: jest.fn(), + } as unknown as AuthenticationRuntimeService; + const guard = new AccountAuthGuard(authenticationRuntimeService); + + return { + guard, + validateAccessToken: + authenticationRuntimeService.validateAccessToken as jest.Mock, + }; + }; + + const buildExecutionContext = ( + authorization?: string, + ): { context: ExecutionContext; request: TestRequest } => { + const request = { + headers: authorization ? { authorization } : {}, + } as TestRequest; + const context = { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as ExecutionContext; + + return { context, request }; + }; + + it('validates a Bearer account access token and attaches its context to the request', async () => { + const { guard, validateAccessToken } = buildGuard(); + const { context, request } = buildExecutionContext( + 'Bearer valid-account-access-token', + ); + validateAccessToken.mockResolvedValue(accountAuthContext); + + await expect(guard.canActivate(context)).resolves.toBe(true); + + expect(validateAccessToken).toHaveBeenCalledTimes(1); + expect(validateAccessToken).toHaveBeenCalledWith( + 'valid-account-access-token', + ); + expect(request.accountAuthContext).toEqual(accountAuthContext); + expect(Object.keys(request.accountAuthContext ?? {}).sort()).toEqual( + ['accessTokenVersion', 'sessionId', 'userId'].sort(), + ); + }); + + it('rejects a missing Authorization header without invoking the runtime', async () => { + const { guard, validateAccessToken } = buildGuard(); + const { context } = buildExecutionContext(); + + await expect(guard.canActivate(context)).rejects.toThrow( + new UnauthorizedException('Authorization header is required'), + ); + expect(validateAccessToken).not.toHaveBeenCalled(); + }); + + it.each([ + 'Basic account-access-token', + 'Bearer', + 'Bearer ', + 'Bearer token with-spaces', + ])( + 'rejects malformed Authorization header %p without invoking the runtime', + async (authorization) => { + const { guard, validateAccessToken } = buildGuard(); + const { context } = buildExecutionContext(authorization); + + await expect(guard.canActivate(context)).rejects.toThrow( + new UnauthorizedException('Invalid Authorization header'), + ); + expect(validateAccessToken).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['invalid JWT', 'not-a-jwt'], + ['expired JWT', 'expired.jwt.token'], + ['revoked session', 'revoked.session.token'], + ['wrong token type', 'wrong.type.token'], + ])( + 'rejects %s when the authentication runtime rejects it', + async (_case, token) => { + const { guard, validateAccessToken } = buildGuard(); + const { context, request } = buildExecutionContext(`Bearer ${token}`); + validateAccessToken.mockRejectedValue( + new UnauthorizedException('Invalid account access token'), + ); + + await expect(guard.canActivate(context)).rejects.toThrow( + new UnauthorizedException('Invalid account access token'), + ); + expect(validateAccessToken).toHaveBeenCalledWith(token); + expect(request.accountAuthContext).toBeUndefined(); + }, + ); +}); + +describe('CurrentAccount', () => { + it('returns the AccountAuthContext attached to the request', () => { + const accountAuthContext: AccountAuthContext = { + userId: 'user-id', + sessionId: 'session-id', + accessTokenVersion: 2, + }; + const metadata = Reflect.getMetadata( + ROUTE_ARGS_METADATA, + CurrentAccountDecoratorHarness, + 'handle', + ) as Record; + const decoratorMetadata = Object.values(metadata)[0]; + const context = { + switchToHttp: () => ({ + getRequest: () => ({ accountAuthContext }), + }), + } as ExecutionContext; + + expect(decoratorMetadata.factory(undefined, context)).toBe( + accountAuthContext, + ); + }); +}); diff --git a/backend/src/modules/auth/account-auth.guard.ts b/backend/src/modules/auth/account-auth.guard.ts new file mode 100644 index 0000000..b6a5913 --- /dev/null +++ b/backend/src/modules/auth/account-auth.guard.ts @@ -0,0 +1,42 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import type { Request } from 'express'; +import type { AccountAuthenticatedRequest } from './account-auth-context'; +import { AuthenticationRuntimeService } from './authentication-runtime.service'; + +@Injectable() +export class AccountAuthGuard implements CanActivate { + constructor( + private readonly authenticationRuntimeService: AuthenticationRuntimeService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const accessToken = this.extractBearerToken(request.headers.authorization); + const accountAuthContext = + await this.authenticationRuntimeService.validateAccessToken(accessToken); + + (request as AccountAuthenticatedRequest).accountAuthContext = + accountAuthContext; + + return true; + } + + private extractBearerToken(authorizationHeader?: string): string { + if (!authorizationHeader) { + throw new UnauthorizedException('Authorization header is required'); + } + + const match = authorizationHeader.match(/^Bearer\s+(\S+)$/i); + + if (!match?.[1]) { + throw new UnauthorizedException('Invalid Authorization header'); + } + + return match[1]; + } +} diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts index 2db5398..719afd1 100644 --- a/backend/src/modules/auth/auth.module.ts +++ b/backend/src/modules/auth/auth.module.ts @@ -2,6 +2,7 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 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 { AccountAuthGuard } from './account-auth.guard'; import { AccessTokenService } from './access-token.service'; import { AuthenticationRuntimeService } from './authentication-runtime.service'; import { DeviceAuthGuard } from './device-auth.guard'; @@ -14,6 +15,7 @@ import { SessionService } from './session.service'; @Module({ imports: [PrismaModule, RequestContextModule], providers: [ + AccountAuthGuard, AccessTokenService, AuthenticationRuntimeService, DeviceAuthService, @@ -24,6 +26,7 @@ import { SessionService } from './session.service'; SessionService, ], exports: [ + AccountAuthGuard, AccessTokenService, AuthenticationRuntimeService, DeviceAuthService, diff --git a/backend/src/modules/auth/authentication-runtime.service.ts b/backend/src/modules/auth/authentication-runtime.service.ts index 9bb722c..d07ec5b 100644 --- a/backend/src/modules/auth/authentication-runtime.service.ts +++ b/backend/src/modules/auth/authentication-runtime.service.ts @@ -1,4 +1,5 @@ import { Injectable } from '@nestjs/common'; +import type { AccountAuthContext } from './account-auth-context'; import { AccessTokenService } from './access-token.service'; import { RefreshTokenService } from './refresh-token.service'; import { SessionService } from './session.service'; @@ -21,12 +22,6 @@ export interface RefreshedAuthenticatedSessionTokens { refreshToken: string; } -export interface AuthenticatedAccountContext { - userId: string; - sessionId: string; - accessTokenVersion: number; -} - @Injectable() export class AuthenticationRuntimeService { constructor( @@ -103,7 +98,7 @@ export class AuthenticationRuntimeService { async validateAccessToken( accessToken: string, - ): Promise { + ): Promise { const claims = await this.accessTokenService.verifyAccessToken(accessToken); diff --git a/backend/src/modules/auth/current-account.decorator.ts b/backend/src/modules/auth/current-account.decorator.ts new file mode 100644 index 0000000..7194da1 --- /dev/null +++ b/backend/src/modules/auth/current-account.decorator.ts @@ -0,0 +1,9 @@ +import { createParamDecorator } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import type { AccountAuthenticatedRequest } from './account-auth-context'; + +export const CurrentAccount = createParamDecorator( + (_data: unknown, context: ExecutionContext) => + context.switchToHttp().getRequest() + .accountAuthContext, +);