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

@@ -0,0 +1,154 @@
import {
ForbiddenException,
ServiceUnavailableException,
} from '@nestjs/common';
import { InvalidCredentialsError } from '@task-stream/api/identity/application';
import { UserFactory } from '@task-stream/api/identity/domain';
import { PireonConfig } from './pireon.config';
import { PireonIdentityProvider } from './pireon-identity.provider';
const TOKEN = {
access_token: 'access',
refresh_token: 'refresh',
expires_in: 3600,
token_type: 'Bearer',
};
/** Pireon clients allow a single grant type: one client for code, one for password. */
function setup(overrides: Partial<PireonConfig> = {}) {
const config: PireonConfig = {
baseUrl: 'https://oauth.pireon.dev',
sessionTtlSeconds: 60,
requestTimeoutMs: 1000,
clientId: 'code-client',
clientSecret: 'code-secret',
passwordClientId: 'password-client',
passwordClientSecret: 'password-secret',
scopes: ['users:read'],
passwordGrant: true,
...overrides,
};
const calls: URLSearchParams[] = [];
/** Answers 401 to the clients in `rejects`, a token otherwise. */
const respond = (rejects: string[] = []) =>
jest.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const body = init?.body as URLSearchParams;
calls.push(body);
return rejects.includes(body.get('client_id') ?? '')
? new Response(
JSON.stringify({
error: 'invalid_request',
error_description: 'The refresh token is invalid.',
}),
{ status: 401 },
)
: new Response(JSON.stringify(TOKEN), { status: 200 });
});
return {
provider: new PireonIdentityProvider(config, new UserFactory()),
calls,
respond,
};
}
const password = {
type: 'password',
username: 'ana@pireon.dev',
password: 'x',
} as const;
const code = {
type: 'authorization_code',
code: 'c',
codeVerifier: 'v',
redirectUri: 'https://taskstream.pireon.dev/auth/callback',
} as const;
const refresh = { type: 'refresh_token', refreshToken: 'r' } as const;
describe('PireonIdentityProvider tokens', () => {
afterEach(() => jest.restoreAllMocks());
it('uses the password client for email + password', async () => {
const { provider, calls, respond } = setup();
respond();
await provider.issueToken(password);
expect(calls[0].get('client_id')).toBe('password-client');
expect(calls[0].get('client_secret')).toBe('password-secret');
expect(calls[0].get('grant_type')).toBe('password');
});
it('uses the authorization_code client for "Sign in with Pireon"', async () => {
const { provider, calls, respond } = setup();
respond();
await provider.issueToken(code);
expect(calls[0].get('client_id')).toBe('code-client');
expect(calls[0].get('code_verifier')).toBe('v');
});
it('falls back to the main client when there is no password client', async () => {
const { provider, calls, respond } = setup({
passwordClientId: undefined,
passwordClientSecret: undefined,
});
respond();
await provider.issueToken(password);
expect(calls[0].get('client_id')).toBe('code-client');
});
it('refreshes with whichever client issued the token', async () => {
const { provider, calls, respond } = setup();
respond(['password-client']);
await expect(provider.issueToken(refresh)).resolves.toMatchObject({
accessToken: 'access',
});
expect(calls.map((c) => c.get('client_id'))).toEqual([
'password-client',
'code-client',
]);
});
it('rejects the refresh when no client accepts it', async () => {
const { provider, respond } = setup();
respond(['password-client', 'code-client']);
await expect(provider.issueToken(refresh)).rejects.toBeInstanceOf(
InvalidCredentialsError,
);
});
it('refuses email + password when PIREON_PASSWORD_GRANT=false', async () => {
const { provider, calls, respond } = setup({ passwordGrant: false });
respond();
await expect(provider.issueToken(password)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(calls).toHaveLength(0);
});
it('says what is missing when a flow is not configured', async () => {
const { provider } = setup({
clientId: undefined,
clientSecret: undefined,
});
await expect(provider.issueToken(code)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('tells the browser which flows exist', () => {
expect(setup().provider.authorizationConfig()).toMatchObject({
clientId: 'code-client',
passwordGrant: true,
});
expect(
setup({
clientId: undefined,
clientSecret: undefined,
}).provider.authorizationConfig(),
).toMatchObject({ clientId: null, passwordGrant: true });
expect(
setup({
clientId: undefined,
passwordClientId: undefined,
}).provider.authorizationConfig(),
).toBeNull();
});
});