Skip to content
129 changes: 103 additions & 26 deletions src/features/auth/store/authStore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { isLocalStudio, localStudioDevUrl } from '@/config/constants';
import { getInstanceClient } from '@/config/getInstanceClient';
import { getCurrentUser } from '@/features/auth/queries/getCurrentUser';
import {
forgetEntitySettings,
pruneStaleEntitySettings,
scrubLegacySettings,
} from '@/features/instance/apis/explorer/settings';
import { SchemaCluster, SchemaHdbInstance } from '@/integrations/api/api.gen';
import { Cluster, Instance, LocalUser, User } from '@/integrations/api/api.patch';
import {
Expand All @@ -10,28 +15,11 @@ import {
import { onInstanceLogoutSubmit } from '@/integrations/api/instance/auth/onInstanceLogoutSubmit';
import { getInstanceUserInfo } from '@/integrations/api/instance/status/getInstanceUserInfo';
import { sleep } from '@/lib/sleep';
import { getLocalStorage } from '@/lib/storage/getLocalStorage';
import { LocalStorageKeys } from '@/lib/storage/localStorageKeys';
import { setLocalStorage } from '@/lib/storage/setLocalStorage';
import { isCluster } from '@/lib/types/isCluster';
import { isInstance } from '@/lib/types/isInstance';
import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluster';
import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance';

/**
* Drop the API explorer's persisted server/credentials for one entity. The explorer keeps a Basic
* password or Bearer token per entity in localStorage; sign-out must not leave it for a later
* connection to reuse. (Full sign-out clears all of localStorage; this covers per-entity sign-out.)
*/
function forgetApiExplorerSettings(id: EntityIds): void {
const settings = getLocalStorage<Record<string, unknown>>(LocalStorageKeys.ApiExplorerSettings, {});
// A corrupted value (a primitive, or an array) must not throw and abort sign-out; hasOwnProperty
// (not `in`) so an entity id can't match an inherited prototype key.
if (settings && typeof settings === 'object' && !Array.isArray(settings) && Object.hasOwn(settings, id)) {
delete settings[id];
setLocalStorage(LocalStorageKeys.ApiExplorerSettings, settings);
}
}
import { isDirectOperationsUrl } from '@/lib/urls/isDirectOperationsUrl';

type AuthStoreListenerCleanup = () => void;

Expand Down Expand Up @@ -61,13 +49,6 @@ type OverallAppSignInType = typeof OverallAppSignIn;
export type EntityIds = OverallAppSignInType | Instance['id'] | Cluster['id'];
type EntityTypes = OverallAppSignInType | Instance | Cluster | null;

// The Fabric Connect proxy routes through these central-manager paths (see getInstanceClient). A
// direct operations URL must never be one of them, or we'd send the instance Bearer JWT to the proxy
// origin instead of the instance.
function isDirectOperationsUrl(url: string | null | undefined): url is string {
return !!url && !url.includes('/HDBInstance/') && !url.includes('/Cluster/');
}

class AuthStore {
private readonly broadListeners: Array<(connection: AuthenticatedConnection, id: EntityIds) => void> = [];
private readonly specificListeners: Record<
Expand Down Expand Up @@ -95,10 +76,95 @@ class AuthStore {
private readonly fabricConnectInFlight = new Map<EntityIds, Promise<LocalUser>>();
private readonly operationTokenRefreshInFlight = new Map<EntityIds, Promise<string | null>>();

// Sign-out generations for the API explorer, per entity plus a global `'*'` slot. The explorer keeps
// its credential per browser tab (sessionStorage), which SURVIVES a reload — so an event-only signal
// is not enough: a tab that reloads after another tab signed out would never see the event. The
// generation therefore lives in localStorage (durable, shared) and each stored credential records the
// generation it was created under, so a stale credential is detected by comparison at read time
// regardless of whether this tab was running when the sign-out happened.
private readonly explorerAuthEpochKey = 'Studio:ExplorerAuthEpoch';

constructor() {
this.potentiallyAuthenticated = JSON.parse(localStorage.getItem(this.potentiallyAuthenticatedKey) || '{}');
}

private readExplorerGenerations(): Record<string, number> {
try {
const raw = JSON.parse(localStorage.getItem(this.explorerAuthEpochKey) || '{}') as unknown;
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, number> : {};
} catch {
return {};
}
}

/**
* Current sign-out generation for an entity — its own count plus the global (`'*'`) count, so a
* global logout advances every entity's generation. The explorer stamps a credential with this and
* compares before using it (and before applying an in-flight mint).
*/
public getExplorerAuthEpoch(id: EntityIds): number {
const generations = this.readExplorerGenerations();
const own = typeof generations[id] === 'number' ? generations[id] : 0;
const all = typeof generations['*'] === 'number' ? generations['*'] : 0;
return own + all;
}

private writeExplorerInvalidation(id: EntityIds | '*'): void {
try {
const generations = this.readExplorerGenerations();
const current = typeof generations[id] === 'number' ? generations[id] : 0;
// The value changes on every write, so other tabs' `storage` listeners still fire.
localStorage.setItem(this.explorerAuthEpochKey, JSON.stringify({ ...generations, [id]: current + 1 }));
} catch {
// Storage disabled: the explorer's per-tab clearing on sign-out still applies within this tab.
}
}

private bumpExplorerAuthEpoch(id: EntityIds): void {
this.writeExplorerInvalidation(id);
}

/** Signal every tab's explorer to clear, regardless of entity — used on a global (all-entity) logout. */
private bumpExplorerAuthEpochAll(): void {
this.writeExplorerInvalidation('*');
}

/**
* Subscribe to a change in the explorer sign-out generation (any entity, or a global logout). The
* caller re-compares its own entity's stamped generation rather than trusting the event to name one,
* which is the same check that runs at bootstrap — so a missed event can't leave a stale credential.
*/
public onExplorerAuthInvalidated(_id: EntityIds, callback: () => void): () => void {
const handler = (event: StorageEvent) => {
if (event.key === this.explorerAuthEpochKey) {
callback();
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}

/**
* App-level cleanup, installed once at bootstrap. Runs immediately — reconciling credentials stored
* in this tab's sessionStorage (which survives a reload) against the durable generation, so a
* sign-out that happened while this tab was closed or reloading is still honored — and again whenever
* the generation changes, so an unmounted explorer's credential is dropped too.
*/
public installExplorerCrossTabCleanup(): () => void {
Comment thread
dawsontoth marked this conversation as resolved.
const reconcile = () => {
pruneStaleEntitySettings(entityId => this.getExplorerAuthEpoch(entityId));
scrubLegacySettings();
};
reconcile();
const handler = (event: StorageEvent) => {
if (event.key === this.explorerAuthEpochKey) {
reconcile();
}
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
}

public getAllConnections(): Record<EntityIds, AuthenticatedConnection> {
if (!this.potentiallyAuthenticated.OverallAppSignIn) {
this.allConnections[OverallAppSignIn] = {
Expand Down Expand Up @@ -176,6 +242,11 @@ class AuthStore {
this.flagKeyAsSignedIn(id, key);
} else {
this.flagKeyAsSignedOut(id);
// Every per-entity disconnect funnels through here (ClusterHome/ClusterCard call
// setUserForEntity(entity, null)), so clear the API explorer's stored credentials for the
// entity here too — otherwise the next user to sign into the same entity would inherit them.
forgetEntitySettings(id);
this.bumpExplorerAuthEpoch(id);
}
this.updateConnectionIfChanged(id, false, user);
}
Expand Down Expand Up @@ -414,6 +485,8 @@ class AuthStore {
this.updateConnectionIfChanged(entityId, false, null);
this.flagKeyAsSignedOut(entityId);
this.fabricConnectAuth.delete(entityId);
forgetEntitySettings(entityId);
this.bumpExplorerAuthEpoch(entityId);
if (entityId === OverallAppSignIn) {
continue;
}
Expand All @@ -438,7 +511,8 @@ class AuthStore {
this.flagForFabricConnect(id, false);
this.flagKeyAsSignedOut(id);
this.updateConnectionIfChanged(id, false, null);
forgetApiExplorerSettings(id);
forgetEntitySettings(id);
Comment thread
dawsontoth marked this conversation as resolved.
this.bumpExplorerAuthEpoch(id);
}

/**
Expand All @@ -459,6 +533,9 @@ class AuthStore {
this.fabricConnectInFlight.clear();
this.operationTokenRefreshInFlight.clear();
this.setUserForEntity(OverallAppSignIn, null);
// Broadcast a global clear so other tabs' explorers drop their credentials even for entities not
// in this tab's potentiallyAuthenticated set.
this.bumpExplorerAuthEpochAll();
}

private calculateKeyFromEntity(entity: EntityTypes): AuthenticatedConnectionKey | undefined {
Expand Down
28 changes: 27 additions & 1 deletion src/features/instance/apis/APIDocs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,19 @@ import { useInstanceClientIdParams } from '@/config/useInstanceClient';
import { ApiExplorer } from '@/features/instance/apis/explorer/ApiExplorer';
import { OpenApiSpec } from '@/features/instance/apis/explorer/types';
import { useRollingConfigUpdate } from '@/hooks/useRollingConfigUpdate';
import {
createInstanceAuthenticationTokens,
mintOperationTokenWithCredentials,
} from '@/integrations/api/instance/auth/createInstanceAuthenticationTokens';
import { getConfigurationQueryOptions } from '@/integrations/api/instance/status/getConfiguration';
import { getOpenAPIQueryOptions } from '@/integrations/api/instance/status/getOpenAPI';
import { getRegistrationInfoQueryOptions } from '@/integrations/api/instance/status/getRegistrationInfo';
import { wasAReleasedBeforeB } from '@/lib/string/wasAReleasedBeforeB';
import { isDirectOperationsUrl } from '@/lib/urls/isDirectOperationsUrl';
import { useQuery } from '@tanstack/react-query';
import { useParams } from '@tanstack/react-router';
import { Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';

export function APIDocs() {
const { instanceId, clusterId }: { instanceId?: string; clusterId?: string } = useParams({ strict: false });
Expand All @@ -31,6 +36,25 @@ export function APIDocs() {
isLoading: isLoadingDocs,
error,
} = useQuery(getOpenAPIQueryOptions(operationsParams));

// The credential (username/password) log-in mints directly against the operations client's own
// base URL — the address Studio already talks to this instance on. When that address is the Fabric
// Connect proxy path, it fails the direct check and the credential fallback is withheld so typed
// credentials never reach central manager; session mint (below) still works there.
const operationsBaseURL = operationsParams.instanceClient.defaults.baseURL;
const onSessionMint = useCallback(
async () =>
(await createInstanceAuthenticationTokens({ instanceClient: operationsParams.instanceClient })).operationToken,
[operationsParams.instanceClient],
);
const onCredentialMint = useMemo(
Comment thread
dawsontoth marked this conversation as resolved.
() =>
isDirectOperationsUrl(operationsBaseURL)
? (credentials: { username: string; password: string }) =>
mintOperationTokenWithCredentials({ operationsUrl: operationsBaseURL, ...credentials })
: null,
[operationsBaseURL],
);
// The explorer builds its own server list from `baseURL` (Studio's computed REST URL) plus the
// spec's declared servers, and lets the user pick — so we no longer mutate the spec's servers as
// the previous Swagger integration did.
Expand Down Expand Up @@ -128,6 +152,8 @@ export function APIDocs() {
spec={spec as OpenApiSpec | undefined}
baseURL={baseURL}
entityId={operationsParams.entityId}
onSessionMint={onSessionMint}
onCredentialMint={onCredentialMint}
/>
</div>
);
Expand Down
Loading