From 9bdfb29c80171cb92c969278f0d0eea73a70462f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sat, 31 Aug 2024 17:18:39 -0700 Subject: [PATCH] Allow multiple outer OAuth logins at the same time --- examples/demo/src/app/ui/page-client.tsx | 178 +++++++++--------- packages/stack-ui/src/index.ts | 1 - .../components/elements/maybe-full-page.tsx | 1 - .../components/message-cards/message-card.tsx | 2 +- packages/stack/src/lib/auth.ts | 47 +++-- packages/stack/src/lib/cookie.ts | 14 +- 6 files changed, 127 insertions(+), 116 deletions(-) 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 (
- -
-
- - + - + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + + +
+ + + +
+
+ {text} + {text} + {text} + {text} + {text} + {text} + {text}
-
- - - -
- -
- - - -
- -
- - - - -
- - - -
-
- {text} - {text} - {text} - {text} - {text} - {text} - {text} -
- - - -
- {text} - {text} - {text} - {text} -
-
+
- link - styled link -
- -
- - - - - -
- -
-
- - -
+ {text} + {text} + {text} + {text}
- + +
+ link + styled link +
+ +
+ + + + + +
+ +
+
+ + +
+
+
); -} \ 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, }; }