fix: prevent OAuth callback race condition between startup handler and OAuthCallback component (#1671)

This commit is contained in:
Konsti Wohlwend 2026-06-26 11:44:28 -07:00 committed by GitHub
parent 014437f478
commit 325fb791f6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 14 additions and 0 deletions

View File

@ -5,6 +5,7 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { StackClientApp } from "../lib/hexclave-app/apps/interfaces/client-app";
import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common";
import { TranslationProviderClient } from "../providers/translation-provider-client";
import { OAuthCallback } from "./oauth-callback";
@ -40,6 +41,9 @@ function createAppTestDouble(options: {
callOAuthCallback: options.callOAuthCallback,
redirectToSignIn: vi.fn(async () => {}),
redirectToHome: vi.fn(async () => {}),
[hexclaveAppInternalsSymbol]: {
awaitPendingAuthResolutions: vi.fn(async () => {}),
},
};
// This test double intentionally implements only the StackClientApp surface

View File

@ -8,6 +8,7 @@ import { useEffect, useRef, useState } from "react";
import { useStackApp } from "..";
import { MaybeFullPage } from "../components/elements/maybe-full-page";
import { StyledLink } from "../components/link";
import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common";
import { useTranslation } from "../lib/translations";
import { ErrorPage } from "./error-page";
@ -22,6 +23,11 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
if (called.current) return;
called.current = true;
try {
// The startup handler in StackClientApp's constructor may have already consumed the
// one-time OAuth params (code + state cookie) via a microtask that fires before this
// macrotask-scheduled useEffect. Await its completion so we don't race: if it succeeds
// it will redirect and this page tears down; if it fails we fall through below.
await app[hexclaveAppInternalsSymbol].awaitPendingAuthResolutions();
const hasRedirected = await app.callOAuthCallback();
if (!hasRedirected) {
await app.redirectToSignIn({ noRedirectBack: true });

View File

@ -4204,6 +4204,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
signInWithTokens: async (tokens: { accessToken: string, refreshToken: string }) => {
await this._signInToAccountWithTokens(tokens);
},
awaitPendingAuthResolutions: async () => {
await this._awaitPendingAuthResolutions();
},
};
};

View File

@ -136,6 +136,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise<void>,
redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<void>,
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
awaitPendingAuthResolutions(): Promise<void>,
},
}
& AsyncStoreProperty<"project", [], Project, false>