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
207 lines
6.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|