import { ForbiddenException, Inject, Injectable, Logger, ServiceUnavailableException, } from '@nestjs/common'; import { IdentityProvider, InvalidAccessTokenError, InvalidCredentialsError, } from '@task-stream/api/identity/application'; import { AuthorizationConfig, TokenGrant, TokenSet, User, UserFactory, } from '@task-stream/api/identity/domain'; import { PIREON_CONFIG, PireonConfig } from './pireon.config'; /** User resource as documented in https://oauth.pireon.dev/docs?api-docs.json */ interface PireonUser { id: number; name: string; email: string; is_active: boolean; roles?: string[]; app_roles?: string[]; } interface OAuthClient { id: string; secret: string | undefined; } /** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */ @Injectable() export class PireonIdentityProvider extends IdentityProvider { private readonly logger = new Logger(PireonIdentityProvider.name); constructor( @Inject(PIREON_CONFIG) private readonly config: PireonConfig, private readonly users: UserFactory, ) { super(); } async whoAmI(accessToken: string): Promise { const response = await this.request('/api/user', accessToken); if (response.status === 401 || response.status === 403) { throw new InvalidAccessTokenError(); } this.assertOk(response, 'GET /api/user'); return this.toUser((await response.json()) as PireonUser); } async findUser(id: string, accessToken: string): Promise { if (!/^\d+$/.test(id)) return null; const response = await this.request(`/api/users/${id}`, accessToken); if ([401, 403, 404].includes(response.status)) return null; this.assertOk(response, `GET /api/users/${id}`); return this.toUser((await response.json()) as PireonUser); } async listUsers(accessToken: string): Promise { const response = await this.request('/api/users', accessToken); if (response.status === 401 || response.status === 403) return []; this.assertOk(response, 'GET /api/users'); const body = (await response.json()) as | PireonUser[] | { data: PireonUser[] }; const users = Array.isArray(body) ? body : body.data; return users.map((u) => this.toUser(u)); } async issueToken(grant: TokenGrant): Promise { const clients = this.clientsFor(grant); // A refresh token only works with the client that issued it, and we don't // know which one that was: try each until one accepts it. let rejected: InvalidCredentialsError | undefined; for (const client of clients) { try { return await this.requestToken(client, grant); } catch (error) { if ( grant.type !== 'refresh_token' || !(error instanceof InvalidCredentialsError) ) throw error; rejected = error; } } throw rejected ?? new InvalidCredentialsError(); } /** * Pireon clients allow a single grant type: authorization_code uses * PIREON_CLIENT_ID, password PIREON_PASSWORD_CLIENT_ID (or the former). */ private clientsFor(grant: TokenGrant): OAuthClient[] { const { clientId, clientSecret, passwordClientId, passwordClientSecret } = this.config; const code: OAuthClient | undefined = clientId ? { id: clientId, secret: clientSecret } : undefined; const password: OAuthClient | undefined = passwordClientId ? { id: passwordClientId, secret: passwordClientSecret } : code; switch (grant.type) { case 'authorization_code': if (!code) throw new ServiceUnavailableException( 'Sign-in with Pireon is not configured (PIREON_CLIENT_ID)', ); return [code]; case 'password': if (!this.config.passwordGrant) throw new ForbiddenException( 'Email and password sign-in is disabled', ); if (!password) throw new ServiceUnavailableException( 'Email and password sign-in is not configured (PIREON_PASSWORD_CLIENT_ID)', ); return [password]; case 'refresh_token': { const candidates = [password, code].filter( (client): client is OAuthClient => !!client, ); if (!candidates.length) throw new ServiceUnavailableException( 'Sign-in is not configured (PIREON_CLIENT_ID)', ); return candidates.filter( (client, i) => candidates.findIndex((c) => c.id === client.id) === i, ); } } } private async requestToken( client: OAuthClient, grant: TokenGrant, ): Promise { const { scopes } = this.config; const body = new URLSearchParams({ client_id: client.id }); if (client.secret) body.set('client_secret', client.secret); switch (grant.type) { case 'authorization_code': body.set('grant_type', 'authorization_code'); body.set('code', grant.code); body.set('code_verifier', grant.codeVerifier); body.set('redirect_uri', grant.redirectUri); break; case 'password': body.set('grant_type', 'password'); body.set('username', grant.username); body.set('password', grant.password); body.set('scope', scopes.join(' ')); break; case 'refresh_token': body.set('grant_type', 'refresh_token'); body.set('refresh_token', grant.refreshToken); break; } let response: Response; try { response = await fetch(new URL('/oauth/token', this.config.baseUrl), { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body, signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); } catch (error) { this.logger.error( `Pireon unreachable (/oauth/token): ${(error as Error).message}`, ); throw new ServiceUnavailableException('Identity server unavailable'); } if (response.status === 400 || response.status === 401) { // OAuth2 error (RFC 6749 §5.2). Passport puts the useful part in `hint` // (which parameter was wrong), so keep it in the message and the log. const error = (await response.json().catch(() => ({}))) as { error?: string; error_description?: string; hint?: string; message?: string; }; const description = error.error_description ?? error.message ?? 'Invalid credentials'; this.logger.warn( `Pireon rejected ${grant.type} for client ${client.id}: ` + `${error.error ?? response.status} — ${description}` + (error.hint ? ` (hint: ${error.hint})` : ''), ); throw new InvalidCredentialsError( error.hint ? `${description} (${error.hint})` : description, ); } this.assertOk(response, 'POST /oauth/token'); const token = (await response.json()) as { access_token: string; refresh_token?: string; expires_in: number; token_type: string; }; return { accessToken: token.access_token, refreshToken: token.refresh_token ?? null, expiresIn: token.expires_in, tokenType: token.token_type, }; } authorizationConfig(): AuthorizationConfig | null { const { clientId, passwordClientId, passwordGrant } = this.config; const password = passwordGrant && !!(passwordClientId ?? clientId); if (!clientId && !password) return null; return { authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(), clientId: clientId ?? null, scopes: this.config.scopes, passwordGrant: password, }; } private toUser(user: PireonUser): User { return this.users.create({ id: String(user.id), name: user.name, email: user.email, isActive: user.is_active, roles: user.roles, appRoles: user.app_roles, }); } private async request(path: string, accessToken: string): Promise { try { return await fetch(new URL(path, this.config.baseUrl), { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, signal: AbortSignal.timeout(this.config.requestTimeoutMs), }); } catch (error) { this.logger.error( `Pireon unreachable (${path}): ${(error as Error).message}`, ); throw new ServiceUnavailableException('Identity server unavailable'); } } private assertOk(response: Response, operation: string): void { if (response.ok) return; this.logger.error(`${operation} answered ${response.status}`); throw new ServiceUnavailableException('Identity server error'); } }