Passport answers token failures with a generic `invalid_request` description and puts the actual cause in `hint` (invalid redirect URI, expired or revoked code, code issued to another client…). The proxy dropped it, so sign-in showed only "The request is missing a required parameter…". It is now appended to the error message and logged with the grant type and client id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
8.6 KiB
TypeScript
267 lines
8.6 KiB
TypeScript
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<User> {
|
|
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<User | null> {
|
|
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<User[]> {
|
|
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<TokenSet> {
|
|
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<TokenSet> {
|
|
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<Response> {
|
|
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');
|
|
}
|
|
}
|