first commit
This commit is contained in:
21
libs/api/identity/presenters/README.md
Normal file
21
libs/api/identity/presenters/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# api-identity-presenters
|
||||
|
||||
Adaptadores de entrada de identidad.
|
||||
|
||||
- **Import**: `@task-stream/api/identity/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:api-identity`, `type:presenters`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()` y `WsAuthenticator` (subscriptions).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`) y `PireonWebhookController` (firma HMAC).
|
||||
- `graphql/`: queries `me`, `user` y `users`.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-identity-presenters
|
||||
npx nx test api-identity-presenters # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/identity/presenters/eslint.config.mjs
Normal file
3
libs/api/identity/presenters/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/identity/presenters/jest.config.cts
Normal file
11
libs/api/identity/presenters/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-identity-presenters',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/identity/presenters',
|
||||
};
|
||||
12
libs/api/identity/presenters/project.json
Normal file
12
libs/api/identity/presenters/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-identity-presenters",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/identity/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-identity",
|
||||
"type:presenters"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-identity-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
5
libs/api/identity/presenters/src/index.ts
Normal file
5
libs/api/identity/presenters/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './lib/identity-presenters.module';
|
||||
export * from './lib/auth/auth.decorators';
|
||||
export * from './lib/auth/auth-request';
|
||||
export * from './lib/auth/ws-authenticator';
|
||||
export * from './lib/graphql/user.type';
|
||||
25
libs/api/identity/presenters/src/lib/auth/auth-request.ts
Normal file
25
libs/api/identity/presenters/src/lib/auth/auth-request.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ExecutionContext } from '@nestjs/common';
|
||||
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
|
||||
/** Request shape shared by HTTP, GraphQL and GraphQL-over-WebSocket. */
|
||||
export interface AuthRequest {
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
user?: User;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export function getAuthRequest(context: ExecutionContext): AuthRequest {
|
||||
if (context.getType<GqlContextType>() === 'graphql') {
|
||||
return GqlExecutionContext.create(context).getContext<{
|
||||
req: AuthRequest;
|
||||
}>().req;
|
||||
}
|
||||
return context.switchToHttp().getRequest<AuthRequest>();
|
||||
}
|
||||
|
||||
export function extractBearerToken(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const [scheme, token] = value.trim().split(/\s+/);
|
||||
return scheme?.toLowerCase() === 'bearer' && token ? token : null;
|
||||
}
|
||||
27
libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
Normal file
27
libs/api/identity/presenters/src/lib/auth/auth.decorators.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { getAuthRequest } from './auth-request';
|
||||
|
||||
export const IS_PUBLIC = 'identity:isPublic';
|
||||
export const REQUIRED_APP_ROLES = 'identity:appRoles';
|
||||
|
||||
/** Skips authentication (health checks, webhooks...). */
|
||||
export const Public = () => SetMetadata(IS_PUBLIC, true);
|
||||
|
||||
/** Requires one of these roles in the task-stream OAuth client (Pireon app roles). */
|
||||
export const RequireAppRoles = (...roles: string[]) =>
|
||||
SetMetadata(REQUIRED_APP_ROLES, roles);
|
||||
|
||||
/** The authenticated caller (User). */
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => getAuthRequest(context).user,
|
||||
);
|
||||
|
||||
/** The caller's raw access token, to call Pireon on their behalf. */
|
||||
export const AccessToken = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) =>
|
||||
getAuthRequest(context).accessToken,
|
||||
);
|
||||
56
libs/api/identity/presenters/src/lib/auth/auth.guard.ts
Normal file
56
libs/api/identity/presenters/src/lib/auth/auth.guard.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidAccessTokenError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import { extractBearerToken, getAuthRequest } from './auth-request';
|
||||
import { IS_PUBLIC, REQUIRED_APP_ROLES } from './auth.decorators';
|
||||
|
||||
/** Global guard: every HTTP/GraphQL operation needs a valid Pireon token unless @Public(). */
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly identity: IdentityService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const targets = [context.getHandler(), context.getClass()];
|
||||
if (this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, targets))
|
||||
return true;
|
||||
|
||||
const req = getAuthRequest(context);
|
||||
// WebSocket subscriptions are authenticated once in connection_init.
|
||||
if (!req.user) {
|
||||
const token = extractBearerToken(req.headers['authorization']);
|
||||
if (!token) throw new UnauthorizedException('Missing bearer token');
|
||||
try {
|
||||
req.user = await this.identity.resolveSession(token);
|
||||
req.accessToken = token;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidAccessTokenError)
|
||||
throw new UnauthorizedException(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const roles = this.reflector.getAllAndOverride<string[]>(
|
||||
REQUIRED_APP_ROLES,
|
||||
targets,
|
||||
);
|
||||
const user = req.user;
|
||||
if (roles?.length && user && !roles.some((role) => user.hasAppRole(role))) {
|
||||
throw new ForbiddenException(
|
||||
`Requires one of the app roles: ${roles.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { AuthRequest, extractBearerToken } from './auth-request';
|
||||
|
||||
/**
|
||||
* Authenticates graphql-ws connections from `connection_init` params:
|
||||
* `{ "authorization": "Bearer <token>" }`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WsAuthenticator {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
async authenticate(
|
||||
connectionParams: Record<string, unknown> | undefined,
|
||||
): Promise<AuthRequest> {
|
||||
const header =
|
||||
connectionParams?.['authorization'] ??
|
||||
connectionParams?.['Authorization'];
|
||||
const token = extractBearerToken(header);
|
||||
if (!token) throw new Error('Missing bearer token in connection params');
|
||||
const user = await this.identity.resolveSession(token);
|
||||
return {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
user,
|
||||
accessToken: token,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Args, Int, Query, Resolver } from '@nestjs/graphql';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { AccessToken, CurrentUser } from '../auth/auth.decorators';
|
||||
import { MeType, UserType } from './user.type';
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserResolver {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
@Query(() => MeType, { description: 'The authenticated user' })
|
||||
me(@CurrentUser() user: User): MeType {
|
||||
return user.toSnapshot();
|
||||
}
|
||||
|
||||
@Query(() => UserType, { nullable: true })
|
||||
user(
|
||||
@Args('id', { type: () => String }) id: string,
|
||||
@AccessToken() token: string,
|
||||
): Promise<UserType | null> {
|
||||
return this.identity.findUser(id, token);
|
||||
}
|
||||
|
||||
@Query(() => [UserType], {
|
||||
description: 'Search users by name or email (to assign / add members)',
|
||||
})
|
||||
users(
|
||||
@Args('search', { defaultValue: '' }) search: string,
|
||||
@Args('limit', { type: () => Int, defaultValue: 20 }) limit: number,
|
||||
@AccessToken() token: string,
|
||||
): Promise<UserType[]> {
|
||||
return this.identity.searchUsers(search, token, limit);
|
||||
}
|
||||
}
|
||||
29
libs/api/identity/presenters/src/lib/graphql/user.type.ts
Normal file
29
libs/api/identity/presenters/src/lib/graphql/user.type.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Field, ID, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('User', {
|
||||
description: 'User managed by the Pireon identity server',
|
||||
})
|
||||
export class UserType {
|
||||
@Field(() => ID)
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
email!: string;
|
||||
|
||||
@Field()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@ObjectType('Me')
|
||||
export class MeType extends UserType {
|
||||
@Field(() => [String], { description: 'Global roles in Pireon' })
|
||||
roles!: string[];
|
||||
|
||||
@Field(() => [String], {
|
||||
description: 'Roles inside the task-stream OAuth client',
|
||||
})
|
||||
appRoles!: string[];
|
||||
}
|
||||
81
libs/api/identity/presenters/src/lib/http/auth.controller.ts
Normal file
81
libs/api/identity/presenters/src/lib/http/auth.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidCredentialsError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
TokenSet,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||
|
||||
/**
|
||||
* Sign-in for browsers. Pireon's /oauth/token has no CORS and the client
|
||||
* secret must stay server-side, so the web app goes through here.
|
||||
*/
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||
@Public()
|
||||
@Get('config')
|
||||
config(): Promise<AuthorizationConfig | null> {
|
||||
return this.identity.authorizationConfig();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
async token(@Body() dto: IssueTokenDto) {
|
||||
try {
|
||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidCredentialsError)
|
||||
throw new UnauthorizedException(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toGrant(dto: IssueTokenDto): TokenGrant {
|
||||
switch (dto.grant_type) {
|
||||
case 'authorization_code':
|
||||
return {
|
||||
type: 'authorization_code',
|
||||
code: dto.code as string,
|
||||
codeVerifier: dto.code_verifier as string,
|
||||
redirectUri: dto.redirect_uri as string,
|
||||
};
|
||||
case 'password':
|
||||
return {
|
||||
type: 'password',
|
||||
username: dto.username as string,
|
||||
password: dto.password as string,
|
||||
};
|
||||
case 'refresh_token':
|
||||
return {
|
||||
type: 'refresh_token',
|
||||
refreshToken: dto.refresh_token as string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard OAuth2 token response. */
|
||||
function toResponse(token: TokenSet) {
|
||||
return {
|
||||
access_token: token.accessToken,
|
||||
refresh_token: token.refreshToken,
|
||||
expires_in: token.expiresIn,
|
||||
token_type: token.tokenType,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
export class IssueTokenDto {
|
||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code_verifier?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsUrl({ require_tld: false })
|
||||
redirect_uri?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
username?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
refresh_token?: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Post,
|
||||
RawBodyRequest,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { Request } from 'express';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
|
||||
/** Receives `user.deactivated` / `token.revoked` from Pireon. */
|
||||
@Controller('webhooks/pireon')
|
||||
export class PireonWebhookController {
|
||||
constructor(
|
||||
private readonly identity: IdentityService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post()
|
||||
@HttpCode(204)
|
||||
async receive(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers('x-webhook-signature') signature?: string,
|
||||
): Promise<void> {
|
||||
this.verifySignature(req.rawBody, signature);
|
||||
const body = req.body as {
|
||||
event?: string;
|
||||
type?: string;
|
||||
data?: unknown;
|
||||
payload?: unknown;
|
||||
};
|
||||
const event = body.event ?? body.type;
|
||||
if (!event) throw new BadRequestException('Missing event name');
|
||||
const data = (body.data ?? body.payload ?? body) as Record<string, unknown>;
|
||||
await this.identity.handleWebhook(event, data);
|
||||
}
|
||||
|
||||
/** X-Webhook-Signature = HMAC-SHA256(raw body, PIREON_WEBHOOK_SECRET). */
|
||||
private verifySignature(
|
||||
rawBody: Buffer | undefined,
|
||||
signature: string | undefined,
|
||||
): void {
|
||||
const secret = this.config.get<string>('PIREON_WEBHOOK_SECRET');
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException(
|
||||
'Webhooks are disabled (PIREON_WEBHOOK_SECRET not set)',
|
||||
);
|
||||
}
|
||||
if (!rawBody || !signature)
|
||||
throw new UnauthorizedException('Missing signature');
|
||||
|
||||
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
|
||||
const received = signature.replace(/^sha256=/, '');
|
||||
const valid =
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(Buffer.from(received), Buffer.from(expected));
|
||||
if (!valid) throw new UnauthorizedException('Invalid signature');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DynamicModule, Module, Type } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AuthGuard } from './auth/auth.guard';
|
||||
import { WsAuthenticator } from './auth/ws-authenticator';
|
||||
import { UserResolver } from './graphql/user.resolver';
|
||||
import { AuthController } from './http/auth.controller';
|
||||
import { PireonWebhookController } from './http/pireon-webhook.controller';
|
||||
|
||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
||||
@Module({
|
||||
controllers: [AuthController, PireonWebhookController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
WsAuthenticator,
|
||||
UserResolver,
|
||||
],
|
||||
exports: [WsAuthenticator],
|
||||
})
|
||||
export class IdentityPresentersModule {
|
||||
static withApplication(
|
||||
applicationModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return {
|
||||
module: IdentityPresentersModule,
|
||||
imports: [applicationModule],
|
||||
exports: [WsAuthenticator],
|
||||
};
|
||||
}
|
||||
}
|
||||
23
libs/api/identity/presenters/tsconfig.json
Normal file
23
libs/api/identity/presenters/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/identity/presenters/tsconfig.lib.json
Normal file
23
libs/api/identity/presenters/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/identity/presenters/tsconfig.spec.json
Normal file
16
libs/api/identity/presenters/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user