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
14 changes: 13 additions & 1 deletion src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,14 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost {
if (this.#loopController.signal.aborted) break;
if (end.failure !== undefined) this.#emitFailure(end.failure);
if (this.#loopController.signal.aborted) break;
if (end.failure instanceof BrowserClientError
&& end.failure.kind === 'authorization'
&& !end.failure.retryable) {
// Asking again cannot change the answer: stop instead of looping.
this.#startDeferred?.reject(end.failure);
void this.stop();
break;
}
this.#transition('reconnecting', undefined, end.failure);
if (this.#loopController.signal.aborted) break;
if (endedSession !== undefined
Expand Down Expand Up @@ -436,7 +444,11 @@ class BrowserClientImpl implements BrowserClient, UserStateHost, UserCallHost {
return this.#options.credentialProvider.getCredential(request);
})
.then((value) => validateBrowserRelayCredential(value, this.#runtime.now()))
.catch(() => { throw browserUnavailable('Browser relay credential provider failed'); });
.catch((error: unknown) => {
// A permanent refusal (home deleted, access withdrawn) stays permanent.
if (error instanceof BrowserClientError && !error.retryable) throw error;
throw browserUnavailable('Browser relay credential provider failed');
});
const timeout = this.#runtime.setTimer(() => {
child.controller.abort(browserUnavailable('Browser relay credential request timed out'));
}, Math.max(1, Math.min(timeoutMs, SESSION_PHASE_TIMEOUT_MS)));
Expand Down
20 changes: 19 additions & 1 deletion src/browser-relay-credential-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import {
parseResponseJson,
type JsonValue,
} from './internal/control-plane-response.js';
import { BrowserClientError } from './internal/browser-errors.js';

/**
* Statuses the control plane uses for answers that will not change by asking
* again: a home that does not exist (404), an account that may not open it
* (403), a request it cannot accept (400). Retrying them only burns quota.
*/
const PERMANENT_REFUSALS = new Set([400, 403, 404]);

const HOME_ID = /^[a-z][a-z0-9-]{1,61}[a-z0-9]$/;
const BASE64URL = /^[A-Za-z0-9_-]+$/;
Expand Down Expand Up @@ -201,6 +209,15 @@ async function exchangeCredential(
signal: request.signal,
} as RequestInit), request.signal);
if (request.signal.aborted) throw abortReason(request.signal);
if (PERMANENT_REFUSALS.has(response.status)) {
cancelResponseBody(response);
throw new BrowserClientError(
'authorization',
'not_dispatched',
`The control plane refused to open this home (HTTP ${response.status})`,
{ code: response.status, retryable: false },
);
}
if (response.status !== 200 || response.redirected || response.type === 'opaqueredirect') {
cancelResponseBody(response);
return exchangeFailure();
Expand All @@ -215,11 +232,12 @@ async function exchangeCredential(
const body = await withAbort(boundedResponseBody(response, request.signal), request.signal);
if (request.signal.aborted) throw abortReason(request.signal);
return credentialResponse(parseResponseJson(body), firebaseIdToken, appCheckToken, Date.now());
} catch {
} catch (error) {
if (request.signal.aborted) {
cancelResponseBody(response);
throw abortReason(request.signal);
}
if (error instanceof BrowserClientError && !error.retryable) throw error;
return exchangeFailure();
}
}
Expand Down
13 changes: 13 additions & 0 deletions test/browser-relay-credential-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,19 @@ describe('control-plane browser relay credential provider', () => {
}
});

test('reports a refusal that asking again cannot change as permanent', async () => {
for (const status of [400, 403, 404]) {
const provider = createControlPlaneBrowserRelayCredentialProvider(options(
async () => new Response('{"error":{"code":"home_not_found","retryable":false}}', {
status,
headers: successHeaders(),
}),
));
const failure = await provider.getCredential(request()).catch((error: unknown) => error);
expect(failure).toMatchObject({ kind: 'authorization', retryable: false, code: status });
}
});

test('coalesces only identical in-flight requests', async () => {
let release: (() => void) | undefined;
const gate = new Promise<void>((resolve) => { release = resolve; });
Expand Down
Loading