feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth
A REST adapter next to GraphQL (presenters/http) that calls the same application services, so validation, permissions and domain errors are the same: projects (members, board, stats, activity), issues (search, workflow, assignee, release, links, comments, history), comments, releases and users. Projects and issues can be addressed by id or key (WEB, WEB-12). - Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json (rewritten by `nx serve api`, like schema.gql) for client generation. - Every route but health/auth/webhook needs a Pireon token: the document declares Pireon OAuth2 (authorization code + PKCE, password) through the /api/auth/token proxy, and a bearer scheme for apps with their own client. - configureApp() shares main.ts setup with the e2e, which now also covers REST. - SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,10 @@ Adaptadores de entrada de identidad.
|
||||
|
||||
## 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).
|
||||
- `auth/`: `AuthGuard` global, `@Public()`, `@CurrentUser()`, `@AccessToken()`, `@RequireAppRoles()`, `WsAuthenticator`
|
||||
(subscriptions) y `@ApiPireonAuth()` (esquemas OAuth2 / Bearer de Pireon en OpenAPI).
|
||||
- `http/`: `AuthController` (`GET /api/auth/config`, `POST /api/auth/token`), `UsersController` (`/api/users/me`,
|
||||
directorio de Pireon) y `PireonWebhookController` (firma HMAC). Documentados con `@nestjs/swagger`.
|
||||
- `graphql/`: queries `me`, `user` y `users`.
|
||||
|
||||
## Comandos
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
SetMetadata,
|
||||
applyDecorators,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOAuth2 } from '@nestjs/swagger';
|
||||
import { getAuthRequest } from './auth-request';
|
||||
|
||||
export const IS_PUBLIC = 'identity:isPublic';
|
||||
@@ -25,3 +27,15 @@ export const AccessToken = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) =>
|
||||
getAuthRequest(context).accessToken,
|
||||
);
|
||||
|
||||
/** OpenAPI security schemes (declared in apps/api/src/app/openapi.ts). */
|
||||
export const PIREON_OAUTH = 'pireon-oauth';
|
||||
export const PIREON_BEARER = 'pireon';
|
||||
|
||||
/**
|
||||
* Documents that the endpoints need a Pireon access token: obtained through
|
||||
* Pireon's OAuth (authorization code + PKCE, or password) or sent as a bearer
|
||||
* token by an app that already has one. Enforced by the global AuthGuard.
|
||||
*/
|
||||
export const ApiPireonAuth = () =>
|
||||
applyDecorators(ApiOAuth2([], PIREON_OAUTH), ApiBearerAuth(PIREON_BEARER));
|
||||
|
||||
@@ -6,6 +6,15 @@ import {
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProperty,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import {
|
||||
IdentityService,
|
||||
InvalidCredentialsError,
|
||||
@@ -18,10 +27,29 @@ import {
|
||||
import { Public } from '../auth/auth.decorators';
|
||||
import { IssueTokenDto } from './dto/issue-token.dto';
|
||||
|
||||
export class AuthorizationConfigDto {
|
||||
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
||||
authorizeUrl!: string;
|
||||
@ApiProperty({ description: 'Public id of the task-stream OAuth client' })
|
||||
clientId!: string;
|
||||
@ApiProperty({ type: [String], example: ['users:read'] })
|
||||
scopes!: string[];
|
||||
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
|
||||
passwordGrant!: boolean;
|
||||
}
|
||||
|
||||
export class TokenResponseDto {
|
||||
@ApiProperty() access_token!: string;
|
||||
@ApiProperty({ type: String, nullable: true }) refresh_token!: string | null;
|
||||
@ApiProperty({ description: 'Seconds' }) expires_in!: number;
|
||||
@ApiProperty({ example: 'Bearer' }) token_type!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@ApiTags('Auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
@@ -29,6 +57,10 @@ export class AuthController {
|
||||
/** What the web app needs to start Authorization Code + PKCE (null = not configured). */
|
||||
@Public()
|
||||
@Get('config')
|
||||
@ApiOperation({
|
||||
summary: 'How to sign in with Pireon (null when not configured)',
|
||||
})
|
||||
@ApiOkResponse({ type: AuthorizationConfigDto })
|
||||
config(): Promise<AuthorizationConfig | null> {
|
||||
return this.identity.authorizationConfig();
|
||||
}
|
||||
@@ -36,6 +68,18 @@ export class AuthController {
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Exchange a grant for Pireon tokens (proxy of Pireon /oauth/token)',
|
||||
description:
|
||||
'For browsers (Pireon has no CORS) and Swagger UI: adds the client secret ' +
|
||||
'server-side. Apps with their own Pireon OAuth client can get tokens from ' +
|
||||
'Pireon directly; any valid Pireon access token is accepted by this API.',
|
||||
})
|
||||
@ApiConsumes('application/json', 'application/x-www-form-urlencoded')
|
||||
@ApiBody({ type: IssueTokenDto })
|
||||
@ApiOkResponse({ type: TokenResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Pireon rejected the grant' })
|
||||
async token(@Body() dto: IssueTokenDto) {
|
||||
try {
|
||||
return toResponse(await this.identity.issueToken(toGrant(dto)));
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsNotEmpty, IsString, IsUrl, ValidateIf } from 'class-validator';
|
||||
|
||||
/** OAuth2 token request (RFC 6749), as JSON or application/x-www-form-urlencoded. */
|
||||
export class IssueTokenDto {
|
||||
@ApiProperty({ enum: ['authorization_code', 'password', 'refresh_token'] })
|
||||
@IsIn(['authorization_code', 'password', 'refresh_token'])
|
||||
grant_type!: 'authorization_code' | 'password' | 'refresh_token';
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({
|
||||
description: 'authorization_code: code returned by Pireon',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'authorization_code: PKCE verifier' })
|
||||
code_verifier?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'authorization_code')
|
||||
@IsUrl({ require_tld: false })
|
||||
@ApiPropertyOptional({
|
||||
description: 'authorization_code: redirect URI used to authorize',
|
||||
})
|
||||
redirect_uri?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'password: Pireon email' })
|
||||
username?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'password')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'password: Pireon password' })
|
||||
password?: string;
|
||||
|
||||
@ValidateIf((dto: IssueTokenDto) => dto.grant_type === 'refresh_token')
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiPropertyOptional({ description: 'refresh_token' })
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ApiHeader,
|
||||
ApiNoContentResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
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. */
|
||||
@ApiTags('Webhooks')
|
||||
@Controller('webhooks/pireon')
|
||||
export class PireonWebhookController {
|
||||
constructor(
|
||||
@@ -25,6 +33,19 @@ export class PireonWebhookController {
|
||||
@Public()
|
||||
@Post()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({
|
||||
summary: 'Pireon events (called by Pireon, not by apps)',
|
||||
description:
|
||||
"`user.deactivated` closes the user's sessions; `token.revoked` forgets the token. " +
|
||||
'Disabled unless PIREON_WEBHOOK_SECRET is set.',
|
||||
})
|
||||
@ApiHeader({
|
||||
name: 'X-Webhook-Signature',
|
||||
description: 'HMAC-SHA256 of the raw body with PIREON_WEBHOOK_SECRET (hex)',
|
||||
required: true,
|
||||
})
|
||||
@ApiNoContentResponse({ description: 'Processed' })
|
||||
@ApiUnauthorizedResponse({ description: 'Missing or invalid signature' })
|
||||
async receive(
|
||||
@Req() req: RawBodyRequest<Request>,
|
||||
@Headers('x-webhook-signature') signature?: string,
|
||||
|
||||
101
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
101
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { ApiReadErrors } from '@task-stream/api/shared/presenters';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
AccessToken,
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '../auth/auth.decorators';
|
||||
|
||||
export class UserDto {
|
||||
@ApiProperty({ description: 'Pireon user id' }) id!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty() email!: string;
|
||||
@ApiProperty() isActive!: boolean;
|
||||
}
|
||||
|
||||
export class MeDto extends UserDto {
|
||||
@ApiProperty({ type: [String], description: 'Global roles in Pireon' })
|
||||
roles!: string[];
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: 'Roles inside the task-stream OAuth client',
|
||||
})
|
||||
appRoles!: string[];
|
||||
}
|
||||
|
||||
export class UserSearchQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Name or email', default: '' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
search = '';
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit = 20;
|
||||
}
|
||||
|
||||
/** REST adapter of identity: who I am and Pireon's user directory. */
|
||||
@ApiTags('Users')
|
||||
@ApiPireonAuth()
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'The user behind the access token' })
|
||||
@ApiOkResponse({ type: MeDto })
|
||||
@ApiReadErrors()
|
||||
me(@CurrentUser() user: User): MeDto {
|
||||
return user.toSnapshot();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Search Pireon users (to assign tickets or add members)',
|
||||
description: 'Needs the `users:read` scope in the access token.',
|
||||
})
|
||||
@ApiOkResponse({ type: [UserDto] })
|
||||
@ApiReadErrors()
|
||||
search(@Query() query: UserSearchQueryDto, @AccessToken() token: string) {
|
||||
return this.identity.searchUsers(query.search, token, query.limit);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'A Pireon user' })
|
||||
@ApiOkResponse({ type: UserDto })
|
||||
@ApiReadErrors()
|
||||
async findOne(@Param('id') id: string, @AccessToken() token: string) {
|
||||
const user = await this.identity.findUser(id, token);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,11 @@ 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';
|
||||
import { UsersController } from './http/users.controller';
|
||||
|
||||
/** Driving adapters of the identity context: global auth guard, webhook, GraphQL. */
|
||||
/** Driving adapters of the identity context: global auth guard, REST (auth, users, webhook), GraphQL. */
|
||||
@Module({
|
||||
controllers: [AuthController, PireonWebhookController],
|
||||
controllers: [AuthController, UsersController, PireonWebhookController],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||
WsAuthenticator,
|
||||
|
||||
Reference in New Issue
Block a user