diff --git a/examples/demo/src/app/ui/page-client.tsx b/examples/demo/src/app/ui/page-client.tsx
index 9e5fa36d5..a0d46998f 100644
--- a/examples/demo/src/app/ui/page-client.tsx
+++ b/examples/demo/src/app/ui/page-client.tsx
@@ -1,6 +1,6 @@
'use client';
-import { Button, Container, Separator, Input, Label, Link, Typography, StyledLink } from '@stackframe/stack-ui';
+import { Button, Separator, Input, Label, Link, Typography, StyledLink } from '@stackframe/stack-ui';
const text = "This is a test sentence. ";
@@ -8,103 +8,101 @@ export default function PageClient() {
return (
-
-
-
-
);
-}
\ No newline at end of file
+}
diff --git a/packages/stack-ui/src/index.ts b/packages/stack-ui/src/index.ts
index 2c009701b..c09c3f1dd 100644
--- a/packages/stack-ui/src/index.ts
+++ b/packages/stack-ui/src/index.ts
@@ -18,7 +18,6 @@ export * from "./components/ui/card";
export * from "./components/ui/checkbox";
export * from "./components/ui/collapsible";
export * from "./components/ui/command";
-export * from "./components/ui/container";
export * from "./components/ui/context-menu";
export * from "./components/ui/dialog";
export * from "./components/ui/dropdown-menu";
diff --git a/packages/stack/src/components/elements/maybe-full-page.tsx b/packages/stack/src/components/elements/maybe-full-page.tsx
index ac75275cd..f97ebb8ad 100644
--- a/packages/stack/src/components/elements/maybe-full-page.tsx
+++ b/packages/stack/src/components/elements/maybe-full-page.tsx
@@ -1,6 +1,5 @@
"use client";
-import { Container, cn } from "@stackframe/stack-ui";
import React, { useId } from "react";
import { SsrScript } from "./ssr-layout-effect";
diff --git a/packages/stack/src/components/message-cards/message-card.tsx b/packages/stack/src/components/message-cards/message-card.tsx
index 31b3419a8..ed3be4a5a 100644
--- a/packages/stack/src/components/message-cards/message-card.tsx
+++ b/packages/stack/src/components/message-cards/message-card.tsx
@@ -2,7 +2,7 @@
import React from "react";
import { MaybeFullPage } from "../elements/maybe-full-page";
-import { Button, Container, Typography } from "@stackframe/stack-ui";
+import { Button, Typography } from "@stackframe/stack-ui";
export function MessageCard(
{ fullPage=false, ...props }:
diff --git a/packages/stack/src/lib/auth.ts b/packages/stack/src/lib/auth.ts
index 9e76195d5..a0468b03a 100644
--- a/packages/stack/src/lib/auth.ts
+++ b/packages/stack/src/lib/auth.ts
@@ -1,9 +1,10 @@
import { KnownError, StackClientInterface } from "@stackframe/stack-shared";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
-import { StackAssertionError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
+import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises";
+import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { constructRedirectUrl } from "../utils/url";
-import { getVerifierAndState, saveVerifierAndState } from "./cookie";
+import { consumeVerifierAndStateCookie, saveVerifierAndState } from "./cookie";
export async function signInWithOAuth(
iface: StackClientInterface,
@@ -59,7 +60,7 @@ export async function addNewOAuthProviderOrScope(
*
* Must be synchronous for the logic in callOAuthCallback to work without race conditions.
*/
-function consumeOAuthCallbackQueryParams(expectedState: string): null | URL {
+function consumeOAuthCallbackQueryParams() {
const requiredParams = ["code", "state"];
const originalUrl = new URL(window.location.href);
for (const param of requiredParams) {
@@ -69,10 +70,21 @@ function consumeOAuthCallbackQueryParams(expectedState: string): null | URL {
}
}
- if (expectedState !== originalUrl.searchParams.get("state")) {
- // If the state doesn't match, then the callback wasn't meant for us.
+ const expectedState = originalUrl.searchParams.get("state") ?? throwErr("This should never happen; isn't state required above?");
+ const cookieResult = consumeVerifierAndStateCookie(expectedState);
+
+ if (!cookieResult) {
+ // If the state can't be found in the cookies, then the callback wasn't meant for us.
// Maybe the website uses another OAuth library?
- captureError("consumeOAuthCallbackQueryParams", new Error(`Invalid OAuth callback state: Are you using another OAuth authentication with the same callback URL as Stack, or did your cookies reset?`));
+ captureError("consumeOAuthCallbackQueryParams", new Error(deindent`
+ Stack found an outer OAuth callback state in the query parameters, but not in cookies.
+
+ This could have multiple reasons:
+ - The cookie expired, because the OAuth flow took too long.
+ - The user's browser deleted the cookie, either manually or because of a very strict cookie policy.
+ - The cookie was already consumed by this page, and the user already logged in.
+ - You are using another OAuth client library with the same callback URL as Stack.
+ `));
return null;
}
@@ -90,7 +102,11 @@ function consumeOAuthCallbackQueryParams(expectedState: string): null | URL {
// prevent an unnecessary reload
window.history.replaceState({}, "", newUrl.toString());
- return originalUrl;
+ return {
+ originalUrl,
+ codeVerifier: cookieResult.codeVerifier,
+ state: expectedState,
+ };
}
export async function callOAuthCallback(
@@ -100,21 +116,18 @@ export async function callOAuthCallback(
// note: this part of the function (until the return) needs
// to be synchronous, to prevent race conditions when
// callOAuthCallback is called multiple times in parallel
- const { codeVerifier, state } = getVerifierAndState();
- if (!codeVerifier || !state) {
- throw new Error("Invalid OAuth callback URL parameters. It seems like the OAuth flow was interrupted, so please try again.");
- }
- const originalUrl = consumeOAuthCallbackQueryParams(state);
- if (!originalUrl) return null;
+ const consumed = consumeOAuthCallbackQueryParams();
+ if (!consumed) return null;
// the rest can be asynchronous (we now know that we are the
- // intended recipient of the callback)
+ // intended recipient of the callback, and the only instance
+ // of callOAuthCallback that's running)
try {
return await iface.callOAuthCallback({
- oauthParams: originalUrl.searchParams,
+ oauthParams: consumed.originalUrl.searchParams,
redirectUri: constructRedirectUrl(redirectUrl),
- codeVerifier,
- state,
+ codeVerifier: consumed.codeVerifier,
+ state: consumed.state,
});
} catch (e) {
if (e instanceof KnownError) {
diff --git a/packages/stack/src/lib/cookie.ts b/packages/stack/src/lib/cookie.ts
index 81883b217..a13d3a918 100644
--- a/packages/stack/src/lib/cookie.ts
+++ b/packages/stack/src/lib/cookie.ts
@@ -68,8 +68,7 @@ export async function saveVerifierAndState() {
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const state = generateRandomState();
- setCookie("stack-outer-code-verifier", codeVerifier, { maxAge: 60 * 10 });
- setCookie("stack-outer-state", state, { maxAge: 60 * 10 });
+ setCookie("stack-oauth-outer-" + state, codeVerifier, { maxAge: 60 * 60 });
return {
codeChallenge,
@@ -77,11 +76,14 @@ export async function saveVerifierAndState() {
};
}
-export function getVerifierAndState() {
- const codeVerifier = getCookie("stack-outer-code-verifier");
- const state = getCookie("stack-outer-state");
+export function consumeVerifierAndStateCookie(state: string) {
+ const cookieName = "stack-oauth-outer-" + state;
+ const codeVerifier = getCookie(cookieName);
+ if (!codeVerifier) {
+ return null;
+ }
+ deleteCookie(cookieName);
return {
codeVerifier,
- state,
};
}