From 8db0983b4ace1a6f85831d065b9077997dff6f93 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Tue, 28 Apr 2026 09:44:17 -0700 Subject: [PATCH 1/2] fix OAuth redirect contract --- apps/e2e/tests/js/oauth.test.ts | 53 ++++++++++++++++ .../src/interface/handler-urls.ts | 2 +- .../src/interface/page-component-versions.ts | 53 ++++++++++++++++ .../src/components-page/oauth-callback.tsx | 7 ++- .../components-page/stack-handler-client.tsx | 24 ++++++- packages/template/src/lib/auth.test.ts | 63 +++++++++++++++++++ packages/template/src/lib/auth.ts | 9 +-- .../apps/implementations/client-app-impl.ts | 29 +++++---- .../template/src/lib/stack-app/url-targets.ts | 9 +++ sdks/spec/src/apps/client-app.spec.md | 6 +- 10 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 packages/template/src/lib/auth.test.ts diff --git a/apps/e2e/tests/js/oauth.test.ts b/apps/e2e/tests/js/oauth.test.ts index ebd1b478bb..8c39360344 100644 --- a/apps/e2e/tests/js/oauth.test.ts +++ b/apps/e2e/tests/js/oauth.test.ts @@ -17,6 +17,7 @@ it("adds provider_scope from oauthScopesOnSignIn for authenticate flow", async ( }, { client: { + redirectMethod: "window", oauthScopesOnSignIn: { github: ["repo"], }, @@ -52,4 +53,56 @@ it("adds provider_scope from oauthScopesOnSignIn for authenticate flow", async ( expect(scope).toBe("user:email repo"); }, { timeout: 40_000 }); +it("does not resolve signInWithOAuth after a custom redirectMethod starts navigation", async ({ expect }) => { + const navigatedUrls: string[] = []; + const { clientApp } = await createApp( + { + config: { + oauthProviders: [ + { + id: "github", + type: "standard", + clientId: "test_client_id", + clientSecret: "test_client_secret", + }, + ], + }, + }, + { + client: { + redirectMethod: { + useNavigate: () => (url) => { + navigatedUrls.push(url); + }, + navigate: (url) => { + navigatedUrls.push(url); + }, + }, + }, + } + ); + + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.window = { + location: { + href: localRedirectUrl, + }, + } as any; + try { + const redirectResult = clientApp.signInWithOAuth("github").then(() => "resolved"); + const result = await Promise.race([ + redirectResult, + new Promise((resolve) => setTimeout(() => resolve("pending"), 2500)), + ]); + + expect(navigatedUrls).toHaveLength(1); + expect(new URL(navigatedUrls[0]).pathname).toBe("/login/oauth/authorize"); + expect(result).toBe("pending"); + } finally { + globalThis.window = previousWindow; + globalThis.document = previousDocument; + } +}, { timeout: 40_000 }); diff --git a/packages/stack-shared/src/interface/handler-urls.ts b/packages/stack-shared/src/interface/handler-urls.ts index cdb2e11fdf..7ad921cfb4 100644 --- a/packages/stack-shared/src/interface/handler-urls.ts +++ b/packages/stack-shared/src/interface/handler-urls.ts @@ -15,6 +15,7 @@ export type HandlerPageUrls = Record< | "magicLinkCallback" | "accountSettings" | "teamInvitation" + | "cliAuthConfirm" | "mfa" | "error" | "onboarding", @@ -45,4 +46,3 @@ export { type PageVersionEntry, type PageVersions } from "./page-component-versions"; - diff --git a/packages/stack-shared/src/interface/page-component-versions.ts b/packages/stack-shared/src/interface/page-component-versions.ts index ed9b20e184..23ba0ffa7f 100644 --- a/packages/stack-shared/src/interface/page-component-versions.ts +++ b/packages/stack-shared/src/interface/page-component-versions.ts @@ -1419,6 +1419,59 @@ export function getCustomPagePrompts(): Record; + } + + if (cliAuth.status === "success") { + return You can close this window and return to the command line.; + } + + if (cliAuth.status === "error") { + return ( + + {cliAuth.error?.message} + + ); + } + + return ( + + A command line application is requesting access to your account. + + ); + } + `, + notes: deindent` + - Be explicit about the account being authorized. CLI auth grants a refresh token to the command line application. + - The hook owns the protocol details: reading \`login_code\`, preserving confirmed state across redirects, claiming anonymous sessions, and completing authorization. + `, + versions: {}, + }), mfa: createCustomPagePrompt({ key: "mfa", title: "MFA", diff --git a/packages/template/src/components-page/oauth-callback.tsx b/packages/template/src/components-page/oauth-callback.tsx index da800b4a0d..4c3b08f751 100644 --- a/packages/template/src/components-page/oauth-callback.tsx +++ b/packages/template/src/components-page/oauth-callback.tsx @@ -13,6 +13,7 @@ import { useTranslation } from "../lib/translations"; export function OAuthCallback({ fullPage }: { fullPage?: boolean }) { const { t } = useTranslation(); const app = useStackApp(); + const navigate = app.useNavigate(); const called = useRef(false); const [showRedirectLink, setShowRedirectLink] = useState(false); @@ -30,13 +31,13 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) { errorUrl.searchParams.set("errorCode", e.errorCode); errorUrl.searchParams.set("message", e.message); errorUrl.searchParams.set("details", JSON.stringify(e.details ?? {})); - window.location.replace(errorUrl.toString()); + navigate(errorUrl.toString()); return; } captureError("", e); - window.location.replace(new URL(app.urls.error, window.location.href).toString()); + navigate(new URL(app.urls.error, window.location.href).toString()); } - }), []); + }), [app, navigate]); useEffect(() => { setTimeout(() => setShowRedirectLink(true), 3000); diff --git a/packages/template/src/components-page/stack-handler-client.tsx b/packages/template/src/components-page/stack-handler-client.tsx index d7af8fad29..346d9b163c 100644 --- a/packages/template/src/components-page/stack-handler-client.tsx +++ b/packages/template/src/components-page/stack-handler-client.tsx @@ -5,6 +5,9 @@ import { FilterUndefined, filterUndefined } from "@stackframe/stack-shared/dist/ import { getRelativePart } from "@stackframe/stack-shared/dist/utils/urls"; import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next import { useMemo } from 'react'; +/* IF_PLATFORM react +import { useEffect } from 'react'; +// END_PLATFORM */ import { SignIn, SignUp, StackServerApp } from ".."; import { useStackApp } from "../lib/hooks"; import { HandlerUrls, StackClientApp, stackAppInternalsSymbol } from "../lib/stack-app"; @@ -229,8 +232,10 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial const currentLocation = pathname; const searchParamsSource = searchParamsFromHook; /* ELSE_IF_PLATFORM react + const navigate = stackApp.useNavigate(); const currentLocation = props.location ?? window.location.pathname; const searchParamsSource = new URLSearchParams(window.location.search); + const redirectTargets: string[] = []; END_PLATFORM */ const { path, searchParams, handlerPath } = useMemo(() => { @@ -277,7 +282,7 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial // IF_PLATFORM next redirect(toAbsoluteOrRelativeRedirectTarget(urlObj), RedirectType.replace); /* ELSE_IF_PLATFORM react - window.location.href = toAbsoluteOrRelativeRedirectTarget(urlObj); + redirectTargets.push(toAbsoluteOrRelativeRedirectTarget(urlObj)); END_PLATFORM */ }; @@ -311,11 +316,24 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial // IF_PLATFORM next redirect(result.redirect, RedirectType.replace); /* ELSE_IF_PLATFORM react - window.location.href = result.redirect; - return null; + redirectTargets.push(result.redirect); END_PLATFORM */ } + /* IF_PLATFORM react + const redirectTarget = redirectTargets[0]; + useEffect(() => { + if (redirectTarget == null) { + return; + } + navigate(redirectTarget); + }, [navigate, redirectTarget]); + + if (redirectTarget != null) { + return null; + } + END_PLATFORM */ + return result; } diff --git a/packages/template/src/lib/auth.test.ts b/packages/template/src/lib/auth.test.ts new file mode 100644 index 0000000000..713c36529e --- /dev/null +++ b/packages/template/src/lib/auth.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { StackClientInterface } from "@stackframe/stack-shared"; +import { describe, expect, it, vi } from "vitest"; +import { getNewOAuthProviderOrScopeUrl } from "./auth"; + +vi.mock("./cookie", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + saveVerifierAndState: async () => ({ + codeChallenge: "", + state: "", + }), + }; +}); + +describe("getNewOAuthProviderOrScopeUrl", () => { + it("returns the OAuth URL without performing navigation", async () => { + window.history.replaceState({}, "", "/account?after_auth_return_to=%2Fsettings"); + + const iface = new StackClientInterface({ + clientVersion: "test", + getBaseUrl: () => "https://api.example.com", + getApiUrls: () => ["https://api.example.com"], + extraRequestHeaders: {}, + projectId: "00000000-0000-4000-8000-000000000000", + publishableClientKey: "pck_test", + }); + const session = iface.createSession({ refreshToken: null, accessToken: null }); + + const location = await getNewOAuthProviderOrScopeUrl( + iface, + { + provider: "github", + redirectUrl: "/handler/oauth-callback", + errorRedirectUrl: "/handler/error", + providerScope: "repo user", + }, + session, + ); + + const url = new URL(location); + expect(`${url.origin}${url.pathname}`).toBe("https://api.example.com/api/v1/auth/oauth/authorize/github"); + expect(Object.fromEntries(url.searchParams.entries())).toMatchInlineSnapshot(` + { + "after_callback_redirect_url": "http://localhost:3000/account?after_auth_return_to=%2Fsettings", + "client_id": "00000000-0000-4000-8000-000000000000", + "client_secret": "pck_test", + "code_challenge": "", + "code_challenge_method": "S256", + "error_redirect_url": "http://localhost:3000/handler/error?after_auth_return_to=%2Fsettings", + "grant_type": "authorization_code", + "provider_scope": "repo user", + "redirect_uri": "http://localhost:3000/handler/oauth-callback?after_auth_return_to=%2Fsettings", + "response_type": "code", + "scope": "legacy", + "state": "", + "type": "link", + } + `); + }); +}); diff --git a/packages/template/src/lib/auth.ts b/packages/template/src/lib/auth.ts index 2a986af6ed..a695b4ad4f 100644 --- a/packages/template/src/lib/auth.ts +++ b/packages/template/src/lib/auth.ts @@ -1,12 +1,11 @@ import { KnownError, StackClientInterface } from "@stackframe/stack-shared"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; -import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { deindent } from "@stackframe/stack-shared/dist/utils/strings"; import { constructRedirectUrl } from "../utils/url"; import { consumeVerifierAndStateCookie, saveVerifierAndState } from "./cookie"; -export async function addNewOAuthProviderOrScope( +export async function getNewOAuthProviderOrScopeUrl( iface: StackClientInterface, options: { provider: string, @@ -15,9 +14,9 @@ export async function addNewOAuthProviderOrScope( providerScope?: string, }, session: InternalSession, -) { +): Promise { const { codeChallenge, state } = await saveVerifierAndState(); - const location = await iface.getOAuthUrl({ + return await iface.getOAuthUrl({ provider: options.provider, redirectUrl: constructRedirectUrl(options.redirectUrl, "redirectUrl"), errorRedirectUrl: constructRedirectUrl(options.errorRedirectUrl, "errorRedirectUrl"), @@ -28,8 +27,6 @@ export async function addNewOAuthProviderOrScope( session, providerScope: options.providerScope, }); - window.location.assign(location); - await neverResolve(); } /** diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 4422a824c2..9d102bc95a 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -41,7 +41,7 @@ import * as NextNavigationUnscrambled from "next/navigation"; // import the enti import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react-like import type * as yup from "yup"; import { constructRedirectUrl } from "../../../../utils/url"; -import { addNewOAuthProviderOrScope, callOAuthCallback } from "../../../auth"; +import { getNewOAuthProviderOrScopeUrl, callOAuthCallback } from "../../../auth"; import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; import { envVars } from "../../../env"; import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys"; @@ -220,7 +220,7 @@ export class _StackClientAppImplIncomplete> { @@ -2795,16 +2798,20 @@ export class _StackClientAppImplIncomplete { return await this._interface.authorizeOAuth({ provider, - redirectUrl: constructRedirectUrl(options?.returnTo ?? this.urls.oauthCallback, "redirectUrl"), + redirectUrl: constructRedirectUrl(this.urls.oauthCallback, "redirectUrl"), errorRedirectUrl: constructRedirectUrl(this.urls.error, "errorRedirectUrl"), afterCallbackRedirectUrl, type: "authenticate", @@ -2843,7 +2850,7 @@ export class _StackClientAppImplIncomplete Date: Tue, 28 Apr 2026 10:20:38 -0700 Subject: [PATCH 2/2] Enhance OAuth callback handling and redirect logic - Updated the OAuthCallback component to manage redirects more effectively by introducing a new `redirectToError` function, which handles error redirects based on the current redirect method. - Adjusted the timeout duration in the OAuth test to ensure proper handling of asynchronous operations. - Improved the StackHandlerClient to utilize a reference for navigation, allowing for more reliable redirect handling based on the current redirect method. These changes aim to streamline the OAuth flow and improve error handling during redirects. --- apps/e2e/tests/js/oauth.test.ts | 2 +- .../src/components-page/oauth-callback.tsx | 19 ++++++++++---- .../components-page/stack-handler-client.tsx | 26 +++++++++++++++---- .../apps/implementations/client-app-impl.ts | 4 +++ .../stack-app/apps/interfaces/client-app.ts | 2 ++ 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/e2e/tests/js/oauth.test.ts b/apps/e2e/tests/js/oauth.test.ts index 8c39360344..d2d55272f9 100644 --- a/apps/e2e/tests/js/oauth.test.ts +++ b/apps/e2e/tests/js/oauth.test.ts @@ -95,7 +95,7 @@ it("does not resolve signInWithOAuth after a custom redirectMethod starts naviga const redirectResult = clientApp.signInWithOAuth("github").then(() => "resolved"); const result = await Promise.race([ redirectResult, - new Promise((resolve) => setTimeout(() => resolve("pending"), 2500)), + new Promise((resolve) => setTimeout(() => resolve("pending"), 5000)), ]); expect(navigatedUrls).toHaveLength(1); diff --git a/packages/template/src/components-page/oauth-callback.tsx b/packages/template/src/components-page/oauth-callback.tsx index 4c3b08f751..8310b033f6 100644 --- a/packages/template/src/components-page/oauth-callback.tsx +++ b/packages/template/src/components-page/oauth-callback.tsx @@ -8,18 +8,27 @@ import { useEffect, useRef, useState } from "react"; import { useStackApp } from ".."; import { MaybeFullPage } from "../components/elements/maybe-full-page"; import { StyledLink } from "../components/link"; +import { stackAppInternalsSymbol } from "../lib/stack-app"; import { useTranslation } from "../lib/translations"; export function OAuthCallback({ fullPage }: { fullPage?: boolean }) { const { t } = useTranslation(); const app = useStackApp(); - const navigate = app.useNavigate(); const called = useRef(false); const [showRedirectLink, setShowRedirectLink] = useState(false); + const [redirectUrl, setRedirectUrl] = useState(null); useEffect(() => runAsynchronously(async () => { if (called.current) return; called.current = true; + const redirectToError = async (url: URL) => { + const urlString = url.toString(); + if (app[stackAppInternalsSymbol].getRedirectMethod() === "none") { + setRedirectUrl(urlString); + return; + } + await app[stackAppInternalsSymbol].redirectToUrl(urlString, { replace: true }); + }; try { const hasRedirected = await app.callOAuthCallback(); if (!hasRedirected) { @@ -31,13 +40,13 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) { errorUrl.searchParams.set("errorCode", e.errorCode); errorUrl.searchParams.set("message", e.message); errorUrl.searchParams.set("details", JSON.stringify(e.details ?? {})); - navigate(errorUrl.toString()); + await redirectToError(errorUrl); return; } captureError("", e); - navigate(new URL(app.urls.error, window.location.href).toString()); + await redirectToError(new URL(app.urls.error, window.location.href)); } - }), [app, navigate]); + }), [app]); useEffect(() => { setTimeout(() => setShowRedirectLink(true), 3000); @@ -57,7 +66,7 @@ export function OAuthCallback({ fullPage }: { fullPage?: boolean }) {
- {showRedirectLink ?

{t('If you are not redirected automatically, ')}{t("click here")}

: null} + {showRedirectLink || redirectUrl != null ?

{t('If you are not redirected automatically, ')}{t("click here")}

: null} ); diff --git a/packages/template/src/components-page/stack-handler-client.tsx b/packages/template/src/components-page/stack-handler-client.tsx index 346d9b163c..99a9fe83b3 100644 --- a/packages/template/src/components-page/stack-handler-client.tsx +++ b/packages/template/src/components-page/stack-handler-client.tsx @@ -6,7 +6,7 @@ import { getRelativePart } from "@stackframe/stack-shared/dist/utils/urls"; import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next import { useMemo } from 'react'; /* IF_PLATFORM react -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; // END_PLATFORM */ import { SignIn, SignUp, StackServerApp } from ".."; import { useStackApp } from "../lib/hooks"; @@ -233,9 +233,11 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial const searchParamsSource = searchParamsFromHook; /* ELSE_IF_PLATFORM react const navigate = stackApp.useNavigate(); + const navigateRef = useRef(navigate); + navigateRef.current = navigate; const currentLocation = props.location ?? window.location.pathname; const searchParamsSource = new URLSearchParams(window.location.search); - const redirectTargets: string[] = []; + const redirectTargets: (string | undefined)[] = []; END_PLATFORM */ const { path, searchParams, handlerPath } = useMemo(() => { @@ -322,12 +324,26 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial /* IF_PLATFORM react const redirectTarget = redirectTargets[0]; + const shouldRenderRedirectFallback = redirectTarget != null && stackApp[stackAppInternalsSymbol].getRedirectMethod() === "none"; useEffect(() => { - if (redirectTarget == null) { + if (redirectTarget == null || shouldRenderRedirectFallback) { return; } - navigate(redirectTarget); - }, [navigate, redirectTarget]); + navigateRef.current(redirectTarget); + }, [redirectTarget, shouldRenderRedirectFallback]); + + if (redirectTarget != null && shouldRenderRedirectFallback) { + return ( + window.location.assign(redirectTarget)} + > + Continue to the next page. + + ); + } if (redirectTarget != null) { return null; diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 9d102bc95a..f380853bea 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -3477,6 +3477,10 @@ export class _StackClientAppImplIncomplete { return await this._interface.sendClientRequest(path, requestOptions, await this._getSession(), requestType); }, + getRedirectMethod: () => this._redirectMethod ?? throwErr("Redirect method should have been initialized in the Stack client app constructor"), + redirectToUrl: async (url: string | URL, options?: { replace?: boolean }) => { + await this._redirectTo({ url, ...options }); + }, refreshOwnedProjects: async () => { await this._refreshOwnedProjects(await this._getSession()); }, diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 6a6d20f2a6..10d8e6a67a 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -118,6 +118,8 @@ export type StackClientApp>, addRequestListener(listener: RequestListener): () => void, sendRequest(path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin"): Promise, + getRedirectMethod(): RedirectMethod, + redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise, signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise, }, }