fix(auth): separate Pireon clients for authorization code and password

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>
This commit is contained in:
2026-10-03 15:41:50 +02:00
parent 7532938b4e
commit cbaad17fc7
18 changed files with 321 additions and 44 deletions

View File

@@ -1,4 +1,5 @@
import {
ForbiddenException,
Inject,
Injectable,
Logger,
@@ -28,6 +29,11 @@ interface PireonUser {
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 {
@@ -69,14 +75,78 @@ export class PireonIdentityProvider extends IdentityProvider {
}
async issueToken(grant: TokenGrant): Promise<TokenSet> {
const { clientId, clientSecret, scopes } = this.config;
if (!clientId)
throw new ServiceUnavailableException(
'Sign-in is not configured (PIREON_CLIENT_ID)',
);
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();
}
const body = new URLSearchParams({ client_id: clientId });
if (clientSecret) body.set('client_secret', clientSecret);
/**
* 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');
@@ -138,12 +208,14 @@ export class PireonIdentityProvider extends IdentityProvider {
}
authorizationConfig(): AuthorizationConfig | null {
if (!this.config.clientId) return 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: this.config.clientId,
clientId: clientId ?? null,
scopes: this.config.scopes,
passwordGrant: this.config.passwordGrant,
passwordGrant: password,
};
}