Files
task-stream/libs/web/identity/application/src/lib/auth.store.ts
Josep Piguillem cbaad17fc7 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>
2026-10-03 15:41:50 +02:00

290 lines
8.5 KiB
TypeScript

import { HttpErrorResponse } from '@angular/common/http';
import { computed, inject } from '@angular/core';
import { Router } from '@angular/router';
import { mapResponse } from '@ngrx/operators';
import {
signalStore,
withComputed,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import {
Dispatcher,
Events,
on,
withEventHandlers,
withReducer,
} from '@ngrx/signals/events';
import {
AuthConfig,
AuthGateway,
AuthorizationFlow,
CurrentUser,
isExpired,
ProfileGateway,
Session,
SessionStorage,
} from '@task-stream/web/identity/domain';
import { exhaustMap, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
import { authApiEvents, authPageEvents, sessionEvents } from './auth.events';
export type AuthStatus =
| 'restoring'
| 'anonymous'
| 'signing-in'
| 'authenticated';
interface AuthState {
status: AuthStatus;
session: Session | null;
user: CurrentUser | null;
/** undefined = not loaded yet, null = only personal access tokens. */
config: AuthConfig | null | undefined;
error: string | null;
}
const initialState: AuthState = {
status: 'restoring',
session: null,
user: null,
config: undefined,
error: null,
};
function describe(error: unknown): string {
if (error instanceof HttpErrorResponse) {
return (
(error.error as { message?: string } | null)?.message ?? error.message
);
}
return error instanceof Error ? error.message : String(error);
}
export const AuthStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed(({ status, user }) => ({
isAuthenticated: computed(() => status() === 'authenticated'),
initials: computed(() =>
(user()?.name ?? '')
.split(/\s+/)
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase(),
),
})),
withReducer(
on(authApiEvents.configLoaded, ({ payload }) => ({ config: payload })),
on(
authPageEvents.signInWithPassword,
authPageEvents.signInWithPireon,
authPageEvents.callbackReceived,
() => ({ status: 'signing-in' as const, error: null }),
),
on(
authApiEvents.signedIn,
authApiEvents.sessionRestored,
({ payload }) => ({
status: 'authenticated' as const,
session: payload.session,
user: payload.user,
error: null,
}),
),
on(authApiEvents.sessionRefreshed, ({ payload }) => ({ session: payload })),
on(authApiEvents.signInFailed, ({ payload }) => ({
status: 'anonymous' as const,
session: null,
user: null,
error: payload,
})),
on(authApiEvents.signedOut, authApiEvents.anonymous, () => ({
status: 'anonymous' as const,
session: null,
user: null,
})),
),
withEventHandlers(
(
store,
events = inject(Events),
auth = inject(AuthGateway),
profile = inject(ProfileGateway),
flow = inject(AuthorizationFlow),
storage = inject(SessionStorage),
router = inject(Router),
) => {
/** Token → who is it → signed in. */
const signIn = (
session$: ReturnType<AuthGateway['password']>,
returnUrl: string,
) =>
session$.pipe(
switchMap((session) =>
profile
.me(session.accessToken)
.pipe(map((user) => ({ session, user }))),
),
mapResponse({
next: ({ session, user }) =>
authApiEvents.signedIn({ session, user, returnUrl }),
error: (error) => authApiEvents.signInFailed(describe(error)),
}),
);
return {
loadConfig$: events.on(authPageEvents.opened).pipe(
exhaustMap(() =>
auth.config().pipe(
mapResponse({
next: (config) => authApiEvents.configLoaded(config),
error: () => authApiEvents.configLoaded(null),
}),
),
),
),
redirectToPireon$: events.on(authPageEvents.signInWithPireon).pipe(
exhaustMap(({ payload }) => {
const config = store.config();
if (!config?.clientId)
return [
authApiEvents.signInFailed(
'Pireon sign-in is not configured on the API',
),
];
return from(flow.start(config, payload.returnUrl)).pipe(
map(() => null),
);
}),
),
password$: events
.on(authPageEvents.signInWithPassword)
.pipe(
exhaustMap(({ payload }) =>
signIn(
auth.password(payload.username, payload.password),
payload.returnUrl,
),
),
),
callback$: events.on(authPageEvents.callbackReceived).pipe(
exhaustMap(({ payload }) => {
try {
const response = flow.complete(
new URLSearchParams(payload.query),
);
return signIn(
auth.exchangeCode(
response.code,
response.codeVerifier,
response.redirectUri,
),
response.returnUrl,
);
} catch (error) {
return [authApiEvents.signInFailed(describe(error))];
}
}),
),
persist$: events
.on(authApiEvents.signedIn, authApiEvents.sessionRefreshed)
.pipe(
tap(() => {
const session = store.session();
if (session) storage.save(session);
}),
),
navigateIn$: events
.on(authApiEvents.signedIn)
.pipe(
tap(
({ payload }) =>
void router.navigateByUrl(payload.returnUrl || '/'),
),
),
signOut$: events
.on(sessionEvents.signOutRequested, sessionEvents.expired)
.pipe(
tap(() => storage.clear()),
map(() => authApiEvents.signedOut()),
),
navigateOut$: events
.on(authApiEvents.signedOut)
.pipe(tap(() => void router.navigateByUrl('/login'))),
};
},
),
withMethods(
(store, auth = inject(AuthGateway), dispatcher = inject(Dispatcher)) => {
let refreshing: Promise<string | null> | null = null;
return {
/**
* A valid access token for API calls: refreshed when about to expire
* (once, even if many requests ask at the same time).
*/
async validAccessToken(): Promise<string | null> {
const session = store.session();
if (!session) return null;
if (!isExpired(session)) return session.accessToken;
if (!session.refreshToken) {
dispatcher.dispatch(sessionEvents.expired());
return null;
}
refreshing ??= firstValueFrom(auth.refresh(session.refreshToken))
.then((fresh) => {
dispatcher.dispatch(authApiEvents.sessionRefreshed(fresh));
return fresh.accessToken;
})
.catch(() => {
dispatcher.dispatch(sessionEvents.expired());
return null;
})
.finally(() => (refreshing = null));
return refreshing;
},
expire(): void {
if (store.status() === 'authenticated')
dispatcher.dispatch(sessionEvents.expired());
},
};
},
),
withHooks({
/** Restores the stored session (and checks it is still accepted). */
onInit(store) {
const storage = inject(SessionStorage);
const profile = inject(ProfileGateway);
const dispatcher = inject(Dispatcher);
const stored = storage.load();
if (!stored) {
dispatcher.dispatch(authApiEvents.anonymous());
return;
}
// Seed the token so validAccessToken() can refresh it if needed.
dispatcher.dispatch(authApiEvents.sessionRefreshed(stored));
void store.validAccessToken().then((token) => {
if (!token) {
dispatcher.dispatch(authApiEvents.anonymous());
return;
}
profile.me(token).subscribe({
next: (user) =>
dispatcher.dispatch(
authApiEvents.sessionRestored({
session: store.session() ?? stored,
user,
}),
),
error: () => {
storage.clear();
dispatcher.dispatch(authApiEvents.anonymous());
},
});
});
},
}),
);