import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { firstValueFrom } from 'rxjs'; import type { User } from '../../../../shared-kernel'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; import type { LoginResponse } from '../../domain/models/authentication.model'; import type { SignalServerCredential } from '../../domain/models/signal-server-credential.model'; import { ProvisionUsernameCollisionError } from '../../domain/logic/signal-server-provision.rules'; import { type ResolvedSignalIdentity, resolveSignalIdentity } from '../../domain/logic/signal-server-credential-resolution.rules'; import { resolveSelfPresenceUserIds } from '../../domain/logic/self-presence-identity.rules'; import { AuthTokenStoreService } from './auth-token-store.service'; import { ProvisionSecretStoreService, generateProvisionSecret } from './provision-secret-store.service'; import { SignalServerCredentialStoreService } from './signal-server-credential-store.service'; import { SignalServerProvisionerService, type ProvisionResult } from './signal-server-provisioner.service'; import { SignalServerProvisionNoticeService } from './signal-server-provision-notice.service'; export type EnsureProvisionedResult = | { kind: 'existing'; credential: SignalServerCredential } | { kind: 'provisioned'; result: ProvisionResult } | { kind: 'skipped'; reason: 'no-home-user' | 'no-provision-secret' | 'already-valid' } | { kind: 'collision'; error: ProvisionUsernameCollisionError }; @Injectable({ providedIn: 'root' }) export class SignalServerAuthService { private readonly store = inject(Store); private readonly credentialStore = inject(SignalServerCredentialStoreService); private readonly authTokenStore = inject(AuthTokenStoreService); private readonly provisionSecretStore = inject(ProvisionSecretStoreService); private readonly provisioner = inject(SignalServerProvisionerService); private readonly provisionNotice = inject(SignalServerProvisionNoticeService); private readonly provisionInFlight = new Map>(); getCredential(serverUrl: string): SignalServerCredential | null { return this.credentialStore.getCredential(serverUrl); } hasValidCredential(serverUrl: string): boolean { return this.credentialStore.hasValidCredential(serverUrl); } upsertCredentialFromLogin( serverUrl: string, response: LoginResponse, options: { provisioned?: boolean } = {} ): SignalServerCredential { return this.provisioner.upsertManualCredential(serverUrl, response, options.provisioned ?? false); } clearCredential(serverUrl: string): void { this.credentialStore.clearCredential(serverUrl); } migrateHomeCredential(user: Pick): void { const homeSignalServerUrl = user.homeSignalServerUrl?.trim(); if (!homeSignalServerUrl || this.credentialStore.hasValidCredential(homeSignalServerUrl)) { return; } const tokenEntry = this.authTokenStore.getTokenEntry(homeSignalServerUrl); if (!tokenEntry) { return; } this.credentialStore.upsertCredential({ serverUrl: homeSignalServerUrl, userId: user.id, username: user.username, displayName: user.displayName, token: tokenEntry.token, expiresAt: tokenEntry.expiresAt, provisioned: false }); } async ensureHomeProvisionSecret(homeUser: Pick, existingSecret?: string | null): Promise { const stored = existingSecret ?? await this.provisionSecretStore.getSecret(homeUser.id); if (stored) { return stored; } const generated = generateProvisionSecret(); await this.provisionSecretStore.storeSecret(homeUser.id, generated); return generated; } async ensureProvisioned(serverUrl: string, homeUser?: User | null): Promise { const normalizedUrl = this.normalizeServerUrl(serverUrl); const existing = this.credentialStore.getCredential(normalizedUrl); if (existing) { return { kind: 'existing', credential: existing }; } const inFlight = this.provisionInFlight.get(normalizedUrl); if (inFlight) { return inFlight; } const provisionPromise = this.runProvision(normalizedUrl, homeUser); this.provisionInFlight.set(normalizedUrl, provisionPromise); try { return await provisionPromise; } finally { this.provisionInFlight.delete(normalizedUrl); } } resolveActorUserIdForServer(serverUrl: string | undefined, fallbackUserId: string): string { if (!serverUrl?.trim()) { return fallbackUserId; } const credential = this.getCredential(serverUrl); return credential?.userId ?? fallbackUserId; } resolveSelfPresenceUserIdsForRoom( currentUser: Pick | null | undefined, roomSourceUrl: string | undefined ): ReadonlySet { const homeOderId = currentUser?.oderId || currentUser?.id; return resolveSelfPresenceUserIds({ homeUserId: currentUser?.id, homeOderId, actorUserId: homeOderId ? this.resolveActorUserIdForServer(roomSourceUrl, homeOderId) : undefined }); } resolveCredentialForSignalUrl( signalUrl: string, homeUser?: Pick | null ): ResolvedSignalIdentity | null { const httpUrl = signalUrl.replace(/^ws/i, 'http'); return resolveSignalIdentity( this.credentialStore.getCredential(httpUrl), this.authTokenStore.getTokenEntry(httpUrl), homeUser ); } private async runProvision( normalizedUrl: string, homeUser?: User | null ): Promise { const user = homeUser ?? await firstValueFrom(this.store.select(selectCurrentUser)); if (!user) { return { kind: 'skipped', reason: 'no-home-user' }; } const provisionSecret = await this.provisionSecretStore.getSecret(user.id); if (!provisionSecret) { return { kind: 'skipped', reason: 'no-provision-secret' }; } try { const result = await this.provisioner.provisionOnServer({ serverUrl: normalizedUrl, homeUser: user, provisionSecret }); if (result.usedSuffix) { this.provisionNotice.publish({ serverName: this.resolveServerDisplayName(normalizedUrl), preferredUsername: user.username, provisionedUsername: result.username }); } return { kind: 'provisioned', result }; } catch (error) { if (error instanceof ProvisionUsernameCollisionError) { return { kind: 'collision', error }; } throw error; } } private normalizeServerUrl(serverUrl: string): string { return serverUrl.trim().replace(/\/+$/, ''); } private resolveServerDisplayName(serverUrl: string): string { try { return new URL(serverUrl).hostname; } catch { return serverUrl; } } }