diff --git a/.changeset/olive-pugs-repeat.md b/.changeset/olive-pugs-repeat.md
new file mode 100644
index 00000000..a6549258
--- /dev/null
+++ b/.changeset/olive-pugs-repeat.md
@@ -0,0 +1,15 @@
+---
+"@tailor-platform/app-shell": patch
+---
+
+Fix `autoLogin` being permanently disabled after a failed OAuth callback.
+
+`@tailor-platform/auth-public-client` cleans the callback parameters out of the URL only when the code exchange succeeds; every failure path leaves `?code=` or `?error=` in the query string (tailor-platform/auth-public-client#139). `AuthProvider` decided whether a callback was in progress by reading that URL, so after any failed callback it treated the page as a live callback forever: auto-login never fired again, the app sat on `guardComponent`, and reloading only replayed the same failing callback.
+
+Auto-login now decides from the callback's status rather than the URL — which is what the check meant all along: "is an exchange in flight", not "has this page ever been a callback". The URL is still the authority when no callback has been claimed at all, where redirecting away from unconsumed parameters would be unsafe.
+
+It will not loop, either. A recoverable failure is retried once per tab; beyond that, and for any refusal the authorization server issues explicitly, AppShell stops rather than redirecting the user back for the same answer. The outcome is classified from the resulting auth state rather than from whether the callback threw, because two failure paths — a server `error` and a state mismatch — return normally after recording the error.
+
+That makes `guardComponent` where a failed sign-in becomes visible: a guard that only renders a spinner will spin indefinitely, so render `useAuth().error` and offer a retry. See the authentication guide.
+
+The stale parameters themselves remain in the URL after a failure until the cleanup is fixed upstream (tailor-platform/auth-public-client#139) — app-shell no longer misbehaves because of them, but deliberately does not reimplement the library's URL cleanup.
diff --git a/docs/concepts/authentication.md b/docs/concepts/authentication.md
index c1ce3a71..7148fbcb 100644
--- a/docs/concepts/authentication.md
+++ b/docs/concepts/authentication.md
@@ -217,6 +217,31 @@ function ChatScreen() {
| `autoLogin` | `boolean` | No | Automatically redirect unauthenticated users to login |
| `guardComponent` | `() => React.ReactNode` | No | Rendered while loading or when not authenticated |
+## When the callback fails
+
+A sign-in that comes back from the authorization server unsuccessfully — the user declined, the client is not permitted, the exchange failed — leaves `isAuthenticated` false with the reason on `useAuth().error`. The stale callback parameters may remain in the URL (the auth client currently cleans them only on success — tracked upstream as `tailor-platform/auth-public-client#139`; the repo is private, hence no link), but they no longer affect AppShell's behaviour.
+
+With `autoLogin`, a failure that looks recoverable is retried once automatically. Beyond that, and for any refusal the authorization server issues explicitly, AppShell stops: sending the user straight back would ask the same question, get the same answer, and loop.
+
+That makes the guard the place where a failed sign-in becomes visible. A guard that only ever renders a spinner will spin indefinitely in this case, so render the error and offer a way out:
+
+```tsx
+const AuthGate = () => {
+ const { isReady, error, login } = useAuth();
+
+ if (error) {
+ return (
+
+
Sign-in failed: {error}
+
+
+ );
+ }
+
+ return isReady ? : ;
+};
+```
+
## Integration with AppShell
The authentication provider works seamlessly with AppShell's data layer, automatically handling:
diff --git a/packages/core/src/contexts/auth-context.test.tsx b/packages/core/src/contexts/auth-context.test.tsx
index f77e69b5..f04d5ad8 100644
--- a/packages/core/src/contexts/auth-context.test.tsx
+++ b/packages/core/src/contexts/auth-context.test.tsx
@@ -23,6 +23,9 @@ afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
window.history.replaceState({}, "", "/");
+ // The failed-callback retry counter is tab-scoped and survives page loads by
+ // design, so it has to be reset between tests or it leaks across them.
+ window.sessionStorage.clear();
});
const LoadingGuard = () =>
Loading...
;
@@ -955,6 +958,205 @@ describe("createAuthClient", () => {
expect(mockHandleCallback).toHaveBeenCalledTimes(1);
});
+ describe("failed callback recovery", () => {
+ const renderWithClient = (
+ baseOverrides: Record,
+ handleCallback: ReturnType,
+ ) => {
+ vi.mocked(createAuthClientMock).mockReturnValue({
+ ...makeBaseClient(handleCallback),
+ ...baseOverrides,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ return createAuthClient({ clientId: "test", appUri: "https://test.com" });
+ };
+
+ // A client whose callback has already settled, so the gate must consult the
+ // status rather than the (still dirty) URL.
+ const createSettledCallbackClient = (
+ status: "resolved" | "rejected",
+ overrides: Record,
+ ) =>
+ ({
+ ...makeBaseClient(vi.fn()),
+ getAppUri: vi.fn(() => "https://api.test.com"),
+ getCallbackStatusSnapshot: () => status,
+ subscribeCallbackStatus: () => () => {},
+ ...overrides,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any;
+
+ const readyUnauthenticated = {
+ isAuthenticated: false,
+ error: null as string | null,
+ isReady: true,
+ };
+
+ it("resumes auto-login after a failed code exchange", async () => {
+ // The dead end this guards: before the parameters were cleared, `?code=`
+ // stayed in the URL, attemptAutoLogin treated the page as a live callback
+ // forever, and the app sat on guardComponent with no way back — a reload
+ // just replayed the same failing callback.
+ vi.stubGlobal("console", { ...console, error: vi.fn() });
+ window.history.replaceState({}, "", "/?code=auth-code-123&state=xyz");
+
+ const mockLogin = vi.fn().mockResolvedValue(undefined);
+ const client = renderWithClient(
+ { getState: vi.fn(() => readyUnauthenticated), login: mockLogin },
+ vi.fn().mockRejectedValue(new Error("Missing session data")),
+ );
+
+ render(
+
+
Content
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("stops re-initiating login once the callback retry budget is spent", async () => {
+ // Every retry is a full-page redirect to the authorization server, so a
+ // deterministic failure (evicted storage, misconfigured client) would loop
+ // forever without a ceiling.
+ vi.stubGlobal("console", { ...console, error: vi.fn() });
+ window.sessionStorage.setItem("tailor-app-shell:oauth-callback-failures", "1");
+ window.history.replaceState({}, "", "/?code=auth-code-123&state=xyz");
+
+ const mockLogin = vi.fn().mockResolvedValue(undefined);
+ const client = renderWithClient(
+ { getState: vi.fn(() => readyUnauthenticated), login: mockLogin },
+ vi.fn().mockRejectedValue(new Error("Missing session data")),
+ );
+
+ render(
+
+
Content
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockLogin).not.toHaveBeenCalled();
+ });
+
+ it("treats a denial that throws as denied rather than retryable", async () => {
+ // The server's `error` branch clears OAuth temp data first, which opens
+ // IndexedDB and can reject. The denial must not be reclassified as a
+ // retryable failure just because it arrived as a throw.
+ vi.stubGlobal("console", { ...console, error: vi.fn() });
+ window.history.replaceState({}, "", "/?error=access_denied");
+
+ const mockLogin = vi.fn().mockResolvedValue(undefined);
+ const client = renderWithClient(
+ { getState: vi.fn(() => readyUnauthenticated), login: mockLogin },
+ vi.fn().mockRejectedValue(new Error("storage unavailable")),
+ );
+
+ render(
+
+
Content
+ ,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockLogin).not.toHaveBeenCalled();
+ // Nothing was spent from the retry budget: this outcome is terminal.
+ expect(window.sessionStorage.getItem("tailor-app-shell:oauth-callback-failures")).toBeNull();
+ });
+
+ it("clears the retry budget once a callback succeeds", async () => {
+ window.sessionStorage.setItem("tailor-app-shell:oauth-callback-failures", "1");
+ window.history.replaceState({}, "", "/?code=auth-code-123&state=xyz");
+
+ const authenticated = { isAuthenticated: true, error: null, isReady: true };
+ renderWithClient(
+ { getState: vi.fn(() => authenticated) },
+ vi.fn().mockResolvedValue(undefined),
+ );
+
+ await waitFor(() => {
+ expect(
+ window.sessionStorage.getItem("tailor-app-shell:oauth-callback-failures"),
+ ).toBeNull();
+ });
+ });
+
+ it("lets a settled callback drive auto-login even if parameters remain in the URL", async () => {
+ // Isolates the narrowing in the auto-login gate: the URL is only an
+ // authority while the status is "idle". Once a callback has run, its
+ // status decides — reading the URL there is what stranded the app, since
+ // a failed callback used to leave its parameters in place permanently.
+ window.history.replaceState({}, "", "/?code=auth-code-123&state=xyz");
+
+ const mockLogin = vi.fn().mockResolvedValue(undefined);
+ const settledState = { isAuthenticated: false, error: "boom", isReady: true };
+ const mockClient = createSettledCallbackClient("rejected", {
+ login: mockLogin,
+ getState: vi.fn(() => settledState),
+ });
+
+ render(
+
+
Content
+ ,
+ );
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("does not re-initiate login after the authorization server denies the callback", async () => {
+ // Recovering from a denial by redirecting straight back to the same
+ // authorization server would ask the same question and get the same
+ // answer — a redirect loop, which is worse than the dead end it replaced.
+ window.history.replaceState({}, "", "/?error=access_denied");
+
+ const mockLogin = vi.fn().mockResolvedValue(undefined);
+ // Stable reference: useSyncExternalStore loops if getSnapshot returns a
+ // fresh object each call.
+ const deniedState = { ...readyUnauthenticated, error: "access_denied" };
+ const client = renderWithClient(
+ { getState: vi.fn(() => deniedState), login: mockLogin },
+ vi.fn().mockResolvedValue(undefined),
+ );
+
+ render(
+
+
Content
+ ,
+ );
+
+ // Let the callback settle and every queued auto-login check run.
+ await act(async () => {
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockLogin).not.toHaveBeenCalled();
+ // The parameters stay in the URL until upstream cleans failed callbacks
+ // too (auth-public-client#139) — correctness here rests on the status.
+ expect(window.location.search).toBe("?error=access_denied");
+ });
+ });
+
it("does not call handleCallback when URL has no OAuth parameters", () => {
// URL is already "/" from afterEach reset
diff --git a/packages/core/src/contexts/auth-context.tsx b/packages/core/src/contexts/auth-context.tsx
index 2cb86b7d..52f5514e 100644
--- a/packages/core/src/contexts/auth-context.tsx
+++ b/packages/core/src/contexts/auth-context.tsx
@@ -30,8 +30,14 @@ export interface AuthClientConfig {
/**
* Internal type for tracking OAuth callback handling status.
+ *
+ * "denied" is distinct from "rejected": it means another automatic login round
+ * trip will not help, so auto-login stays out of it. Two things land there —
+ * the authorization server refusing outright (the user declined consent, the
+ * client is not permitted), and a callback that keeps failing after its retry
+ * budget is spent. "rejected" is a failure that is still worth one more attempt.
*/
-type CallbackStatus = "idle" | "pending" | "resolved" | "rejected";
+type CallbackStatus = "idle" | "pending" | "resolved" | "rejected" | "denied";
/**
* Enhanced auth client with additional helper methods
@@ -94,6 +100,9 @@ const createCallbackStatusManager = () => {
reject: () => {
updateStatus("rejected");
},
+ deny: () => {
+ updateStatus("denied");
+ },
};
},
subscribe: (listener: () => void) => {
@@ -109,6 +118,56 @@ const createCallbackStatusManager = () => {
};
};
+/**
+ * How many times auto-login may re-run a callback that failed, per browser tab.
+ *
+ * A failed callback is worth one more attempt: the common causes (a PKCE
+ * verifier clobbered by a concurrent teardown, two tabs racing the same
+ * single-slot OAuth storage) clear on a second pass. A failure that survives
+ * the retry is treated as permanent, because every attempt is a full-page
+ * redirect to the authorization server — without a ceiling, a deterministic
+ * failure (blocked or evicted browser storage, a misconfigured client) becomes
+ * an unbounded redirect loop, which is worse than the dead end it replaced.
+ */
+const MAX_AUTOMATIC_CALLBACK_RETRIES = 1;
+
+const CALLBACK_FAILURE_COUNT_KEY = "tailor-app-shell:oauth-callback-failures";
+
+/**
+ * Read the consecutive-failure count for this tab.
+ *
+ * sessionStorage rather than memory, because each retry is a full page load and
+ * an in-memory counter resets with it. Reports failures as exhausted when
+ * storage is unreadable: without somewhere to count, the ceiling cannot be
+ * enforced, and looping is the worse failure. Such a browser cannot complete
+ * the flow anyway — the auth client needs IndexedDB.
+ */
+const readCallbackFailureCount = (): number | null => {
+ try {
+ const raw = window.sessionStorage.getItem(CALLBACK_FAILURE_COUNT_KEY);
+ const count = raw == null ? 0 : Number.parseInt(raw, 10);
+ return Number.isNaN(count) ? null : count;
+ } catch {
+ return null;
+ }
+};
+
+const recordCallbackFailure = (count: number) => {
+ try {
+ window.sessionStorage.setItem(CALLBACK_FAILURE_COUNT_KEY, String(count + 1));
+ } catch {
+ // Nothing to do: the read side already treats unreadable storage as exhausted.
+ }
+};
+
+const clearCallbackFailures = () => {
+ try {
+ window.sessionStorage.removeItem(CALLBACK_FAILURE_COUNT_KEY);
+ } catch {
+ // Best effort — a stale count only costs a retry, and it is tab-scoped.
+ }
+};
+
/**
* Create an enhanced authentication client.
*
@@ -160,13 +219,47 @@ export function createAuthClient(config: AuthClientConfig): EnhancedAuthClient {
const currentUrl = new URL(window.location.href);
if (isOAuthCallbackUrl(currentUrl)) {
- const { resolve, reject } = callbackManager.start();
+ // Read this before the callback settles: the parameters are stripped below.
+ const authServerReportedError = currentUrl.searchParams.has("error");
+ const { resolve, reject, deny } = callbackManager.start();
+
+ // The outcome is classified from the resulting auth state rather than from
+ // whether the promise resolved. Not every failure throws — an `error` from
+ // the authorization server and a state mismatch both return normally after
+ // recording the error — so resolve-vs-throw is the wrong signal, and using
+ // it would let those two skip the retry ceiling and loop.
+ //
+ // Note the URL is deliberately NOT cleaned here. auth-public-client owns
+ // callback URL cleanup and currently performs it only on success
+ // (tailor-platform/auth-public-client#139); on failure the parameters
+ // remain until that is fixed upstream. app-shell stays correct regardless,
+ // because auto-login is gated on this settled status, not on the URL.
+ const settle = () => {
+ if (baseClient.getState().isAuthenticated) {
+ clearCallbackFailures();
+ resolve();
+ return;
+ }
+
+ const failureCount = readCallbackFailureCount();
+ if (
+ authServerReportedError ||
+ failureCount === null ||
+ failureCount >= MAX_AUTOMATIC_CALLBACK_RETRIES
+ ) {
+ deny();
+ return;
+ }
+
+ recordCallbackFailure(failureCount);
+ reject();
+ };
baseClient
.handleCallback()
- .then(resolve)
+ .then(settle)
.catch((error) => {
- reject();
+ settle();
console.error("Failed to handle OAuth callback:", error);
});
}
@@ -321,7 +414,11 @@ type AuthProviderProps = {
* - initial deferred auto-login attempt
* - duplicate login prevention
*/
-const useAutoLogin = (props: { client: EnhancedAuthClient; enabled?: boolean }) => {
+const useAutoLogin = (props: {
+ client: EnhancedAuthClient;
+ enabled?: boolean;
+ callbackStatus: CallbackStatus;
+}) => {
// Prevent duplicate login redirects when multiple auth_state_changed
// events fire before the first login attempt settles.
const loginInFlightRef = useRef | null>(null);
@@ -331,7 +428,20 @@ const useAutoLogin = (props: { client: EnhancedAuthClient; enabled?: boolean })
const authState = props.client.getState();
if (
!props.enabled ||
- isCurrentOAuthCallbackUrl() ||
+ // Hold off while a code exchange is in flight, and stand down entirely
+ // once the authorization server has refused: redirecting back would ask
+ // the same question and get the same answer.
+ props.callbackStatus === "pending" ||
+ props.callbackStatus === "denied" ||
+ // Only consult the URL while no callback has been claimed. This client
+ // starts the exchange itself whenever it is constructed on a callback
+ // URL, so "idle" here means nobody is handling these parameters and
+ // redirecting is unsafe. Once a callback has run, its status is the
+ // authority — reading the URL instead is what used to strand the app:
+ // upstream cleans the URL only on success, so a failed callback leaves
+ // its parameters in place (auth-public-client#139) and a URL-based gate
+ // treats the page as a live callback forever.
+ (props.callbackStatus === "idle" && isCurrentOAuthCallbackUrl()) ||
!authState.isReady ||
authState.isAuthenticated ||
loginInFlightRef.current
@@ -348,7 +458,7 @@ const useAutoLogin = (props: { client: EnhancedAuthClient; enabled?: boolean })
.finally(() => {
loginInFlightRef.current = null;
});
- }, [props.client, props.enabled]);
+ }, [props.client, props.enabled, props.callbackStatus]);
return {
subscribeAuthState: useCallback(
@@ -459,21 +569,24 @@ const useCallbackStatus = (client: EnhancedAuthClient): CallbackStatus => {
export const AuthProvider = (props: React.PropsWithChildren) => {
const client = props.client;
+ // Read callback status first so it can be passed to both useAutoLogin and
+ // useEnsureAuthInitialized. This lets each retry automatically when a pending
+ // callback settles — the new function references trigger their effects again,
+ // which is what lets auto-login resume after a failed callback instead of
+ // being stranded by leftover parameters in the URL.
+ const callbackStatus = useCallbackStatus(client);
+
// Set up auth state subscription for auto-login orchestration
const { subscribeAuthState } = useAutoLogin({
client,
enabled: props.autoLogin,
+ callbackStatus,
});
// Use useSyncExternalStore for state management from auth client.
const getSnapshot = useCallback(() => client.getState(), [client]);
const authState = useSyncExternalStore(subscribeAuthState, getSnapshot);
- // Read callback status first so it can be passed to useEnsureAuthInitialized.
- // This lets initialization retry automatically when a pending callback settles
- // to rejected — the new ensureInitialized reference triggers the useEffect again.
- const callbackStatus = useCallbackStatus(client);
-
// Prepare a shared initialization function so AuthProvider can start the
// first auth check itself without depending on router navigation.
const ensureAuthInitialized = useEnsureAuthInitialized(client, callbackStatus);