diff --git a/.env.example b/.env.example index b55e727..10b41c9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/apps/api/openapi.json b/apps/api/openapi.json index a9a2eb5..13c7a53 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": [ diff --git a/apps/api/src/app/config.schema.ts b/apps/api/src/app/config.schema.ts index afd0695..171fdde 100644 --- a/apps/api/src/app/config.schema.ts +++ b/apps/api/src/app/config.schema.ts @@ -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), diff --git a/docker-compose.yml b/docker-compose.yml index 7f7df2f..79b1d2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/docs/05-autenticacion.md b/docs/05-autenticacion.md index a331587..3972185 100644 --- a/docs/05-autenticacion.md +++ b/docs/05-autenticacion.md @@ -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 diff --git a/docs/07-despliegue.md b/docs/07-despliegue.md index e507cc3..6a1394e 100644 --- a/docs/07-despliegue.md +++ b/docs/07-despliegue.md @@ -62,7 +62,8 @@ docker compose up -d --build ``` En **Dokploy** (despliegue con Docker Compose): pestaña _Environment_ del servicio → `NEXUS_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 | diff --git a/docs/TaskStream-Documentacion.docx b/docs/TaskStream-Documentacion.docx index 8e3b99b..e5ae207 100644 Binary files a/docs/TaskStream-Documentacion.docx and b/docs/TaskStream-Documentacion.docx differ diff --git a/docs/TaskStream-Documentacion.pdf b/docs/TaskStream-Documentacion.pdf index d49d1f3..5ba5bad 100644 Binary files a/docs/TaskStream-Documentacion.pdf and b/docs/TaskStream-Documentacion.pdf differ diff --git a/libs/api/identity/domain/src/lib/token.ts b/libs/api/identity/domain/src/lib/token.ts index 025c1d5..ae1c9e7 100644 --- a/libs/api/identity/domain/src/lib/token.ts +++ b/libs/api/identity/domain/src/lib/token.ts @@ -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; } diff --git a/libs/api/identity/infrastructure/src/lib/identity-infrastructure.module.ts b/libs/api/identity/infrastructure/src/lib/identity-infrastructure.module.ts index 9e386f2..690a8da 100644 --- a/libs/api/identity/infrastructure/src/lib/identity-infrastructure.module.ts +++ b/libs/api/identity/infrastructure/src/lib/identity-infrastructure.module.ts @@ -31,6 +31,10 @@ import { RedisSessionCache } from './redis/redis-session.cache'; requestTimeoutMs: Number(config.get('PIREON_TIMEOUT_MS', 5000)), clientId: config.get('PIREON_CLIENT_ID') || undefined, clientSecret: config.get('PIREON_CLIENT_SECRET') || undefined, + passwordClientId: + config.get('PIREON_PASSWORD_CLIENT_ID') || undefined, + passwordClientSecret: + config.get('PIREON_PASSWORD_CLIENT_SECRET') || undefined, scopes: String(config.get('PIREON_SCOPES', 'users:read')) .split(/\s+/) .filter(Boolean), diff --git a/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.spec.ts b/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.spec.ts new file mode 100644 index 0000000..546cd29 --- /dev/null +++ b/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.spec.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.ts b/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.ts index 9177887..8219acd 100644 --- a/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.ts +++ b/libs/api/identity/infrastructure/src/lib/pireon/pireon-identity.provider.ts @@ -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 { - 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 { + 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, }; } diff --git a/libs/api/identity/infrastructure/src/lib/pireon/pireon.config.ts b/libs/api/identity/infrastructure/src/lib/pireon/pireon.config.ts index 9c267e8..1a41351 100644 --- a/libs/api/identity/infrastructure/src/lib/pireon/pireon.config.ts +++ b/libs/api/identity/infrastructure/src/lib/pireon/pireon.config.ts @@ -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; } diff --git a/libs/api/identity/presenters/src/lib/http/auth.controller.ts b/libs/api/identity/presenters/src/lib/http/auth.controller.ts index c7b6ed9..9703d66 100644 --- a/libs/api/identity/presenters/src/lib/http/auth.controller.ts +++ b/libs/api/identity/presenters/src/lib/http/auth.controller.ts @@ -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' }) diff --git a/libs/web/identity/application/src/lib/auth.store.ts b/libs/web/identity/application/src/lib/auth.store.ts index a49821c..2aae220 100644 --- a/libs/web/identity/application/src/lib/auth.store.ts +++ b/libs/web/identity/application/src/lib/auth.store.ts @@ -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', diff --git a/libs/web/identity/domain/src/lib/session.ts b/libs/web/identity/domain/src/lib/session.ts index b21fb1f..965cf7b 100644 --- a/libs/web/identity/domain/src/lib/session.ts +++ b/libs/web/identity/domain/src/lib/session.ts @@ -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; } diff --git a/libs/web/identity/infrastructure/src/lib/adapters/browser-authorization.flow.ts b/libs/web/identity/infrastructure/src/lib/adapters/browser-authorization.flow.ts index e43df12..03dccb9 100644 --- a/libs/web/identity/infrastructure/src/lib/adapters/browser-authorization.flow.ts +++ b/libs/web/identity/infrastructure/src/lib/adapters/browser-authorization.flow.ts @@ -21,6 +21,8 @@ export class BrowserAuthorizationFlow extends AuthorizationFlow { private readonly document = inject(DOCUMENT); async start(config: AuthConfig, returnUrl: string): Promise { + 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( diff --git a/libs/web/identity/presenters/src/lib/pages/login.page.ts b/libs/web/identity/presenters/src/lib/pages/login.page.ts index ffd10f9..089ac30 100644 --- a/libs/web/identity/presenters/src/lib/pages/login.page.ts +++ b/libs/web/identity/presenters/src/lib/pages/login.page.ts @@ -163,7 +163,7 @@ const SAMPLE_FEED = [ /> } - @if (auth.config(); as config) { + @if (auth.config()?.clientId) { - @if (config.passwordGrant) { + @if (passwordEnabled()) { } }