Add account auth guard foundation

This commit is contained in:
diyaa 2026-06-28 05:51:36 +02:00
parent 2ccb54f978
commit 9012dde138
6 changed files with 220 additions and 7 deletions

View File

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

View File

@ -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<string, RouteArgumentMetadata>;
const decoratorMetadata = Object.values(metadata)[0];
const context = {
switchToHttp: () => ({
getRequest: () => ({ accountAuthContext }),
}),
} as ExecutionContext;
expect(decoratorMetadata.factory(undefined, context)).toBe(
accountAuthContext,
);
});
});

View File

@ -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<boolean> {
const request = context.switchToHttp().getRequest<Request>();
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];
}
}

View File

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

View File

@ -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<AuthenticatedAccountContext> {
): Promise<AccountAuthContext> {
const claims =
await this.accessTokenService.verifyAccessToken(accessToken);

View File

@ -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<AccountAuthenticatedRequest>()
.accountAuthContext,
);