Pireon OAuth clients allow a single grant type. With one PIREON_CLIENT_ID of type password, email + password worked but "Sign in with Pireon" failed with `unauthorized_client`. - PIREON_CLIENT_ID/SECRET: authorization_code client (Sign in with Pireon, Swagger UI). New PIREON_PASSWORD_CLIENT_ID/SECRET: password client, falling back to the former so existing setups keep working. - Refresh tries both clients: a refresh token only works with its issuer. - PIREON_PASSWORD_GRANT=false now also rejects password grants in the API (before it only hid the form). - /api/auth/config returns clientId: null when there is no authorization_code client, and the web hides "Sign in with Pireon". - docker-compose passes the new variables; docs 05/07, .env.example, openapi.json; unit tests for the client selection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
131 lines
3.7 KiB
TypeScript
131 lines
3.7 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({
|
|
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<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,
|
|
};
|
|
}
|