Fix USER_AUTHENTICATION_REQUIRED error on signout

This commit is contained in:
Konstantin Wohlwend 2024-12-11 14:46:17 -08:00
parent 8638cf78ad
commit 6bf2c2b33d
5 changed files with 42 additions and 14 deletions

View File

@ -3,9 +3,9 @@
import { ProjectCard } from "@/components/project-card";
import { useRouter } from "@/components/router";
import { SearchBar } from "@/components/search-bar";
import { Button, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@stackframe/stack-ui";
import { useUser } from "@stackframe/stack";
import { wait } from "@stackframe/stack-shared/dist/utils/promises";
import { Button, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@stackframe/stack-ui";
import { useEffect, useMemo, useState } from "react";

View File

@ -2,19 +2,16 @@ import { stackServerApp } from "@/stack";
import { redirect } from "next/navigation";
import Footer from "./footer";
import PageClient from "./page-client";
import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises";
export const metadata = {
title: "Projects",
};
export default async function Page() {
const user = await stackServerApp.getUser();
if (user) {
const projects = await user.listOwnedProjects();
if (projects.length === 0) {
redirect("/new-project");
}
const user = await stackServerApp.getUser({ or: "redirect" });
const projects = await user.listOwnedProjects();
if (projects.length === 0) {
redirect("/new-project");
}
return (

View File

@ -43,9 +43,12 @@ export class InternalSession {
private readonly _refreshToken: RefreshToken | null;
/**
* Whether the session as a whole is known to be invalid. Used as a cache to avoid making multiple requests to the server (sessions never go back to being valid after being invalidated).
* Whether the session as a whole is known to be invalid (ie. both access and refresh tokens are invalid). Used as a cache to avoid making multiple requests to the server (sessions never go back to being valid after being invalidated).
*
* Applies to both the access token and the refresh token (it is possible for the access token to be invalid but the refresh token to be valid, in which case the session is still valid).
* It is possible for the access token to be invalid but the refresh token to be valid, in which case the session is
* still valid (just needs a refresh). It is also possible for the access token to be valid but the refresh token to
* be invalid, in which case the session is also valid (eg. if the refresh token is null because the user only passed
* in an access token, eg. in a server-side request handler).
*/
private _knownToBeInvalid = new Store<boolean>(false);
@ -58,6 +61,10 @@ export class InternalSession {
}) {
this._accessToken = new Store(_options.accessToken ? new AccessToken(_options.accessToken) : null);
this._refreshToken = _options.refreshToken ? new RefreshToken(_options.refreshToken) : null;
if (_options.accessToken === null && _options.refreshToken === null) {
// this session is already invalid
this._knownToBeInvalid.set(true);
}
this.sessionKey = InternalSession.calculateSessionKey({ accessToken: _options.accessToken ?? null, refreshToken: _options.refreshToken });
}

View File

@ -24,11 +24,11 @@ function removeStacktraceNameLine(stack: string): string {
/**
* Concatenates the stacktraces of the given errors onto the first.
* Concatenates the (original) stacktraces of the given errors onto the first.
*
* Useful when you invoke an async function to receive a promise without awaiting it immediately. Browsers are smart
* enough to keep track of the call stack in async function calls when you invoke `.then` within the same async tick,
* but if you don't,
* but if you don't, the stacktrace will be lost.
*
* Here's an example of the unwanted behavior:
*

View File

@ -232,8 +232,9 @@ function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, Result<T>>, depe
const result = React.use(promise);
if (result.status === "error") {
const error = result.error;
if (error instanceof Error) {
if (error instanceof Error && !(error as any).__stackHasConcatenatedStacktraces) {
concatStacktraces(error, new Error());
(error as any).__stackHasConcatenatedStacktraces = true;
}
throw error;
}
@ -289,6 +290,25 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
if (this.__DEMO_ENABLE_SLIGHT_FETCH_DELAY) {
await wait(2000);
}
if (session.isKnownToBeInvalid()) {
// let's save ourselves a network request
//
// this also makes a certain race condition less likely to happen. particularly, it's quite common for code to
// look like this:
//
// const user = await useUser({ or: "required" });
// const something = user.useSomething();
//
// now, let's say the session is invalidated. this will trigger a refresh to refresh both the user and the
// something. however, it's not guaranteed that the user will return first, so useUser might still return a
// user object while the something request has already completed (and failed, because the session is invalid).
// by returning null quickly here without a request, it is very very likely for the user request to complete
// first.
//
// TODO HACK: the above is a bit of a hack, and we should probably think of more consistent ways to handle this.
// it also only works for the user endpoint, and only if the session is known to be invalid.
return null;
}
return await this._interface.getClientUserByToken(session);
});
private readonly _currentProjectCache = createCache(async () => {
@ -1617,7 +1637,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
toClientJson: (): StackClientAppJson<HasTokenStore, ProjectId> => {
if (!("publishableClientKey" in this._interface.options)) {
// TODO find a way to do this
throw Error("Cannot serialize to JSON from an application without a publishable client key");
throw new StackAssertionError("Cannot serialize to JSON from an application without a publishable client key");
}
return {
@ -1652,6 +1672,10 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
// TODO override the client user cache to use the server user cache, so we save some requests
private readonly _currentServerUserCache = createCacheBySession(async (session) => {
if (session.isKnownToBeInvalid()) {
// see comment in _currentUserCache for more details on why we do this
return null;
}
return await this._interface.getServerUserByToken(session);
});
private readonly _serverUsersCache = createCache<[