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:
@@ -18,10 +18,14 @@ EVENTS_BRIDGE_ENABLED=true
|
|||||||
PIREON_BASE_URL=https://oauth.pireon.dev
|
PIREON_BASE_URL=https://oauth.pireon.dev
|
||||||
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
# Secret returned when registering the webhook (POST /api/clients/{client}/webhooks)
|
||||||
PIREON_WEBHOOK_SECRET=
|
PIREON_WEBHOOK_SECRET=
|
||||||
# OAuth client registered for task-stream (POST /api/clients). Needed by the web app to sign in.
|
# OAuth clients of task-stream in Pireon (POST /api/clients). Each Pireon client allows ONE grant type:
|
||||||
# Redirect URI to register: http://localhost:4200/auth/callback
|
# 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_ID=
|
||||||
PIREON_CLIENT_SECRET=
|
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_SCOPES=users:read
|
||||||
PIREON_PASSWORD_GRANT=true
|
PIREON_PASSWORD_GRANT=true
|
||||||
AUTH_SESSION_TTL_SECONDS=60
|
AUTH_SESSION_TTL_SECONDS=60
|
||||||
|
|||||||
@@ -3879,7 +3879,8 @@
|
|||||||
},
|
},
|
||||||
"clientId": {
|
"clientId": {
|
||||||
"type": "string",
|
"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": {
|
"scopes": {
|
||||||
"example": [
|
"example": [
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ export const configSchema = Joi.object({
|
|||||||
// Pireon identity server
|
// Pireon identity server
|
||||||
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
PIREON_BASE_URL: Joi.string().uri().default('https://oauth.pireon.dev'),
|
||||||
PIREON_WEBHOOK_SECRET: Joi.string().allow('').optional(),
|
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_ID: Joi.string().allow('').optional(),
|
||||||
PIREON_CLIENT_SECRET: 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_SCOPES: Joi.string().default('users:read'),
|
||||||
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
PIREON_PASSWORD_GRANT: Joi.boolean().default(true),
|
||||||
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
PIREON_TIMEOUT_MS: Joi.number().default(5000),
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ services:
|
|||||||
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
|
||||||
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID:-}
|
||||||
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET:-}
|
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'
|
GRAPHQL_PLAYGROUND: 'true'
|
||||||
# The SDL / OpenAPI files are only useful in development (codegen); the
|
# The SDL / OpenAPI files are only useful in development (codegen); the
|
||||||
# document is still served at /api/docs-json.
|
# document is still served at /api/docs-json.
|
||||||
|
|||||||
@@ -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
|
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.
|
la API: `POST /api/auth/token` hace de proxy hacia Pireon.
|
||||||
|
|
||||||
| Método | Necesita | Flujo |
|
Cada cliente OAuth de Pireon admite **un único tipo de grant**, así que cada forma de entrar usa su cliente:
|
||||||
| ------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **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`) |
|
|
||||||
|
|
||||||
Las dos opciones necesitan el cliente OAuth: sin `PIREON_CLIENT_ID` la API responde `503` y el login avisa de que no
|
| Método | Cliente de Pireon (`grant_type`) | Variables | Flujo |
|
||||||
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
|
| **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`) |
|
||||||
`Bearer`.)
|
| **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
|
### 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`).
|
Con un token tuyo de Pireon (scope `account:write`), `POST https://oauth.pireon.dev/api/clients`:
|
||||||
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).
|
|
||||||
|
|
||||||
`GET /api/auth/config` devuelve a la web lo necesario para iniciar el flujo (`authorizeUrl`, `clientId`, `scopes`,
|
1. **Cliente para «Entrar con Pireon»**: `{"name": "TaskStream", "grant_type": "authorization_code", "confidential": true,
|
||||||
`passwordGrant`), o `null` si no hay cliente configurado; en ese caso el login muestra el aviso y no deja entrar.
|
"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
|
### Sesión en la web
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
donde los lee Compose.
|
||||||
|
|
||||||
## 7.2 Variables de entorno de la API
|
## 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 |
|
| `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) |
|
| `EVENTS_BRIDGE_ENABLED` | `true` | Si esta instancia proyecta eventos hacia Mongo (ver escalado) |
|
||||||
| `PIREON_BASE_URL` | `https://oauth.pireon.dev` | Servidor de identidad |
|
| `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_SCOPES` | `users:read` | Scopes que se piden al iniciar sesión |
|
||||||
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
| `PIREON_PASSWORD_GRANT` | `true` | Permite el login con email y contraseña |
|
||||||
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
| `PIREON_WEBHOOK_SECRET` | — | Activa y firma los webhooks de Pireon |
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -17,11 +17,15 @@ export interface TokenSet {
|
|||||||
tokenType: string;
|
tokenType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a browser needs to start the Authorization Code + PKCE flow. */
|
/** How a browser can sign in. */
|
||||||
export interface AuthorizationConfig {
|
export interface AuthorizationConfig {
|
||||||
authorizeUrl: string;
|
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[];
|
scopes: string[];
|
||||||
/** Password grant can be disabled on the identity server. */
|
/** Whether email + password sign-in is available. */
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ import { RedisSessionCache } from './redis/redis-session.cache';
|
|||||||
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)),
|
||||||
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
clientId: config.get<string>('PIREON_CLIENT_ID') || undefined,
|
||||||
clientSecret: config.get<string>('PIREON_CLIENT_SECRET') || 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'))
|
scopes: String(config.get('PIREON_SCOPES', 'users:read'))
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ForbiddenException,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
@@ -28,6 +29,11 @@ interface PireonUser {
|
|||||||
app_roles?: string[];
|
app_roles?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OAuthClient {
|
||||||
|
id: string;
|
||||||
|
secret: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PireonIdentityProvider extends IdentityProvider {
|
export class PireonIdentityProvider extends IdentityProvider {
|
||||||
@@ -69,14 +75,78 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||||
const { clientId, clientSecret, scopes } = this.config;
|
const clients = this.clientsFor(grant);
|
||||||
if (!clientId)
|
// 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(
|
throw new ServiceUnavailableException(
|
||||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||||
);
|
);
|
||||||
|
return candidates.filter(
|
||||||
|
(client, i) => candidates.findIndex((c) => c.id === client.id) === i,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = new URLSearchParams({ client_id: clientId });
|
private async requestToken(
|
||||||
if (clientSecret) body.set('client_secret', clientSecret);
|
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) {
|
switch (grant.type) {
|
||||||
case 'authorization_code':
|
case 'authorization_code':
|
||||||
body.set('grant_type', 'authorization_code');
|
body.set('grant_type', 'authorization_code');
|
||||||
@@ -138,12 +208,14 @@ export class PireonIdentityProvider extends IdentityProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
authorizationConfig(): AuthorizationConfig | null {
|
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 {
|
return {
|
||||||
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
||||||
clientId: this.config.clientId,
|
clientId: clientId ?? null,
|
||||||
scopes: this.config.scopes,
|
scopes: this.config.scopes,
|
||||||
passwordGrant: this.config.passwordGrant,
|
passwordGrant: password,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ export interface PireonConfig {
|
|||||||
/** Seconds a resolved token is trusted before asking Pireon again. */
|
/** Seconds a resolved token is trusted before asking Pireon again. */
|
||||||
sessionTtlSeconds: number;
|
sessionTtlSeconds: number;
|
||||||
requestTimeoutMs: 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;
|
clientId?: string;
|
||||||
/** Kept server-side: browsers sign in through /api/auth/token. */
|
/** Kept server-side: browsers sign in through /api/auth/token. */
|
||||||
clientSecret?: string;
|
clientSecret?: string;
|
||||||
|
/** Pireon OAuth client with grant type `password`; defaults to the one above. */
|
||||||
|
passwordClientId?: string;
|
||||||
|
passwordClientSecret?: string;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ import { IssueTokenDto } from './dto/issue-token.dto';
|
|||||||
export class AuthorizationConfigDto {
|
export class AuthorizationConfigDto {
|
||||||
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
@ApiProperty({ example: 'https://oauth.pireon.dev/oauth/authorize' })
|
||||||
authorizeUrl!: string;
|
authorizeUrl!: string;
|
||||||
@ApiProperty({ description: 'Public id of the task-stream OAuth client' })
|
@ApiProperty({
|
||||||
clientId!: string;
|
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'] })
|
@ApiProperty({ type: [String], example: ['users:read'] })
|
||||||
scopes!: string[];
|
scopes!: string[];
|
||||||
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
|
@ApiProperty({ description: 'Whether email + password sign-in is allowed' })
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const AuthStore = signalStore(
|
|||||||
redirectToPireon$: events.on(authPageEvents.signInWithPireon).pipe(
|
redirectToPireon$: events.on(authPageEvents.signInWithPireon).pipe(
|
||||||
exhaustMap(({ payload }) => {
|
exhaustMap(({ payload }) => {
|
||||||
const config = store.config();
|
const config = store.config();
|
||||||
if (!config)
|
if (!config?.clientId)
|
||||||
return [
|
return [
|
||||||
authApiEvents.signInFailed(
|
authApiEvents.signInFailed(
|
||||||
'Pireon sign-in is not configured on the API',
|
'Pireon sign-in is not configured on the API',
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export interface CurrentUser {
|
|||||||
/** How the browser can sign in (null config = the API has no Pireon client yet). */
|
/** How the browser can sign in (null config = the API has no Pireon client yet). */
|
||||||
export interface AuthConfig {
|
export interface AuthConfig {
|
||||||
authorizeUrl: string;
|
authorizeUrl: string;
|
||||||
clientId: string;
|
/** Null when only email + password is configured (no "Sign in with Pireon"). */
|
||||||
|
clientId: string | null;
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
passwordGrant: boolean;
|
passwordGrant: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export class BrowserAuthorizationFlow extends AuthorizationFlow {
|
|||||||
private readonly document = inject(DOCUMENT);
|
private readonly document = inject(DOCUMENT);
|
||||||
|
|
||||||
async start(config: AuthConfig, returnUrl: string): Promise<void> {
|
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 codeVerifier = randomString(64);
|
||||||
const state = randomString(32);
|
const state = randomString(32);
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const SAMPLE_FEED = [
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (auth.config(); as config) {
|
@if (auth.config()?.clientId) {
|
||||||
<pi-action-button
|
<pi-action-button
|
||||||
size="w100"
|
size="w100"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -172,7 +172,7 @@ const SAMPLE_FEED = [
|
|||||||
[disabled]="busy()"
|
[disabled]="busy()"
|
||||||
(execute)="auth.signInWithPireon(returnUrl())"
|
(execute)="auth.signInWithPireon(returnUrl())"
|
||||||
/>
|
/>
|
||||||
@if (config.passwordGrant) {
|
@if (passwordEnabled()) {
|
||||||
<pi-divider color="grey" label="o" gap="m" />
|
<pi-divider color="grey" label="o" gap="m" />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user