Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions src/config/getInstanceClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { authStore, EntityIds, OverallAppSignIn } from '@/features/auth/store/au
import { rejectReplicationFailures } from '@/integrations/api/replication';
import { curryRecoverExpiredOperationToken } from '@/integrations/api/retryExpiredOperationToken';
import { curryRetryGatewayErrors } from '@/integrations/api/retryGatewayErrors';
import axios from 'axios';
import axios, { AxiosError } from 'axios';

interface InstanceClient {
id?: EntityIds;
Expand All @@ -21,10 +21,17 @@ export function getInstanceClient(
forceFabricConnect,
disableFabricConnect,
forceOperationToken,
disableTokenRecovery,
}: InstanceClient & {
forceFabricConnect?: boolean;
disableFabricConnect?: boolean;
forceOperationToken?: boolean;
/**
* Opt this client out of the direct-connect token lifecycle entirely — no request-time Bearer,
* no 401 recovery. Set by the refresh call itself: it carries the refresh token as its own
* Bearer, and a recovery interceptor on it would re-enter the mint that is awaiting it.
*/
disableTokenRecovery?: boolean;
} = {},
) {
let baseURL = operationsUrl || authStore.getOperationsUrl(id);
Expand Down Expand Up @@ -72,13 +79,41 @@ export function getInstanceClient(
},
baseURL,
});
// The Bearer comes from the store on every send, not from the header baked in at construction: a
// client outlives its token, so one refresh has to advance every client the page holds. A retry is
// not a new send — `curryRetryGatewayErrors` sleeps up to 20s, long enough to reconnect as someone
// else — so a request keeps the generation of its first send.
if (operationToken && !disableTokenRecovery) {
client.interceptors.request.use((config) => {
const stamped = config as typeof config & { __connectionGeneration?: number };
const generation = authStore.getConnectionGeneration(id);
if (stamped.__connectionGeneration === undefined) {
stamped.__connectionGeneration = generation;
} else if (stamped.__connectionGeneration !== generation) {
throw new AxiosError(
'Connection replaced before this request could be retried.',
AxiosError.ERR_CANCELED,
config,
);
}
const token = authStore.getOperationToken(id);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
} else {
// Signed out: drop the constructor-baked header rather than letting a JWT the store has
// discarded — still valid at the instance for ~a day — ride out on a retained client.
delete config.headers.Authorization;
}
return config;
});
}
client.interceptors.response.use(
rejectReplicationFailures,
curryRetryGatewayErrors(client),
);
// Direct-connect clients recover from a 401 (expired operation token) by minting a fresh token and
// replaying once. Registered after the gateway-retry handler, which passes a 401 straight through.
if (operationToken) {
if (operationToken && !disableTokenRecovery) {
client.interceptors.response.use(undefined, curryRecoverExpiredOperationToken(client, id));
}
return client;
Expand Down
71 changes: 59 additions & 12 deletions src/features/auth/store/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ class AuthStore {
>();
private readonly fabricConnectInFlight = new Map<EntityIds, Promise<LocalUser>>();
private readonly operationTokenRefreshInFlight = new Map<EntityIds, Promise<string | null>>();
// An entity can be disconnected and reconnected as a different user while a mint is in flight, so
// `mode === 'direct'` alone cannot tell one connection's credential from the next one's.
private readonly directConnectionGeneration = new Map<EntityIds, number>();

// 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
Expand Down Expand Up @@ -329,6 +332,7 @@ class AuthStore {
} else {
localStorage.removeItem(this.fabricConnectKeyPrefix + id);
this.fabricConnectAuth.delete(id);
this.bumpConnectionGeneration(id);
}
}

Expand All @@ -346,6 +350,15 @@ class AuthStore {
return value === 'fabric' || value === 'direct' ? value : undefined;
}

public getConnectionGeneration(id: EntityIds): number {
return this.directConnectionGeneration.get(id) ?? 0;
}

private bumpConnectionGeneration(id: EntityIds): void {
this.directConnectionGeneration.set(id, this.getConnectionGeneration(id) + 1);
this.operationTokenRefreshInFlight.delete(id);
}

/** The in-memory Fabric Connect JWT for direct connect, or undefined if not connected directly. */
public getOperationToken(id: EntityIds): string | undefined {
const fabric = this.fabricConnectAuth.get(id);
Expand Down Expand Up @@ -389,23 +402,33 @@ class AuthStore {
if (inFlight) {
return inFlight;
}
const promise = this.mintFreshOperationToken(id, fabric.refreshToken);
const generation = this.getConnectionGeneration(id);
const promise = this.mintFreshOperationToken(id, fabric.refreshToken, generation);
this.operationTokenRefreshInFlight.set(id, promise);
return promise.finally(() => this.operationTokenRefreshInFlight.delete(id));
return promise.finally(() => {
// Only evict our own entry: a reconnect clears the map, and the replacement connection may
// already have registered its refresh by the time this one settles.
if (this.operationTokenRefreshInFlight.get(id) === promise) {
this.operationTokenRefreshInFlight.delete(id);
}
});
}

private async mintFreshOperationToken(id: EntityIds, refreshToken: string | undefined): Promise<string | null> {
private async mintFreshOperationToken(
id: EntityIds,
refreshToken: string | undefined,
generation: number,
): Promise<string | null> {
// Cheap path: exchange the refresh token for a new operation token, directly at the instance.
// forceOperationToken keeps getInstanceClient on the direct URL (not the proxy) even if a stale
// basic-auth entry exists; refreshInstanceOperationToken overrides the Bearer with the refresh token.
if (refreshToken) {
try {
const token = await refreshInstanceOperationToken({
instanceClient: getInstanceClient({ id, forceOperationToken: true }),
instanceClient: getInstanceClient({ id, forceOperationToken: true, disableTokenRecovery: true }),
refreshToken,
});
this.updateDirectOperationToken(id, token, refreshToken);
return token;
return this.updateDirectOperationToken(id, token, refreshToken, generation) ? token : null;
} catch (err) {
console.debug(
'Operation token refresh failed; re-minting via proxy',
Expand All @@ -415,23 +438,37 @@ class AuthStore {
}

// Fall back to minting a fresh pair through the proxy.
if (this.getConnectionGeneration(id) !== generation) {
return null;
}
try {
const { operationToken, refreshToken: newRefreshToken } = await createInstanceAuthenticationTokens({
instanceClient: getInstanceClient({ id, forceFabricConnect: true }),
});
this.updateDirectOperationToken(id, operationToken, newRefreshToken ?? refreshToken);
return operationToken;
return this.updateDirectOperationToken(id, operationToken, newRefreshToken ?? refreshToken, generation)
? operationToken
: null;
} catch (err) {
console.debug('Operation token re-mint failed', err instanceof Error ? err.message : err);
return null;
}
}

/** Update the in-memory direct token, but only if a concurrent logout/flag-off hasn't cleared it. */
private updateDirectOperationToken(id: EntityIds, token: string, refreshToken: string | undefined): void {
if (this.fabricConnectAuth.get(id)?.mode === 'direct') {
this.fabricConnectAuth.set(id, { mode: 'direct', token, refreshToken });
/**
* Commit a freshly minted direct token, but only onto the connection that asked for it: a
* drop-and-reconnect leaves `mode` unchanged while the identity behind it changes.
*/
private updateDirectOperationToken(
id: EntityIds,
token: string,
refreshToken: string | undefined,
generation: number,
): boolean {
if (this.fabricConnectAuth.get(id)?.mode !== 'direct' || this.getConnectionGeneration(id) !== generation) {
return false;
}
this.fabricConnectAuth.set(id, { mode: 'direct', token, refreshToken });
return true;
}

/**
Expand Down Expand Up @@ -469,6 +506,7 @@ class AuthStore {
// (or the passed URL) is a proxy URL we'd send the instance JWT to the central-manager origin.
// In that case skip straight to the proxy fallback.
if (isDirectOperationsUrl(operationsUrl)) {
this.bumpConnectionGeneration(id);
this.fabricConnectAuth.set(id, { mode: 'direct', token: operationToken, refreshToken });
try {
// forceOperationToken so a stale basic-auth entry for this id can't shadow the token we
Expand All @@ -490,6 +528,7 @@ class AuthStore {
}

// Proxy fallback: route every operation through the central-manager proxy.
this.bumpConnectionGeneration(id);
this.fabricConnectAuth.set(id, { mode: 'proxy' });
const proxyClient = getInstanceClient({ id, forceFabricConnect: true });
const user = await getInstanceUserInfo({ instanceClient: proxyClient });
Expand All @@ -500,6 +539,7 @@ class AuthStore {
} catch (err) {
// Couldn't establish either mode — clear the half-resolved state so we retry next time.
this.fabricConnectAuth.delete(id);
this.bumpConnectionGeneration(id);
throw err;
}
}
Expand Down Expand Up @@ -540,6 +580,10 @@ class AuthStore {
instanceClient.defaults.timeout = LOGOUT_TIMEOUT_MS;
// No gateway-error retries (5s + 10s + 20s) and no token recovery for a best-effort logout.
instanceClient.interceptors.response.clear();
// The request interceptor re-reads the store at send time, and the caller clears the store
// before posting — leaving it on would strip the very credential the logout must present.
// The constructor-baked header carries it instead.
instanceClient.interceptors.request.clear();
return instanceClient;
} catch (err: unknown) {
reportLogoutFailure(entityId, err);
Expand Down Expand Up @@ -577,6 +621,9 @@ class AuthStore {
for (const id of Object.keys(this.potentiallyAuthenticated)) {
this.signOutLocally(id);
}
for (const id of this.fabricConnectAuth.keys()) {
this.bumpConnectionGeneration(id);
}
this.fabricConnectAuth.clear();
this.fabricConnectInFlight.clear();
this.operationTokenRefreshInFlight.clear();
Expand Down
Loading