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

@@ -18,10 +18,14 @@ EVENTS_BRIDGE_ENABLED=true
PIREON_BASE_URL=https://oauth.pireon.dev
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
PIREON_WEBHOOK_SECRET=
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in.
# Redirect URI to register: http://localhost:4200/auth/callback
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
# authorization_code → "Sign in with Pireon" and Swagger UI. Redirect URIs to register:
# http://localhost:4200/auth/callback, http://localhost:3000/api/docs/oauth2-redirect.html
PIREON_CLIENT_ID=
PIREON_CLIENT_SECRET=
# password → email + password sign-in (defaults to the client above when empty)
PIREON_PASSWORD_CLIENT_ID=
PIREON_PASSWORD_CLIENT_SECRET=
PIREON_SCOPES=users:read
PIREON_PASSWORD_GRANT=true
AUTH_SESSION_TTL_SECONDS=60

View File

@@ -3879,7 +3879,8 @@
},
"clientId": {
"type": "string",
"description": "Public id of the task-stream OAuth client"
"nullable": true,
"description": "Public id of the authorization_code client (\"Sign in with Pireon\"); null when only email + password is configured"
},
"scopes": {
"example": [

View File

@@ -23,9 +23,13 @@ export const configSchema = Joi.object({
// Pireon identity server
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
// OAuth client of task-stream in Pireon, used by the web app to sign in.
// OAuth clients of task-stream in Pireon (each Pireon client allows one grant type):
// authorization_code for "Sign in with Pireon" / Swagger UI…
PIREON_CLIENT_ID: Joi.string().allow('').optional(),
PIREON_CLIENT_SECRET: Joi.string().allow('').optional(),
// …and password for email + password (defaults to the client above).
PIREON_PASSWORD_CLIENT_ID: Joi.string().allow('').optional(),
PIREON_PASSWORD_CLIENT_SECRET: Joi.string().allow('').optional(),
PIREON_SCOPES: Joi.string().default('users:read'),
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
PIREON_TIMEOUT_MS: Joi.number().default(5000),

View File

@@ -47,6 +47,10 @@ services:
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
# Pireon clients allow one grant type: the one above is authorization_code,
# this one password (email + password sign-in). Optional.
PIREON_PASSWORD_CLIENT_ID: ${PIREON_PASSWORD_CLIENT_ID:-}
PIREON_PASSWORD_CLIENT_SECRET: ${PIREON_PASSWORD_CLIENT_SECRET:-}
GRAPHQL_PLAYGROUND: 'true'
# The SDL / OpenAPI files are only useful in development (codegen); the
# document is still served at /api/docs-json.

View File

@@ -38,29 +38,42 @@ sequenceDiagram
Pireon **no permite CORS** en `/oauth/token` y el `client_secret` no debe llegar al navegador, así que la web pasa por
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
| Método | Necesita | Flujo |
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Entrar con Pireon** (Authorization Code + PKCE) | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y la redirect URI registrada | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
| **Email y contraseña** | El mismo cliente, con `PIREON_PASSWORD_GRANT=true` | `POST /api/auth/token` (`password`) |
Cada cliente OAuth de Pireon admite **un único tipo de grant**, así que cada forma de entrar usa su cliente:
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
está configurado. El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía.
(El login con token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como
`Bearer`.)
| Método | Cliente de Pireon (`grant_type`) | Variables | Flujo |
| ------------------------------------------------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **Entrar con Pireon** (Authorization Code + PKCE) | `authorization_code` | `PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` | Web → `/oauth/authorize` de Pireon → `/auth/callback` → `POST /api/auth/token` (`authorization_code` + `code_verifier`) |
| **Email y contraseña** | `password` | `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` | `POST /api/auth/token` (`password`); se desactiva con `PIREON_PASSWORD_GRANT=false` |
Si no se definen las variables `PIREON_PASSWORD_CLIENT_*`, el login con contraseña usa `PIREON_CLIENT_ID` (sirve si
solo hay un cliente de tipo `password`, pero entonces «Entrar con Pireon» falla con `unauthorized_client`). Al renovar
la sesión, la API prueba los dos clientes: un `refresh_token` solo vale con el cliente que lo emitió.
El email y la contraseña son los de la cuenta de Pireon; TaskStream no los guarda, solo los reenvía. (El login con
token de acceso personal se ha retirado de la web por ahora; la API sigue aceptando esos tokens como `Bearer`.)
### Registrar TaskStream como cliente en Pireon
1. Crea el cliente: `POST https://oauth.pireon.dev/api/clients` (con un token tuyo de Pireon, scope `account:write`).
2. Registra como redirect URIs:
- `http://localhost:4200/auth/callback` (desarrollo)
- `http://localhost:8080/auth/callback` (Docker)
- la de producción
- para el botón «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y la de
producción (ver [API REST](03-api.md#33-rest-con-openapi-para-otras-apps))
3. Pon `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET` en `.env` (o en el entorno del contenedor).
Con un token tuyo de Pireon (scope `account:write`), `POST https://oauth.pireon.dev/api/clients`:
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
1. **Cliente para «Entrar con Pireon»**: `{"name": "TaskStream", "grant_type": "authorization_code", "confidential": true,
"redirect_uris": [...]}` con estas redirect URIs:
- `http://localhost:4200/auth/callback` (desarrollo) y `http://localhost:8080/auth/callback` (Docker)
- `https://taskstream.pireon.dev/auth/callback` (producción)
- para el «Authorize» de Swagger UI: `http://localhost:3000/api/docs/oauth2-redirect.html` y
`https://api.taskstream.pireon.dev/api/docs/oauth2-redirect.html` (ver
[API REST](03-api.md#33-rest-con-openapi-para-otras-apps))
→ `PIREON_CLIENT_ID` y `PIREON_CLIENT_SECRET`.
2. **Cliente para email y contraseña**: `{"name": "TaskStream (password)", "grant_type": "password"}`
→ `PIREON_PASSWORD_CLIENT_ID` y `PIREON_PASSWORD_CLIENT_SECRET`.
Ponlos en `.env` (o en el entorno del contenedor; en Dokploy, en _Environment_) y reinicia la API.
`GET /api/auth/config` le dice a la web qué formas de entrar hay: `clientId` del cliente `authorization_code` (o `null`:
la web oculta «Entrar con Pireon») y `passwordGrant`. Devuelve `null` si no hay ningún cliente; en ese caso el login
muestra el aviso y no deja entrar.
### Sesión en la web

View File

@@ -62,7 +62,8 @@ docker compose up -d --build
```
En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_TOKEN=<token>`, junto a
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET` y `PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
`PIREON_CLIENT_ID`, `PIREON_CLIENT_SECRET`, `PIREON_PASSWORD_CLIENT_ID`, `PIREON_PASSWORD_CLIENT_SECRET` y
`PIREON_WEBHOOK_SECRET`. Dokploy los escribe en el `.env` del proyecto, de
donde los lee Compose.
## 7.2 Variables de entorno de la API
@@ -79,7 +80,8 @@ donde los lee Compose.
| `EVENT_STREAM_MAX_LENGTH` | `1000000` | Longitud aproximada que se conserva en el stream |
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth para el login desde la web |
| `PIREON_CLIENT_ID` / `PIREON_CLIENT_SECRET` | — | Cliente OAuth `authorization_code`: «Entrar con Pireon» y Swagger UI |
| `PIREON_PASSWORD_CLIENT_ID` / `_SECRET` | el anterior | Cliente OAuth `password`: login con email y contraseña |
| `PIREON_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |

Binary file not shown.

Binary file not shown.

View File

@@ -17,11 +17,15 @@ export interface TokenSet {
tokenType: string;
}
/** What a browser needs to start the Authorization Code + PKCE flow. */
/** How a browser can sign in. */
export interface AuthorizationConfig {
authorizeUrl: string;
clientId: string;
/**
* Client for Authorization Code + PKCE ("Sign in with Pireon"). Null when
* only email + password is configured: Pireon clients allow a single grant.
*/
clientId: string | null;
scopes: string[];
/** Password grant can be disabled on the identity server. */
/** Whether email + password sign-in is available. */
passwordGrant: boolean;
}

View File

@@ -31,6 +31,10 @@ import { RedisSessionCache } from './redis/redis-session.cache';
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || undefined,
passwordClientId:
config.get<string>('PIREON_PASSWORD_CLIENT_ID') || undefined,
passwordClientSecret:
config.get<string>('PIREON_PASSWORD_CLIENT_SECRET') || undefined,
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
.split(/\s+/)
.filter(Boolean),

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();
});
});

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,
};
}

View File

@@ -4,10 +4,17 @@ export interface PireonConfig {
/** Seconds a resolved token is trusted before asking Pireon again. */
sessionTtlSeconds: number;
requestTimeoutMs: number;
/** OAuth client of task-stream in Pireon (Authorization Code + PKCE / password grant). */
/**
* Pireon OAuth client with grant type `authorization_code` ("Sign in with
* Pireon", Swagger UI). Pireon clients allow a single grant type, so email +
* password uses its own client below.
*/
clientId?: string;
/** Kept server-side: browsers sign in through /api/auth/token. */
clientSecret?: string;
/** Pireon OAuth client with grant type `password`; defaults to the one above. */
passwordClientId?: string;
passwordClientSecret?: string;
scopes: string[];
passwordGrant: boolean;
}

View File

@@ -30,8 +30,13 @@ import { IssueTokenDto } from './dto/issue-token.dto';
export class AuthorizationConfigDto {
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
authorizeUrl!: string;
@ApiProperty({ description: 'Public id of the task-stream OAuth client' })
clientId!: 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' })

View File

@@ -148,7 +148,7 @@ export const AuthStore = signalStore(
redirectToPireon$: events.on(authPageEvents.signInWithPireon).pipe(
exhaustMap(({ payload }) => {
const config = store.config();
if (!config)
if (!config?.clientId)
return [
authApiEvents.signInFailed(
'Pireon sign-in is not configured on the API',

View File

@@ -16,7 +16,8 @@ export interface CurrentUser {
/** How the browser can sign in (null config = the API has no Pireon client yet). */
export interface AuthConfig {
authorizeUrl: string;
clientId: string;
/** Null when only email + password is configured (no "Sign in with Pireon"). */
clientId: string | null;
scopes: string[];
passwordGrant: boolean;
}

View File

@@ -21,6 +21,8 @@ export class BrowserAuthorizationFlow extends AuthorizationFlow {
private readonly document = inject(DOCUMENT);
async start(config: AuthConfig, returnUrl: string): Promise<void> {
if (!config.clientId)
throw new Error('Pireon sign-in is not configured on the API');
const codeVerifier = randomString(64);
const state = randomString(32);
sessionStorage.setItem(

View File

@@ -163,7 +163,7 @@ const SAMPLE_FEED = [
/>
}
@if (auth.config(); as config) {
@if (auth.config()?.clientId) {
<pi-action-button
size="w100"
color="primary"
@@ -172,7 +172,7 @@ const SAMPLE_FEED = [
[disabled]="busy()"
(execute)="auth.signInWithPireon(returnUrl())"
/>
@if (config.passwordGrant) {
@if (passwordEnabled()) {
<pi-divider color="grey" label="o" gap="m" />
}
}