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>
290 lines
8.5 KiB
TypeScript
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());
|
|
},
|
|
});
|
|
});
|
|
},
|
|
}),
|
|
);
|