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 { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
import { RequestContextMiddleware } from '../../infrastructure/request-context/request-context.middleware';
|
||||||
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
import { RequestContextModule } from '../../infrastructure/request-context/request-context.module';
|
||||||
|
import { AccountAuthGuard } from './account-auth.guard';
|
||||||
import { AccessTokenService } from './access-token.service';
|
import { AccessTokenService } from './access-token.service';
|
||||||
import { AuthenticationRuntimeService } from './authentication-runtime.service';
|
import { AuthenticationRuntimeService } from './authentication-runtime.service';
|
||||||
import { DeviceAuthGuard } from './device-auth.guard';
|
import { DeviceAuthGuard } from './device-auth.guard';
|
||||||
@ -14,6 +15,7 @@ import { SessionService } from './session.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, RequestContextModule],
|
imports: [PrismaModule, RequestContextModule],
|
||||||
providers: [
|
providers: [
|
||||||
|
AccountAuthGuard,
|
||||||
AccessTokenService,
|
AccessTokenService,
|
||||||
AuthenticationRuntimeService,
|
AuthenticationRuntimeService,
|
||||||
DeviceAuthService,
|
DeviceAuthService,
|
||||||
@ -24,6 +26,7 @@ import { SessionService } from './session.service';
|
|||||||
SessionService,
|
SessionService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
|
AccountAuthGuard,
|
||||||
AccessTokenService,
|
AccessTokenService,
|
||||||
AuthenticationRuntimeService,
|
AuthenticationRuntimeService,
|
||||||
DeviceAuthService,
|
DeviceAuthService,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { AccountAuthContext } from './account-auth-context';
|
||||||
import { AccessTokenService } from './access-token.service';
|
import { AccessTokenService } from './access-token.service';
|
||||||
import { RefreshTokenService } from './refresh-token.service';
|
import { RefreshTokenService } from './refresh-token.service';
|
||||||
import { SessionService } from './session.service';
|
import { SessionService } from './session.service';
|
||||||
@ -21,12 +22,6 @@ export interface RefreshedAuthenticatedSessionTokens {
|
|||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthenticatedAccountContext {
|
|
||||||
userId: string;
|
|
||||||
sessionId: string;
|
|
||||||
accessTokenVersion: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticationRuntimeService {
|
export class AuthenticationRuntimeService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -103,7 +98,7 @@ export class AuthenticationRuntimeService {
|
|||||||
|
|
||||||
async validateAccessToken(
|
async validateAccessToken(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
): Promise<AuthenticatedAccountContext> {
|
): Promise<AccountAuthContext> {
|
||||||
const claims =
|
const claims =
|
||||||
await this.accessTokenService.verifyAccessToken(accessToken);
|
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