Add account auth guard foundation
This commit is contained in:
parent
2ccb54f978
commit
9012dde138
11
backend/src/modules/auth/account-auth-context.ts
Normal file
11
backend/src/modules/auth/account-auth-context.ts
Normal 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;
|
||||
}
|
||||
153
backend/src/modules/auth/account-auth.guard.spec.ts
Normal file
153
backend/src/modules/auth/account-auth.guard.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
42
backend/src/modules/auth/account-auth.guard.ts
Normal file
42
backend/src/modules/auth/account-auth.guard.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
9
backend/src/modules/auth/current-account.decorator.ts
Normal file
9
backend/src/modules/auth/current-account.decorator.ts
Normal 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,
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user