mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Allow multiple outer OAuth logins at the same time
This commit is contained in:
parent
1ac8b3013a
commit
9bdfb29c80
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Container, cn } from "@stackframe/stack-ui";
|
||||
import React, { useId } from "react";
|
||||
import { SsrScript } from "./ssr-layout-effect";
|
||||
|
||||
|
||||
@ -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 }:
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user