Files
Toju/toju-app/src/app/domains/authentication/application/services/signal-server-auth.service.ts
Myx eb51f043ac
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m12s
Queue Release Build / build-windows (push) Successful in 27m44s
Queue Release Build / build-linux (push) Successful in 48m1s
Queue Release Build / build-android (push) Successful in 22m7s
Queue Release Build / finalize (push) Successful in 2m42s
fix: Major bug cleanup pass 1
2026-06-09 17:59:54 +02:00

207 lines
6.9 KiB
TypeScript

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<string, Promise<EnsureProvisionedResult>>();
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<User, 'id' | 'username' | 'displayName' | 'homeSignalServerUrl'>): 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<User, 'id'>, existingSecret?: string | null): Promise<string> {
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<EnsureProvisionedResult> {
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<User, 'id' | 'oderId'> | null | undefined,
roomSourceUrl: string | undefined
): ReadonlySet<string> {
const homeOderId = currentUser?.oderId || currentUser?.id;
return resolveSelfPresenceUserIds({
homeUserId: currentUser?.id,
homeOderId,
actorUserId: homeOderId
? this.resolveActorUserIdForServer(roomSourceUrl, homeOderId)
: undefined
});
}
resolveCredentialForSignalUrl(
signalUrl: string,
homeUser?: Pick<User, 'id' | 'homeSignalServerUrl' | 'displayName'> | 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<EnsureProvisionedResult> {
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;
}
}
}