Files
task-stream/libs/api/identity/presenters/src/lib/http/auth.controller.ts
Josep Piguillem 341241a52f 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>
2026-10-03 15:26:47 +02:00

126 lines
3.6 KiB
TypeScript

import {
Body,
Controller,
Get,
HttpCode,
Post,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiBody,
ApiConsumes,
ApiOkResponse,
ApiOperation,
ApiProperty,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
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';
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) {}
/** 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();
}
@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)));
} 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,
};
}