fix: Major bug cleanup pass 1
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
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
This commit is contained in:
@@ -15,11 +15,19 @@ import {
|
||||
CameraState,
|
||||
GameActivity
|
||||
} from '../../shared-kernel';
|
||||
import type { LoginResponse } from '../../domains/authentication/domain/models/authentication.model';
|
||||
|
||||
export const UsersActions = createActionGroup({
|
||||
source: 'Users',
|
||||
events: {
|
||||
'Authenticate User': props<{ user: User }>(),
|
||||
'Authenticate User': props<{ user: User; loginResponse?: LoginResponse }>(),
|
||||
'Authorize Signal Server': props<{
|
||||
serverUrl: string;
|
||||
response: LoginResponse;
|
||||
provisioned?: boolean;
|
||||
}>(),
|
||||
'Revoke Signal Server Credential': props<{ serverUrl: string }>(),
|
||||
'Signal Server Auth Failed': props<{ signalUrl: string }>(),
|
||||
'Load Current User': emptyProps(),
|
||||
'Load Current User Success': props<{ user: User }>(),
|
||||
'Load Current User Failure': props<{ error: string }>(),
|
||||
|
||||
@@ -53,6 +53,7 @@ import { clearStoredCurrentUserId, setStoredCurrentUserId } from '../../core/sto
|
||||
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
|
||||
import { AppI18nService } from '../../core/i18n';
|
||||
import { AuthTokenStoreService } from '../../domains/authentication/application/services/auth-token-store.service';
|
||||
import { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
|
||||
import { hasValidPersistedSession, SESSION_EXPIRED_ERROR_CODE } from '../../domains/authentication/domain/logic/auth-session.rules';
|
||||
import { buildLoginReturnQueryParams } from '../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||
|
||||
@@ -74,14 +75,16 @@ export class UsersEffects {
|
||||
private webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly i18n = inject(AppI18nService);
|
||||
private readonly authTokenStore = inject(AuthTokenStoreService);
|
||||
private readonly signalServerAuthRetries = new Map<string, { count: number; windowStart: number }>();
|
||||
private readonly signalServerAuth = inject(SignalServerAuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
/** Prepares persisted state for a successful login before exposing the user in-memory. */
|
||||
authenticateUser$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.authenticateUser),
|
||||
switchMap(({ user }) =>
|
||||
from(this.prepareAuthenticatedUserStorage(user)).pipe(
|
||||
switchMap(({ user, loginResponse }) =>
|
||||
from(this.prepareAuthenticatedUserStorage(user, loginResponse)).pipe(
|
||||
mergeMap(() => [
|
||||
MessagesActions.clearMessages(),
|
||||
UsersActions.resetUsersState(),
|
||||
@@ -99,6 +102,119 @@ export class UsersEffects {
|
||||
)
|
||||
);
|
||||
|
||||
/** Stores credentials for a foreign signal server without resetting local state. */
|
||||
authorizeSignalServer$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.authorizeSignalServer),
|
||||
tap(({ serverUrl, response, provisioned }) => {
|
||||
this.signalServerAuth.upsertCredentialFromLogin(serverUrl, response, { provisioned });
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Clears credentials for a single signal server. */
|
||||
revokeSignalServerCredential$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.revokeSignalServerCredential),
|
||||
tap(({ serverUrl }) => {
|
||||
this.signalServerAuth.clearCredential(serverUrl);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
/** Re-provisions or logs out depending on which signal server rejected auth. */
|
||||
signalServerAuthFailed$ = createEffect(() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.signalServerAuthFailed),
|
||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||
switchMap(([{ signalUrl }, currentUser]) => {
|
||||
const normalizedSignalUrl = signalUrl.replace(/^ws/i, 'http').replace(/\/+$/, '');
|
||||
const homeSignalServerUrl = currentUser?.homeSignalServerUrl?.replace(/\/+$/, '');
|
||||
|
||||
// A rejection while we still hold a valid credential is almost always
|
||||
// transient (a message raced ahead of identify on a (re)connect). Re-identify
|
||||
// with the existing credential instead of tearing down the session or
|
||||
// provisioning a duplicate account. Bounded so a genuinely invalid token
|
||||
// (server-side revocation) still falls through to expiry/provisioning.
|
||||
if (
|
||||
currentUser
|
||||
&& this.signalServerAuth.hasValidCredential(normalizedSignalUrl)
|
||||
&& this.shouldRetrySignalIdentify(normalizedSignalUrl)
|
||||
) {
|
||||
this.webrtc.identify(
|
||||
currentUser.oderId || currentUser.id,
|
||||
this.resolveDisplayName(currentUser),
|
||||
signalUrl,
|
||||
{
|
||||
description: currentUser.description,
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt,
|
||||
homeSignalServerUrl: currentUser.homeSignalServerUrl
|
||||
}
|
||||
);
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
this.signalServerAuthRetries.delete(normalizedSignalUrl);
|
||||
this.signalServerAuth.clearCredential(normalizedSignalUrl);
|
||||
|
||||
if (homeSignalServerUrl && normalizedSignalUrl === homeSignalServerUrl) {
|
||||
clearStoredCurrentUserId();
|
||||
|
||||
return of(UsersActions.loadCurrentUserFailure({ error: SESSION_EXPIRED_ERROR_CODE }));
|
||||
}
|
||||
|
||||
return from(this.signalServerAuth.ensureProvisioned(normalizedSignalUrl, currentUser)).pipe(
|
||||
mergeMap((result) => {
|
||||
if (result.kind === 'provisioned' || result.kind === 'existing') {
|
||||
if (currentUser) {
|
||||
this.webrtc.identify(
|
||||
currentUser.oderId || currentUser.id,
|
||||
this.resolveDisplayName(currentUser),
|
||||
signalUrl,
|
||||
{
|
||||
description: currentUser.description,
|
||||
profileUpdatedAt: currentUser.profileUpdatedAt,
|
||||
homeSignalServerUrl: currentUser.homeSignalServerUrl
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
catchError(() => EMPTY)
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
/** Provisions missing credentials for active signal servers after home login loads. */
|
||||
provisionActiveSignalServers$ = createEffect(
|
||||
() =>
|
||||
this.actions$.pipe(
|
||||
ofType(UsersActions.loadCurrentUserSuccess),
|
||||
tap(({ user }) => {
|
||||
this.signalServerAuth.migrateHomeCredential(user);
|
||||
|
||||
for (const endpoint of this.serverDirectory.activeServers()) {
|
||||
if (endpoint.status === 'incompatible' || !endpoint.isActive) {
|
||||
continue;
|
||||
}
|
||||
|
||||
void this.signalServerAuth.ensureProvisioned(endpoint.url, user).catch(() => undefined);
|
||||
}
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
);
|
||||
|
||||
// Load current user from storage
|
||||
/** Loads the persisted current user from the local database on startup. */
|
||||
loadCurrentUser$ = createEffect(() =>
|
||||
@@ -166,11 +282,25 @@ export class UsersEffects {
|
||||
};
|
||||
}
|
||||
|
||||
private async prepareAuthenticatedUserStorage(user: User): Promise<void> {
|
||||
private async prepareAuthenticatedUserStorage(
|
||||
user: User,
|
||||
loginResponse?: {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
setStoredCurrentUserId(user.id);
|
||||
await this.db.initialize();
|
||||
await this.db.setCurrentUserId(user.id);
|
||||
await this.db.saveUser(user);
|
||||
|
||||
if (user.homeSignalServerUrl && loginResponse) {
|
||||
this.signalServerAuth.upsertCredentialFromLogin(user.homeSignalServerUrl, loginResponse, { provisioned: false });
|
||||
await this.signalServerAuth.ensureHomeProvisionSecret(user);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads all users associated with a specific room from the local database. */
|
||||
@@ -553,6 +683,32 @@ export class UsersEffects {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded guard for re-identifying after a transient signal-server auth rejection.
|
||||
* Allows a small number of retries per rolling window so a genuinely revoked token
|
||||
* eventually falls through to session-expiry / re-provisioning instead of looping.
|
||||
*/
|
||||
private shouldRetrySignalIdentify(normalizedSignalUrl: string): boolean {
|
||||
const MAX_RETRIES = 3;
|
||||
const WINDOW_MS = 30_000;
|
||||
const now = Date.now();
|
||||
const existing = this.signalServerAuthRetries.get(normalizedSignalUrl);
|
||||
|
||||
if (!existing || now - existing.windowStart > WINDOW_MS) {
|
||||
this.signalServerAuthRetries.set(normalizedSignalUrl, { count: 1, windowStart: now });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (existing.count >= MAX_RETRIES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private resolveDisplayName(user: Pick<User, 'displayName' | 'username'>): string {
|
||||
const displayName = user.displayName?.trim();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user