Allow multiple outer OAuth logins at the same time

This commit is contained in:
Konstantin Wohlwend 2024-08-31 17:18:39 -07:00
parent 1ac8b3013a
commit 9bdfb29c80
6 changed files with 127 additions and 116 deletions

View File

@ -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 (
<div>
<Container size={600}>
<div style={{ display: 'flex', 'flexDirection': 'column', 'gap': 20 }}>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<Button size='sm'>
<div style={{ display: 'flex', 'flexDirection': 'column', 'gap': 20 }}>
<div style={{ display: 'flex', gap: 5, alignItems: 'center' }}>
<Button size='sm'>
Button
</Button>
<Button>
</Button>
<Button>
Button
</Button>
<Button size='lg'>
</Button>
<Button size='lg'>
Button
</Button>
</Button>
</div>
<div style={{ display: 'flex', gap: 5 }}>
<Button>
Button
</Button>
<Button variant="secondary">
Button
</Button>
<Button variant='destructive'>
Button
</Button>
</div>
<div style={{ display: 'flex', gap: 5 }}>
<Button disabled>
Button
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="destructive" disabled>
Button
</Button>
</div>
<div style={{ display: 'flex', gap: 5 }}>
<Button loading>
Button
</Button>
<Button variant="secondary" loading>
Button
</Button>
<Button loading>
Button
</Button>
<Button color='orange' loading>
Button
</Button>
</div>
<Separator />
<div style={{ display: 'flex', gap: 20}}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Typography type='h1'>{text}</Typography>
<Typography type='h2'>{text}</Typography>
<Typography type='h3'>{text}</Typography>
<Typography type='h4'>{text}</Typography>
<Typography type='p'>{text}</Typography>
<Typography type='label'>{text}</Typography>
<Typography type='footnote'>{text}</Typography>
</div>
<div style={{ display: 'flex', gap: 5 }}>
<Button>
Button
</Button>
<Button variant="secondary">
Button
</Button>
<Button variant='destructive'>
Button
</Button>
</div>
<div style={{ display: 'flex', gap: 5 }}>
<Button disabled>
Button
</Button>
<Button variant="secondary" disabled>
Button
</Button>
<Button variant="destructive" disabled>
Button
</Button>
</div>
<div style={{ display: 'flex', gap: 5 }}>
<Button loading>
Button
</Button>
<Button variant="secondary" loading>
Button
</Button>
<Button loading>
Button
</Button>
<Button color='orange' loading>
Button
</Button>
</div>
<Separator />
<div style={{ display: 'flex', gap: 20}}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Typography type='h1'>{text}</Typography>
<Typography type='h2'>{text}</Typography>
<Typography type='h3'>{text}</Typography>
<Typography type='h4'>{text}</Typography>
<Typography type='p'>{text}</Typography>
<Typography type='label'>{text}</Typography>
<Typography type='footnote'>{text}</Typography>
</div>
<Separator orientation='vertical' />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Typography variant='primary'>{text}</Typography>
<Typography variant="secondary">{text}</Typography>
<Typography variant="destructive">{text}</Typography>
<Typography variant="success">{text}</Typography>
</div>
</div>
<Separator orientation='vertical' />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Link href='/test'>link</Link>
<StyledLink href='/test'>styled link</StyledLink>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Input placeholder='text' />
<Input type='file' />
<Input type='password' placeholder="password" />
<Input type='date' />
<Input placeholder='text' disabled />
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<form>
<Label htmlFor="text">Text input label</Label>
<Input id='text' placeholder='text' />
</form>
<Typography variant='primary'>{text}</Typography>
<Typography variant="secondary">{text}</Typography>
<Typography variant="destructive">{text}</Typography>
<Typography variant="success">{text}</Typography>
</div>
</div>
</Container>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Link href='/test'>link</Link>
<StyledLink href='/test'>styled link</StyledLink>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<Input placeholder='text' />
<Input type='file' />
<Input type='password' placeholder="password" />
<Input type='date' />
<Input placeholder='text' disabled />
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 5 }}>
<form>
<Label htmlFor="text">Text input label</Label>
<Input id='text' placeholder='text' />
</form>
</div>
</div>
</div>
);
}
}

View File

@ -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";

View File

@ -1,6 +1,5 @@
"use client";
import { Container, cn } from "@stackframe/stack-ui";
import React, { useId } from "react";
import { SsrScript } from "./ssr-layout-effect";

View File

@ -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 }:

View File

@ -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) {

View File

@ -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,
};
}