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:
2026-10-03 15:26:47 +02:00
parent b2c9be72c3
commit 341241a52f
36 changed files with 7386 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

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