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({ type: String, nullable: true, description: 'Public id of the authorization_code client ("Sign in with Pireon"); null when only email + password is configured', }) clientId!: string | null; @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 { 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, }; }