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>
126 lines
3.6 KiB
TypeScript
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,
|
|
};
|
|
}
|