Refactor TokenStore into Session

This commit is contained in:
Stan Wohlwend 2024-06-06 12:30:17 +02:00
parent 5b3db1a7ad
commit d95696ee96
25 changed files with 751 additions and 383 deletions

View File

@ -31,6 +31,7 @@ module.exports = {
multilineDetection: "brackets",
},
],
"@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }],
"no-restricted-syntax": [
"error",
{

View File

@ -1,7 +1,7 @@
"use client";
import React from "react";
import { useStackApp, useUser } from "@stackframe/stack";
import { useUser } from "@stackframe/stack";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { cacheFunction } from "@stackframe/stack-shared/dist/utils/caches";
import { CurrentUser, StackAdminApp } from "@stackframe/stack";
@ -16,8 +16,7 @@ const createAdminApp = cacheFunction((baseUrl: string, projectId: string, userId
baseUrl,
projectId,
tokenStore: null,
projectOwnerTokens: usersMap.get(userId)!.tokenStore,
refreshProjectOwnerTokens: async () => await usersMap.get(userId)!.refreshAccessToken(),
projectOwnerSession: usersMap.get(userId)!.session,
});
});

View File

@ -2,15 +2,23 @@
import { useEffect } from "react";
import { useToast } from "./ui/use-toast";
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
const neverNotify = [
"Failed to fetch RSC payload",
"[Fast Refresh] performing full",
];
const callbacks: ((prop: string, args: any[]) => void)[] = [];
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === 'development' && isBrowserLike()) {
for (const prop of ["warn", "error"] as const) {
const original = console[prop];
console[prop] = (...args) => {
original(...args, new Error("This error was caught by DevErrorNotifier, and the original stacktrace is below."));
callbacks.forEach((cb) => cb(prop, args));
if (!neverNotify.some((msg) => args.some((arg) => `${arg}`.includes(msg)))) {
callbacks.forEach((cb) => cb(prop, args));
}
};
}
}

View File

@ -1,3 +1,4 @@
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
import { useEffect, useLayoutEffect, useRef } from "react";
export function useAnimationFrame(callback: FrameRequestCallback) {
@ -6,7 +7,7 @@ export function useAnimationFrame(callback: FrameRequestCallback) {
useLayoutEffect(() => {
// check if we're in a browser environment
if (typeof window === "undefined") return () => {};
if (!isBrowserLike()) return () => {};
let handle = -1;
const newCallback: FrameRequestCallback = (...args) => {

View File

@ -48,7 +48,7 @@ async function validate<T>(obj: unknown, schema: yup.Schema<T>, req: NextRequest
if (error instanceof yup.ValidationError) {
throw new KnownErrors.SchemaError(
deindent`
Request validation failed on ${req.nextUrl.pathname}:
Request validation failed on ${req.method} ${req.nextUrl.pathname}:
${(error.inner.length ? error.inner : [error]).map(e => deindent`
- ${e.message}
`).join("\n")}

View File

@ -1,5 +1,6 @@
import { ServerAuthApplicationOptions, StackServerInterface } from "./serverInterface";
import { EmailConfigJson, ProjectJson, ReadonlyTokenStore, SharedProvider, StandardProvider, TokenStore } from "./clientInterface";
import { EmailConfigJson, ProjectJson, SharedProvider, StandardProvider } from "./clientInterface";
import { Session } from "../sessions";
export type AdminAuthApplicationOptions = Readonly<
ServerAuthApplicationOptions &
@ -8,7 +9,7 @@ export type AdminAuthApplicationOptions = Readonly<
superSecretAdminKey: string,
}
| {
projectOwnerTokens: ReadonlyTokenStore,
projectOwnerSession: Session,
}
)
>
@ -85,7 +86,7 @@ export class StackAdminInterface extends StackServerInterface {
super(options);
}
protected async sendAdminRequest(path: string, options: RequestInit, tokenStore: TokenStore | null, requestType: "admin" = "admin") {
protected async sendAdminRequest(path: string, options: RequestInit, session: Session | null, requestType: "admin" = "admin") {
return await this.sendServerRequest(
path,
{
@ -95,7 +96,7 @@ export class StackAdminInterface extends StackServerInterface {
...options.headers,
},
},
tokenStore,
session,
requestType,
);
}
@ -168,8 +169,8 @@ export class StackAdminInterface extends StackServerInterface {
);
}
async getApiKeySet(id: string, tokenStore: TokenStore): Promise<ApiKeySetJson> {
const response = await this.sendAdminRequest(`/api-keys/${id}`, {}, tokenStore);
async getApiKeySet(id: string, session: Session): Promise<ApiKeySetJson> {
const response = await this.sendAdminRequest(`/api-keys/${id}`, {}, session);
return await response.json();
}
}

View File

@ -4,10 +4,13 @@ import { Result } from "../utils/results";
import { ReadonlyJson } from '../utils/json';
import { AsyncStore, ReadonlyAsyncStore } from '../utils/stores';
import { KnownError, KnownErrors } from '../known-errors';
import { StackAssertionError, throwErr } from '../utils/errors';
import { StackAssertionError, captureError, throwErr } from '../utils/errors';
import { ProjectUpdateOptions } from './adminInterface';
import { cookies } from '@stackframe/stack-sc';
import { generateSecureRandomString } from '../utils/crypto';
import { AccessToken, RefreshToken, Session } from '../sessions';
import { globalVar } from '../utils/globals';
import { logged } from '../utils/proxies';
type UserCustomizableJson = {
displayName: string | null,
@ -53,8 +56,7 @@ export type ClientInterfaceOptions = {
} & ({
publishableClientKey: string,
} | {
projectOwnerTokens: TokenStore,
refreshProjectOwnerTokens: () => Promise<void>,
projectOwnerSession: Session,
});
export type SharedProvider = "shared-github" | "shared-google" | "shared-facebook" | "shared-microsoft" | "shared-spotify";
@ -83,14 +85,6 @@ export function toSharedProvider(provider: SharedProvider | StandardProvider): S
return "shared-" + provider as SharedProvider;
}
export type ReadonlyTokenStore = ReadonlyAsyncStore<TokenObject>;
export type TokenStore = AsyncStore<TokenObject>;
export type TokenObject = Readonly<{
refreshToken: string | null,
accessToken: string | null,
}>;
export type ProjectJson = {
id: string,
displayName: string,
@ -189,21 +183,12 @@ export class StackClientInterface {
return this.options.baseUrl + "/api/v1";
}
public async refreshAccessToken(tokenStore: TokenStore) {
public async fetchNewAccessToken(refreshToken: RefreshToken) {
if (!('publishableClientKey' in this.options)) {
// TODO fix
throw new Error("Admin session token is currently not supported for fetching new access token");
// TODO support it
throw new Error("Admin session token is currently not supported for fetching new access token. Did you try to log in on a StackApp initiated with the admin session?");
}
const refreshToken = (await tokenStore.getOrWait()).refreshToken;
if (!refreshToken) {
tokenStore.set({
accessToken: null,
refreshToken: null,
});
return;
}
const as = {
issuer: this.options.baseUrl,
algorithm: 'oauth2',
@ -218,17 +203,14 @@ export class StackClientInterface {
const rawResponse = await oauth.refreshTokenGrantRequest(
as,
client,
refreshToken,
refreshToken.token,
);
const response = await this._processResponse(rawResponse);
if (response.status === "error") {
const error = response.error;
if (error instanceof KnownErrors.RefreshTokenError) {
return tokenStore.set({
accessToken: null,
refreshToken: null,
});
return null;
}
throw error;
}
@ -250,41 +232,52 @@ export class StackClientInterface {
throw new StackAssertionError("OAuth error", { result });
}
tokenStore.update(old => ({
accessToken: result.access_token ?? null,
refreshToken: result.refresh_token ?? old?.refreshToken ?? null,
}));
if (!result.access_token) {
throw new StackAssertionError("Access token not found in token endpoint response, this is weird!");
}
return new AccessToken(result.access_token);
}
protected async sendClientRequest(
path: string,
requestOptions: RequestInit,
tokenStoreOrNull: TokenStore | null,
session: Session | null,
requestType: "client" | "server" | "admin" = "client",
) {
const tokenStore = tokenStoreOrNull ?? new AsyncStore<TokenObject>({
accessToken: null,
session ??= this.createSession({
refreshToken: null,
});
return await Result.orThrowAsync(
Result.retry(
() => this.sendClientRequestInner(path, requestOptions, tokenStore!, requestType),
() => this.sendClientRequestInner(path, requestOptions, session!, requestType),
5,
{ exponentialDelayBase: 1000 },
)
);
}
public createSession(options: Omit<ConstructorParameters<typeof Session>[0], "refreshAccessTokenCallback">): Session {
const session = new Session({
refreshAccessTokenCallback: async (refreshToken) => await this.fetchNewAccessToken(refreshToken),
...options,
});
return session;
}
protected async sendClientRequestAndCatchKnownError<E extends typeof KnownErrors[keyof KnownErrors]>(
path: string,
requestOptions: RequestInit,
tokenStoreOrNull: TokenStore | null,
tokenStoreOrNull: Session | null,
errorsToCatch: readonly E[],
): Promise<Result<
Response & {
usedTokens: TokenObject,
usedTokens: {
accessToken: AccessToken,
refreshToken: RefreshToken | null,
} | null,
},
InstanceType<E>
>> {
@ -303,30 +296,21 @@ export class StackClientInterface {
private async sendClientRequestInner(
path: string,
options: RequestInit,
/**
* This object will be modified for future retries, so it should be passed by reference.
*/
tokenStore: TokenStore,
session: Session,
requestType: "client" | "server" | "admin",
): Promise<Result<Response & {
usedTokens: TokenObject,
usedTokens: {
accessToken: AccessToken,
refreshToken: RefreshToken | null,
} | null,
}>> {
let tokenObj = await tokenStore.getOrWait();
if (!tokenObj.accessToken && tokenObj.refreshToken) {
await this.refreshAccessToken(tokenStore);
tokenObj = await tokenStore.getOrWait();
}
/**
* `tokenObj === null` means the session is invalid/not logged in
*/
let tokenObj = await session.getPotentiallyExpiredTokens();
let adminTokenStore: TokenStore | null = null;
let adminTokenObj: TokenObject | null = null;
if ("projectOwnerTokens" in this.options) {
adminTokenStore = this.options.projectOwnerTokens;
adminTokenObj = await adminTokenStore.getOrWait();
if (!adminTokenObj.accessToken) {
await this.options.refreshProjectOwnerTokens();
adminTokenObj = await adminTokenStore.getOrWait();
}
}
let adminSession = "projectOwnerSession" in this.options ? this.options.projectOwnerSession : null;
let adminTokenObj = adminSession ? await adminSession.getPotentiallyExpiredTokens() : null;
// all requests should be dynamic to prevent Next.js caching
cookies?.();
@ -343,7 +327,7 @@ export class StackClientInterface {
* However, Cloudflare Workers don't actually support `credentials`, so we only set it
* if Cloudflare-exclusive globals are not detected. https://github.com/cloudflare/workers-sdk/issues/2514
*/
..."WebSocketPair" in globalThis ? {} : {
..."WebSocketPair" in globalVar ? {} : {
credentials: "omit",
},
...options,
@ -352,18 +336,18 @@ export class StackClientInterface {
"X-Stack-Project-Id": this.projectId,
"X-Stack-Request-Type": requestType,
"X-Stack-Client-Version": this.options.clientVersion,
...tokenObj.accessToken ? {
"Authorization": "StackSession " + tokenObj.accessToken,
"X-Stack-Access-Token": tokenObj.accessToken,
...tokenObj ? {
"Authorization": "StackSession " + tokenObj.accessToken.token,
"X-Stack-Access-Token": tokenObj.accessToken.token,
} : {},
...tokenObj.refreshToken ? {
"X-Stack-Refresh-Token": tokenObj.refreshToken,
...tokenObj?.refreshToken ? {
"X-Stack-Refresh-Token": tokenObj.refreshToken.token,
} : {},
...'publishableClientKey' in this.options ? {
"X-Stack-Publishable-Client-Key": this.options.publishableClientKey,
} : {},
...adminTokenObj ? {
"X-Stack-Admin-Access-Token": adminTokenObj.accessToken ?? "",
"X-Stack-Admin-Access-Token": adminTokenObj.accessToken.token,
} : {},
/**
* Next.js until v15 would cache fetch requests by default, and forcefully disabling it was nearly impossible.
@ -379,7 +363,7 @@ export class StackClientInterface {
/**
* Cloudflare Workers does not support cache, so don't pass it there
*/
..."WebSocketPair" in globalThis ? {} : {
..."WebSocketPair" in globalVar ? {} : {
cache: "no-store",
},
};
@ -398,26 +382,26 @@ export class StackClientInterface {
const processedRes = await this._processResponse(rawRes);
if (processedRes.status === "error") {
// If the access token is expired, reset it and retry
// If the access token is invalid, reset it and retry
if (processedRes.error instanceof KnownErrors.InvalidAccessToken) {
tokenStore.set({
accessToken: null,
refreshToken: tokenObj.refreshToken,
});
return Result.error(new Error("Access token expired"));
if (!tokenObj) {
throw new StackAssertionError("Received invalid access token, but session is not logged in", { tokenObj, processedRes });
}
session.markAccessTokenExpired(tokenObj.accessToken);
return Result.error(processedRes.error);
}
// Same for the admin access token
// TODO HACK: Some of the backend hasn't been ported to use the new error codes, so if we have project owner tokens we need to check for ApiKeyNotFound too. Once the migration to smartRouteHandlers is complete, we can check for AdminAccessTokenExpired only.
if (adminTokenStore && (processedRes.error instanceof KnownErrors.AdminAccessTokenExpired || processedRes.error instanceof KnownErrors.ApiKeyNotFound)) {
adminTokenStore.set({
accessToken: null,
refreshToken: adminTokenObj!.refreshToken,
});
return Result.error(new Error("Admin access token expired"));
// TODO HACK: Some of the backend hasn't been ported to use the new error codes, so if we have project owner tokens we need to check for ApiKeyNotFound too. Once the migration to smartRouteHandlers is complete, we can check for InvalidAdminAccessToken only.
if (adminSession && (processedRes.error instanceof KnownErrors.InvalidAdminAccessToken || processedRes.error instanceof KnownErrors.ApiKeyNotFound)) {
if (!adminTokenObj) {
throw new StackAssertionError("Received invalid admin access token, but admin session is not logged in", { adminTokenObj, processedRes });
}
adminSession.markAccessTokenExpired(adminTokenObj.accessToken);
return Result.error(processedRes.error);
}
// Known errors are client side errors, and should hence not be retried (except for access token expired above).
// Known errors are client side errors, so except for the ones above they should not be retried
// Hence, throw instead of returning an error
throw processedRes.error;
}
@ -487,7 +471,7 @@ export class StackClientInterface {
async sendVerificationEmail(
emailVerificationRedirectUrl: string,
tokenStore: TokenStore
session: Session
): Promise<KnownErrors["EmailAlreadyVerified"] | undefined> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/send-verification-email",
@ -500,7 +484,7 @@ export class StackClientInterface {
emailVerificationRedirectUrl,
}),
},
tokenStore,
session,
[KnownErrors.EmailAlreadyVerified]
);
@ -557,7 +541,7 @@ export class StackClientInterface {
async updatePassword(
options: { oldPassword: string, newPassword: string },
tokenStore: TokenStore
session: Session
): Promise<KnownErrors["PasswordMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | undefined> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/update-password",
@ -568,7 +552,7 @@ export class StackClientInterface {
},
body: JSON.stringify(options),
},
tokenStore,
session,
[KnownErrors.PasswordMismatch, KnownErrors.PasswordRequirementsNotMet]
);
@ -609,8 +593,8 @@ export class StackClientInterface {
async signInWithCredential(
email: string,
password: string,
tokenStore: TokenStore
): Promise<KnownErrors["EmailPasswordMismatch"] | undefined> {
session: Session
): Promise<KnownErrors["EmailPasswordMismatch"] | { accessToken: string, refreshToken: string }> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/signin",
{
@ -623,7 +607,7 @@ export class StackClientInterface {
password,
}),
},
tokenStore,
session,
[KnownErrors.EmailPasswordMismatch]
);
@ -632,18 +616,18 @@ export class StackClientInterface {
}
const result = await res.data.json();
tokenStore.set({
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
};
}
async signUpWithCredential(
email: string,
password: string,
emailVerificationRedirectUrl: string,
tokenStore: TokenStore,
): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"] | undefined> {
session: Session,
): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"] | { accessToken: string, refreshToken: string }> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/signup",
{
@ -657,7 +641,7 @@ export class StackClientInterface {
emailVerificationRedirectUrl,
}),
},
tokenStore,
session,
[KnownErrors.UserEmailAlreadyExists, KnownErrors.PasswordRequirementsNotMet]
);
@ -666,13 +650,13 @@ export class StackClientInterface {
}
const result = await res.data.json();
tokenStore.set({
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
};
}
async signInWithMagicLink(code: string, tokenStore: TokenStore): Promise<KnownErrors["MagicLinkError"] | { newUser: boolean }> {
async signInWithMagicLink(code: string, session: Session): Promise<KnownErrors["MagicLinkError"] | { newUser: boolean, accessToken: string, refreshToken: string }> {
const res = await this.sendClientRequestAndCatchKnownError(
"/auth/magic-link-verification",
{
@ -693,11 +677,11 @@ export class StackClientInterface {
}
const result = await res.data.json();
tokenStore.set({
return {
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
return { newUser: result.newUser };
newUser: result.newUser,
};
}
async getOAuthUrl(
@ -736,8 +720,7 @@ export class StackClientInterface {
redirectUri: string,
codeVerifier: string,
state: string,
tokenStore: TokenStore,
) {
): Promise<{ newUser: boolean, accessToken: string, refreshToken: string }> {
if (!('publishableClientKey' in this.options)) {
// TODO fix
throw new Error("Admin session token is currently not supported for OAuth");
@ -754,7 +737,7 @@ export class StackClientInterface {
};
const params = oauth.validateAuthResponse(as, client, oauthParams, state);
if (oauth.isOAuth2Error(params)) {
throw new StackAssertionError("Error validating OAuth response", { params }); // Handle OAuth 2.0 redirect error
throw new StackAssertionError("Error validating outer OAuth response", { params }); // Handle OAuth 2.0 redirect error
}
const response = await oauth.authorizationCodeGrantRequest(
as,
@ -767,45 +750,49 @@ export class StackClientInterface {
let challenges: oauth.WWWAuthenticateChallenge[] | undefined;
if ((challenges = oauth.parseWwwAuthenticateChallenges(response))) {
// TODO Handle WWW-Authenticate Challenges as needed
throw new StackAssertionError("OAuth WWW-Authenticate challenge not implemented", { challenges });
throw new StackAssertionError("Outer OAuth WWW-Authenticate challenge not implemented", { challenges });
}
const result = await oauth.processAuthorizationCodeOAuth2Response(as, client, response);
if (oauth.isOAuth2Error(result)) {
// TODO Handle OAuth 2.0 response body error
throw new StackAssertionError("OAuth error", { result });
throw new StackAssertionError("Outer OAuth error during authorization code response", { result });
}
tokenStore.update(old => ({
accessToken: result.access_token ?? null,
refreshToken: result.refresh_token ?? old?.refreshToken ?? null,
}));
return result;
return {
newUser: result.newUser as boolean,
accessToken: result.access_token,
refreshToken: result.refresh_token ?? throwErr("Refresh token not found in outer OAuth response"),
};
}
async signOut(tokenStore: TokenStore): Promise<void> {
const tokenObj = await tokenStore.getOrWait();
const res = await this.sendClientRequest(
"/auth/signout",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
refreshToken: tokenObj.refreshToken ?? "",
}),
},
tokenStore,
);
await res.json();
tokenStore.set({
accessToken: null,
refreshToken: null,
});
async signOut(session: Session): Promise<void> {
const tokenObj = await session.getPotentiallyExpiredTokens();
if (tokenObj) {
if (!tokenObj.refreshToken) {
// TODO implement this
captureError("clientInterface.signOut()", new StackAssertionError("Signing out a user without access to the refresh token does not invalidate the session on the server. Please open an issue in the Stack repository if you see this error"));
} else {
const res = await this.sendClientRequest(
"/auth/signout",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
refreshToken: tokenObj.refreshToken.token,
}),
},
session,
);
await res.json();
}
}
session.invalidate();
}
async getClientUserByToken(tokenStore: TokenStore): Promise<Result<UserJson>> {
async getClientUserByToken(tokenStore: Session): Promise<Result<UserJson>> {
const response = await this.sendClientRequest(
"/current-user",
{},
@ -822,22 +809,22 @@ export class StackClientInterface {
type: 'global' | 'team',
direct: boolean,
},
tokenStore: TokenStore
session: Session
): Promise<PermissionDefinitionJson[]> {
const response = await this.sendClientRequest(
`/current-user/teams/${options.teamId}/permissions?type=${options.type}&direct=${options.direct}`,
{},
tokenStore,
session,
);
const permissions: PermissionDefinitionJson[] = await response.json();
return permissions;
}
async listClientUserTeams(tokenStore: TokenStore): Promise<TeamJson[]> {
async listClientUserTeams(session: Session): Promise<TeamJson[]> {
const response = await this.sendClientRequest(
"/current-user/teams",
{},
tokenStore,
session,
);
const teams: TeamJson[] = await response.json();
return teams;
@ -850,7 +837,7 @@ export class StackClientInterface {
return Result.ok(project);
}
async setClientUserCustomizableData(update: UserUpdateJson, tokenStore: TokenStore) {
async setClientUserCustomizableData(update: UserUpdateJson, session: Session) {
await this.sendClientRequest(
"/current-user",
{
@ -860,12 +847,12 @@ export class StackClientInterface {
},
body: JSON.stringify(update),
},
tokenStore,
session,
);
}
async listProjects(tokenStore: TokenStore): Promise<ProjectJson[]> {
const response = await this.sendClientRequest("/projects", {}, tokenStore);
async listProjects(session: Session): Promise<ProjectJson[]> {
const response = await this.sendClientRequest("/projects", {}, session);
if (!response.ok) {
throw new Error("Failed to list projects: " + response.status + " " + (await response.text()));
}
@ -876,7 +863,7 @@ export class StackClientInterface {
async createProject(
project: ProjectUpdateOptions & { displayName: string },
tokenStore: TokenStore,
session: Session,
): Promise<ProjectJson> {
const fetchResponse = await this.sendClientRequest(
"/projects",
@ -887,7 +874,7 @@ export class StackClientInterface {
},
body: JSON.stringify(project),
},
tokenStore,
session,
);
if (!fetchResponse.ok) {
throw new Error("Failed to create project: " + fetchResponse.status + " " + (await fetchResponse.text()));

View File

@ -1,9 +1,7 @@
import {
ClientInterfaceOptions,
UserJson,
TokenStore,
StackClientInterface,
ReadonlyTokenStore,
OrglikeJson,
UserUpdateJson,
PermissionDefinitionJson,
@ -13,6 +11,7 @@ import {
import { Result } from "../utils/results";
import { ReadonlyJson } from "../utils/json";
import { EmailTemplateCrud, ListEmailTemplatesCrud } from "./crud/email-templates";
import { Session } from "../sessions";
export type ServerUserJson = UserJson & {
serverMetadata: ReadonlyJson,
@ -51,7 +50,7 @@ export type ServerAuthApplicationOptions = (
readonly secretServerKey: string,
}
| {
readonly projectOwnerTokens: ReadonlyTokenStore,
readonly projectOwnerSession: Session,
}
)
);
@ -64,7 +63,7 @@ export class StackServerInterface extends StackClientInterface {
super(options);
}
protected async sendServerRequest(path: string, options: RequestInit, tokenStore: TokenStore | null, requestType: "server" | "admin" = "server") {
protected async sendServerRequest(path: string, options: RequestInit, session: Session | null, requestType: "server" | "admin" = "server") {
return await this.sendClientRequest(
path,
{
@ -74,16 +73,16 @@ export class StackServerInterface extends StackClientInterface {
...options.headers,
},
},
tokenStore,
session,
requestType,
);
}
async getServerUserByToken(tokenStore: TokenStore): Promise<Result<ServerUserJson>> {
async getServerUserByToken(session: Session): Promise<Result<ServerUserJson>> {
const response = await this.sendServerRequest(
"/current-user?server=true",
{},
tokenStore,
session,
);
const user: ServerUserJson | null = await response.json();
if (!user) return Result.error(new Error("Failed to get user"));
@ -107,22 +106,22 @@ export class StackServerInterface extends StackClientInterface {
type: 'global' | 'team',
direct: boolean,
},
tokenStore: TokenStore
session: Session
): Promise<ServerPermissionDefinitionJson[]> {
const response = await this.sendServerRequest(
`/current-user/teams/${options.teamId}/permissions?type=${options.type}&direct=${options.direct}&server=true`,
{},
tokenStore,
session,
);
const permissions: ServerPermissionDefinitionJson[] = await response.json();
return permissions;
}
async listServerUserTeams(tokenStore: TokenStore): Promise<ServerTeamJson[]> {
async listServerUserTeams(session: Session): Promise<ServerTeamJson[]> {
const response = await this.sendServerRequest(
"/current-user/teams?server=true",
{},
tokenStore,
session,
);
const teams: ServerTeamJson[] = await response.json();
return teams;

View File

@ -0,0 +1,142 @@
import { unsubscribe } from "diagnostics_channel";
import { StackAssertionError } from "./utils/errors";
import { ReadonlyStore, Store } from "./utils/stores";
export class AccessToken {
constructor(
public readonly token: string,
) {}
}
export class RefreshToken {
constructor(
public readonly token: string,
) {}
}
/**
* A session represents a user's session, which may or may not be valid. It may contain an access token, a refresh token, or both.
*
* A session never changes which user or session it belongs to, but the tokens may change over time.
*/
export class Session {
/**
* Each session has a session key that depends on the tokens inside. If the session has a refresh token, the session key depends only on the refresh token. If the session does not have a refresh token, the session key depends only on the access token.
*
* Multiple Session objects may have the same session key, which implies that they represent the same session by the same user. Furthermore, a session's key never changes over the lifetime of a session object.
*
* This makes session keys useful for caching and indexing sessions.
*/
public readonly sessionKey: string;
/**
* An access token that is not known to be invalid (ie. may be valid, but may have expired).
*/
private _accessToken: Store<AccessToken | null>;
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).
*
* 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).
*/
private _knownToBeInvalid = new Store<boolean>(false);
private _refreshPromise: Promise<AccessToken | null> | null = null;
constructor(private readonly _options: {
refreshAccessTokenCallback(refreshToken: RefreshToken): Promise<AccessToken | null>,
refreshToken: string | null,
accessToken?: string | null,
}) {
this._accessToken = new Store(_options.accessToken ? new AccessToken(_options.accessToken) : null);
this._refreshToken = _options.refreshToken ? new RefreshToken(_options.refreshToken) : null;
this.sessionKey = Session.calculateSessionKey({ accessToken: _options.accessToken ?? null, refreshToken: _options.refreshToken });
}
static calculateSessionKey(ofTokens: { refreshToken: string | null, accessToken?: string | null }): string {
if (ofTokens.refreshToken) {
return `refresh-${ofTokens.refreshToken}`;
} else if (ofTokens.accessToken) {
return `access-${ofTokens.accessToken}`;
} else {
return "not-logged-in";
}
}
invalidate() {
this._accessToken.set(null);
this._knownToBeInvalid.set(true);
}
onInvalidate(callback: () => void): { unsubscribe: () => void } {
return this._knownToBeInvalid.onChange(() => callback());
}
async getPotentiallyExpiredTokens(): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {
const accessToken = await this._getPotentiallyExpiredAccessToken();
return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;
}
async getNewlyFetchedTokens(): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> {
const accessToken = await this._getNewlyFetchedAccessToken();
return accessToken ? { accessToken, refreshToken: this._refreshToken } : null;
}
markAccessTokenExpired(accessToken: AccessToken) {
if (this._accessToken.get() === accessToken) {
this._accessToken.set(null);
}
}
/**
* Note that a callback invocation with `null` does not mean the session has been invalidated; the access token may just have expired. Use `onInvalidate` to detect invalidation.
*/
onAccessTokenChange(callback: (newAccessToken: AccessToken | null) => void): { unsubscribe: () => void } {
return this._accessToken.onChange(callback);
}
/**
* @returns An access token (cached if possible), or null if the session either does not represent a user or the session is invalid.
*/
private async _getPotentiallyExpiredAccessToken(): Promise<AccessToken | null> {
const oldAccessToken = this._accessToken.get();
if (oldAccessToken) return oldAccessToken;
if (!this._refreshToken) return null;
if (this._knownToBeInvalid.get()) return null;
// refresh access token
if (!this._refreshPromise) {
this._refreshAndSetRefreshPromise(this._refreshToken);
}
return await this._refreshPromise;
}
/**
* You should prefer `getPotentiallyExpiredAccessToken` in almost all cases.
*
* @returns A newly fetched access token (never read from cache), or null if the session either does not represent a user or the session is invalid.
*/
private async _getNewlyFetchedAccessToken(): Promise<AccessToken | null> {
if (!this._refreshToken) return null;
if (this._knownToBeInvalid.get()) return null;
this._refreshAndSetRefreshPromise(this._refreshToken);
return await this._refreshPromise;
}
private _refreshAndSetRefreshPromise(refreshToken: RefreshToken) {
let refreshPromise: Promise<AccessToken | null> = this._options.refreshAccessTokenCallback(refreshToken).then((accessToken) => {
if (refreshPromise === this._refreshPromise) {
this._refreshPromise = null;
this._accessToken.set(accessToken);
if (!accessToken) {
this.invalidate();
}
}
return accessToken;
});
this._refreshPromise = refreshPromise;
}
}

View File

@ -1,4 +1,5 @@
import { encodeBase32 } from "./bytes";
import { globalVar } from "./globals";
/**
* Generates a secure alphanumeric string using the system's cryptographically secure
@ -7,7 +8,7 @@ import { encodeBase32 } from "./bytes";
export function generateSecureRandomString(minBitsOfEntropy: number = 224) {
const base32CharactersCount = Math.ceil(minBitsOfEntropy / 5);
const bytesCount = Math.ceil(base32CharactersCount * 5 / 8);
const randomBytes = globalThis.crypto.getRandomValues(new Uint8Array(bytesCount));
const randomBytes = globalVar.crypto.getRandomValues(new Uint8Array(bytesCount));
const str = encodeBase32(randomBytes);
return str.slice(str.length - base32CharactersCount).toLowerCase();
}

View File

@ -1,11 +1,15 @@
import { throwErr } from "./errors";
import { deindent } from "./strings";
export function isBrowserLike() {
return typeof window !== "undefined" && typeof document !== "undefined" && typeof document.createElement !== "undefined";
}
/**
* Returns the environment variable with the given name, throwing an error if it's undefined or the empty string.
*/
export function getEnvVariable(name: string): string {
if (typeof window !== 'undefined') {
if (isBrowserLike()) {
throw new Error(deindent`
Can't use getEnvVariable on the client because Next.js transpiles expressions of the kind process.env.XYZ at build-time on the client.

View File

@ -1,3 +1,4 @@
import { globalVar } from "./globals";
import { Json } from "./json";
@ -35,6 +36,10 @@ export function registerErrorSink(sink: (location: string, error: unknown) => vo
registerErrorSink((location, ...args) => {
console.error(`Error in ${location}:`, ...args);
});
registerErrorSink((location, error, ...args) => {
globalVar.stackCapturedErrors = globalVar.stackCapturedErrors ?? [];
globalVar.stackCapturedErrors.push({ location, error: args, extraArgs: args });
});
export function captureError(location: string, error: unknown): void {
for (const sink of errorSinks) {

View File

@ -1,7 +1,7 @@
const globalVar: any =
typeof globalThis !== 'undefined' ? globalThis :
typeof window !== 'undefined' ? window :
typeof global !== 'undefined' ? global :
typeof global !== 'undefined' ? global :
typeof window !== 'undefined' ? window :
typeof self !== 'undefined' ? self :
{};
export {

View File

@ -0,0 +1,59 @@
import { nicify } from "./strings";
export function logged<T extends object>(name: string, toLog: T, options: {} = {}): T {
const proxy = new Proxy(toLog, {
get(target, prop, receiver) {
const orig = Reflect.get(target, prop, receiver);
if (typeof orig === "function") {
return function (this: any, ...args: any[]) {
const success = (v: any, isPromise: boolean) => console.debug(`logged(...): Called ${name}.${String(prop)}(${args.map(a => nicify(a)).join(", ")}) => ${isPromise ? "Promise<" : ""}${nicify(result)}${isPromise ? ">" : ""}`, { this: this, args, promise: isPromise ? result : false, result: v, trace: new Error() });
const error = (e: any, isPromise: boolean) => console.debug(`logged(...): Error in ${name}.${String(prop)}(${args.map(a => nicify(a)).join(", ")})`, { this: this, args, promise: isPromise ? result : false, error: e, trace: new Error() });
let result: unknown;
try {
result = orig.apply(this, args);
} catch (e) {
error(e, false);
throw e;
}
if (result instanceof Promise) {
result.then((v) => success(v, true)).catch((e) => error(e, true));
} else {
success(result, false);
}
return result;
};
}
return orig;
},
set(target, prop, value) {
console.log(`Setting ${name}.${String(prop)} to ${value}`);
return Reflect.set(target, prop, value);
},
apply(target, thisArg, args) {
console.log(`Calling ${name}(${JSON.stringify(args).slice(1, -1)})`);
return Reflect.apply(target as any, thisArg, args);
},
construct(target, args, newTarget) {
console.log(`Constructing ${name}(${JSON.stringify(args).slice(1, -1)})`);
return Reflect.construct(target as any, args, newTarget);
},
defineProperty(target, prop, descriptor) {
console.log(`Defining ${name}.${String(prop)} as ${JSON.stringify(descriptor)}`);
return Reflect.defineProperty(target, prop, descriptor);
},
deleteProperty(target, prop) {
console.log(`Deleting ${name}.${String(prop)}`);
return Reflect.deleteProperty(target, prop);
},
setPrototypeOf(target, prototype) {
console.log(`Setting prototype of ${name} to ${prototype}`);
return Reflect.setPrototypeOf(target, prototype);
},
preventExtensions(target) {
console.log(`Preventing extensions of ${name}`);
return Reflect.preventExtensions(target);
},
});
return proxy;
}

View File

@ -1,6 +1,7 @@
import { use } from "react";
import { neverResolve } from "./promises";
import { deindent } from "./strings";
import { isBrowserLike } from "./env";
export function getNodeText(node: React.ReactNode): string {
if (["number", "string"].includes(typeof node)) {
@ -33,7 +34,7 @@ export function suspend(): never {
* Use this in a component or a hook to disable SSR. Should be wrapped in a Suspense boundary, or it will throw an error.
*/
export function suspendIfSsr(caller?: string) {
if (typeof window === "undefined") {
if (!isBrowserLike()) {
const error = Object.assign(
new Error(deindent`
${caller ?? "This code path"} attempted to display a loading indicator during SSR by falling back to the nearest Suspense boundary. If you see this error, it means no Suspense boundary was found, and no loading indicator could be displayed. Make sure you are not catching this error with try-catch, and that the component is rendered inside a Suspense boundary, for example by adding a \`loading.tsx\` file in your app directory.

View File

@ -1,4 +1,5 @@
import { wait } from "./promises";
import { deindent } from "./strings";
export type Result<T, E = unknown> =
| {
@ -109,9 +110,20 @@ function mapResult<T, U, E = unknown, P = unknown>(result: AsyncResult<T, E, P>,
}
class RetryError extends Error {
class RetryError extends AggregateError {
constructor(public readonly errors: unknown[]) {
super(`Error after retrying ${errors.length} times.`, { cause: errors[errors.length - 1] });
super(
errors,
deindent`
Error after retrying ${errors.length} times.
${errors.map((e, i) => deindent`
Attempt ${i + 1}:
${e}
`).join("\n\n")}
`,
{ cause: errors[errors.length - 1] }
);
this.name = "RetryError";
}

View File

@ -2,6 +2,54 @@ import { AsyncResult, Result } from "./results";
import { generateUuid } from "./uuids";
import { ReactPromise, pending, rejected, resolved } from "./promises";
export type ReadonlyStore<T> = {
get(): T,
onChange(callback: (value: T, oldValue: T | undefined) => void): { unsubscribe: () => void },
onceChange(callback: (value: T, oldValue: T | undefined) => void): { unsubscribe: () => void },
};
export class Store<T> implements ReadonlyStore<T> {
private readonly _callbacks: Map<string, ((value: T, oldValue: T | undefined) => void)> = new Map();
constructor(
private _value: T
) {}
get(): T {
return this._value;
}
set(value: T): void {
const oldValue = this._value;
this._value = value;
this._callbacks.forEach((callback) => callback(value, oldValue));
}
update(updater: (value: T) => T): T {
const value = updater(this._value);
this.set(value);
return value;
}
onChange(callback: (value: T, oldValue: T | undefined) => void): { unsubscribe: () => void } {
const uuid = generateUuid();
this._callbacks.set(uuid, callback);
return {
unsubscribe: () => {
this._callbacks.delete(uuid);
},
};
}
onceChange(callback: (value: T, oldValue: T | undefined) => void): { unsubscribe: () => void } {
const { unsubscribe } = this.onChange((...args) => {
unsubscribe();
callback(...args);
});
return { unsubscribe };
}
}
export type ReadonlyAsyncStore<T> = {
isAvailable(): boolean,
get(): AsyncResult<T, unknown, void>,

View File

@ -105,3 +105,42 @@ export function deindent(strings: string | readonly string[], ...values: any[]):
return templateIdentity(deindentedStrings, ...indentedValues);
}
export function nicify(value: unknown, { depth = 5 } = {}): string {
switch (typeof value) {
case "string": case "boolean": case "number": {
return JSON.stringify(value);
}
case "undefined": {
return "undefined";
}
case "symbol": {
return value.toString();
}
case "bigint": {
return `${value}n`;
}
case "function": {
if (value.name) return `function ${value.name}(...) { ... }`;
return `(...) => { ... }`;
}
case "object": {
if (value === null) return "null";
if (Array.isArray(value)) {
if (depth <= 0 && value.length !== 0) return "[...]";
return `[${value.map((v) => nicify(v, { depth: depth - 1 })).join(", ")}]`;
}
const entries = Object.entries(value);
if (entries.length === 0) return "{}";
if (depth <= 0) return "{...}";
return `{ ${Object.entries(value).map(([k, v]) => {
if (typeof v === "function" && v.name === k) return `${k}(...): { ... }`;
else return `${k}: ${nicify(v, { depth: depth - 1 })}`;
}).join(", ")} }`;
}
default: {
return `${typeof value}<${value}>`;
}
}
}

View File

@ -1,3 +1,5 @@
import { globalVar } from "./globals";
export function generateUuid() {
return globalThis.crypto.randomUUID();
return globalVar.crypto.randomUUID();
}

View File

@ -1,7 +1,6 @@
import { StackClientInterface } from "@stackframe/stack-shared";
import { saveVerifierAndState, getVerifierAndState } from "./cookie";
import { constructRedirectUrl } from "../utils/url";
import { TokenStore } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { neverResolve, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
@ -66,7 +65,6 @@ function consumeOAuthCallbackQueryParams(expectedState: string): null | URL {
export async function callOAuthCallback(
iface: StackClientInterface,
tokenStore: TokenStore,
redirectUrl: string,
) {
// note: this part of the function (until the return) needs
@ -87,7 +85,6 @@ export async function callOAuthCallback(
constructRedirectUrl(redirectUrl),
codeVerifier,
state,
tokenStore,
);
} catch (e) {
throw new StackAssertionError("Error signing in during OAuth callback. Please try again.", { cause: e });

View File

@ -1,19 +1,19 @@
import React, { use, useCallback, useMemo } from "react";
import { KnownError, KnownErrors, OAuthProviderConfigJson, ServerUserJson, StackAdminInterface, StackClientInterface, StackServerInterface } from "@stackframe/stack-shared";
import { getCookie, setOrDeleteCookie } from "./cookie";
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { AsyncResult, Result } from "@stackframe/stack-shared/dist/utils/results";
import { suspendIfSsr } from "@stackframe/stack-shared/dist/utils/react";
import { AsyncStore } from "@stackframe/stack-shared/dist/utils/stores";
import { ClientProjectJson, UserJson, TokenObject, TokenStore, ProjectJson, EmailConfigJson, DomainConfigJson, ReadonlyTokenStore, getProductionModeErrors, ProductionModeError, UserUpdateJson, TeamJson, PermissionDefinitionJson, PermissionDefinitionScopeJson, TeamMemberJson } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { isClient } from "../utils/next";
import { AsyncStore, Store } from "@stackframe/stack-shared/dist/utils/stores";
import { ClientProjectJson, UserJson, ProjectJson, EmailConfigJson, DomainConfigJson, getProductionModeErrors, ProductionModeError, UserUpdateJson, TeamJson, PermissionDefinitionJson, PermissionDefinitionScopeJson, TeamMemberJson } from "@stackframe/stack-shared/dist/interface/clientInterface";
import { isBrowserLike } from "@stackframe/stack-shared/src/utils/env";
import { callOAuthCallback, signInWithOAuth } from "./auth";
import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases
import { ReadonlyJson } from "@stackframe/stack-shared/dist/utils/json";
import { constructRedirectUrl } from "../utils/url";
import { filterUndefined, omit } from "@stackframe/stack-shared/dist/utils/objects";
import { neverResolve, resolved, runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { resolved, runAsynchronously, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { AsyncCache } from "@stackframe/stack-shared/dist/utils/caches";
import { ApiKeySetBaseJson, ApiKeySetCreateOptions, ApiKeySetFirstViewJson, ApiKeySetJson, ProjectUpdateOptions } from "@stackframe/stack-shared/dist/interface/adminInterface";
import { suspend } from "@stackframe/stack-shared/dist/utils/react";
@ -22,6 +22,7 @@ import { EmailTemplateCrud, ListEmailTemplatesCrud } from "@stackframe/stack-sha
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
import { isReactServer } from "@stackframe/stack-sc";
import * as cookie from "cookie";
import { AccessToken, Session } from "@stackframe/stack-shared/dist/sessions";
// NextNavigation.useRouter does not exist in react-server environments and some bundler try to be helpful and throw a warning. Ignore the warning.
const NextNavigation = scrambleDuringCompileTime(NextNavigationUnscrambled);
@ -40,7 +41,7 @@ export type TokenStoreInit<HasTokenStore extends boolean = boolean> =
| "nextjs-cookie"
| "memory"
| RequestLike
| { accessToken: string, refreshToken: string }
)
: HasTokenStore extends false ? null
: TokenStoreInit<true> | TokenStoreInit<false>;
@ -134,8 +135,7 @@ export type StackAdminAppConstructorOptions<HasTokenStore extends boolean, Proje
| (
& Omit<StackServerAppConstructorOptions<HasTokenStore, ProjectId>, "publishableClientKey" | "secretServerKey">
& {
projectOwnerTokens: TokenStore,
refreshProjectOwnerTokens: () => Promise<void>,
projectOwnerSession: Session,
}
)
);
@ -147,54 +147,62 @@ export type StackClientAppJson<HasTokenStore extends boolean, ProjectId extends
const defaultBaseUrl = "https://app.stack-auth.com";
type TokenObject = {
accessToken: string | null,
refreshToken: string | null,
};
function createEmptyTokenStore() {
return new AsyncStore<TokenObject>({
return new Store<TokenObject>({
refreshToken: null,
accessToken: null,
});
}
let cookieTokenStore: TokenStore | null = null;
const cookieTokenStoreInitializer = (): TokenStore => {
if (!isClient()) {
let storedCookieTokenStore: Store<TokenObject> | null = null;
const getCookieTokenStore = (): Store<TokenObject> => {
if (!isBrowserLike()) {
throw new Error("Cannot use cookie token store on the server!");
}
if (cookieTokenStore === null) {
cookieTokenStore = new AsyncStore<TokenObject>();
if (storedCookieTokenStore === null) {
const getCurrentValue = () => ({
refreshToken: getCookie('stack-refresh'),
accessToken: getCookie('stack-access'),
});
storedCookieTokenStore = new Store<TokenObject>(getCurrentValue());
let hasSucceededInWriting = true;
setInterval(() => {
if (hasSucceededInWriting) {
const newValue = {
refreshToken: getCookie('stack-refresh'),
accessToken: getCookie('stack-access'),
};
const res = cookieTokenStore!.get();
if (res.status !== "ok"
|| res.data.refreshToken !== newValue.refreshToken
|| res.data.accessToken !== newValue.accessToken
) {
cookieTokenStore!.set(newValue);
const currentValue = getCurrentValue();
const oldValue = storedCookieTokenStore!.get();
if (JSON.stringify(currentValue) !== JSON.stringify(oldValue)) {
storedCookieTokenStore!.set(currentValue);
}
}
}, 10);
cookieTokenStore.onChange((value) => {
}, 100);
storedCookieTokenStore.onChange((value) => {
try {
setOrDeleteCookie('stack-refresh', value.refreshToken, { maxAge: 60 * 60 * 24 * 365 });
setOrDeleteCookie('stack-access', value.accessToken, { maxAge: 60 * 60 * 24 });
hasSucceededInWriting = true;
} catch (e) {
hasSucceededInWriting = false;
if (!isBrowserLike()) {
// Setting cookies inside RSCs is not allowed, so we just ignore it
hasSucceededInWriting = false;
} else {
throw e;
}
}
});
}
return cookieTokenStore;
return storedCookieTokenStore;
};
const loadingSentinel = Symbol("stackAppCacheLoadingSentinel");
function useCache<D extends any[], T>(cache: AsyncCache<D, T>, dependencies: D, caller: string): T {
function useAsyncCache<D extends any[], T>(cache: AsyncCache<D, T>, dependencies: D, caller: string): T {
// we explicitly don't want to run this hook in SSR
suspendIfSsr(caller);
@ -218,6 +226,16 @@ function useCache<D extends any[], T>(cache: AsyncCache<D, T>, dependencies: D,
}
}
function useStore<T>(store: Store<T>): T {
const subscribe = useCallback((cb: () => void) => {
const { unsubscribe } = store.onChange(() => cb());
return unsubscribe;
}, [store]);
const getSnapshot = useCallback(() => store.get(), [store]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
export const stackAppInternalsSymbol = Symbol.for("StackAppInternals");
const allClientApps = new Map<string, [checkString: string, app: StackClientApp<any, any>]>();
@ -229,19 +247,13 @@ const createCache = <D extends any[], T>(fetcher: (dependencies: D) => Promise<T
);
};
// note that we intentionally use TokenStore (a reference type) as a key instead of a stringified version of it, as different token stores with the same tokens should be treated differently
// (if we wouldn't , we would cache users across requests on the server, which may cause issues)
const createCacheByTokenStore = <D extends any[], T>(fetcher: (tokenStore: TokenStore, extraDependencies: D) => Promise<T> ) => {
return new AsyncCache<[TokenStore, ...D], T>(
async ([tokenStore, ...extraDependencies]) => await fetcher(tokenStore, extraDependencies),
const createCacheBySession = <D extends any[], T>(fetcher: (session: Session, extraDependencies: D) => Promise<T> ) => {
return new AsyncCache<[Session, ...D], T>(
async ([session, ...extraDependencies]) => await fetcher(session, extraDependencies),
{
onSubscribe: ([tokenStore], refresh) => {
// TODO find a *clean* way to not refresh when the token change was made inside the fetcher (for example due to expired access token)
const handlerObj = tokenStore.onChange((newValue, oldValue) => {
if (newValue.refreshToken === oldValue?.refreshToken) return;
refresh();
});
return () => handlerObj.unsubscribe();
onSubscribe: ([session], refresh) => {
const handler = session.onInvalidate(() => refresh());
return () => handler.unsubscribe();
},
},
);
@ -255,29 +267,29 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
protected readonly _tokenStoreInit: TokenStoreInit<HasTokenStore>;
protected readonly _urlOptions: Partial<HandlerUrls>;
private readonly __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false;
private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false;
private readonly _currentUserCache = createCacheByTokenStore(async (tokenStore) => {
private readonly _currentUserCache = createCacheBySession(async (session) => {
if (this.__DEMO_ENABLE_SLIGHT_FETCH_DELAY) {
await wait(2000);
}
const user = await this._interface.getClientUserByToken(tokenStore);
const user = await this._interface.getClientUserByToken(session);
return Result.or(user, null);
});
private readonly _currentProjectCache = createCache(async () => {
return Result.orThrow(await this._interface.getClientProject());
});
private readonly _ownedProjectsCache = createCacheByTokenStore(async (tokenStore) => {
return await this._interface.listProjects(tokenStore);
private readonly _ownedProjectsCache = createCacheBySession(async (session) => {
return await this._interface.listProjects(session);
});
private readonly _currentUserPermissionsCache = createCacheByTokenStore<
private readonly _currentUserPermissionsCache = createCacheBySession<
[string, 'team' | 'global', boolean],
PermissionDefinitionJson[]
>(async (tokenStore, [teamId, type, direct]) => {
return await this._interface.listClientUserTeamPermissions({ teamId, type, direct }, tokenStore);
>(async (session, [teamId, type, direct]) => {
return await this._interface.listClientUserTeamPermissions({ teamId, type, direct }, session);
});
private readonly _currentUserTeamsCache = createCacheByTokenStore(async (tokenStore) => {
return await this._interface.listClientUserTeams(tokenStore);
private readonly _currentUserTeamsCache = createCacheBySession(async (session) => {
return await this._interface.listClientUserTeams(session);
});
constructor(protected readonly _options:
@ -340,20 +352,19 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
private _memoryTokenStore = createEmptyTokenStore();
private _requestTokenStores = new Map<RequestLike, TokenStore>();
protected _getTokenStore(overrideTokenStoreInit?: TokenStoreInit): TokenStore {
private _requestTokenStores = new WeakMap<RequestLike, Store<TokenObject>>();
protected _getOrCreateTokenStore(overrideTokenStoreInit?: TokenStoreInit): Store<TokenObject> {
const tokenStoreInit = overrideTokenStoreInit === undefined ? this._tokenStoreInit : overrideTokenStoreInit;
switch (tokenStoreInit) {
case "cookie": {
return cookieTokenStoreInitializer();
return getCookieTokenStore();
}
case "nextjs-cookie": {
if (isClient()) {
return cookieTokenStoreInitializer();
if (isBrowserLike()) {
return getCookieTokenStore();
} else {
const store = new AsyncStore<TokenObject>();
store.set({
const store = new Store<TokenObject>({
refreshToken: getCookie('stack-refresh'),
accessToken: getCookie('stack-access'),
});
@ -375,11 +386,11 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return createEmptyTokenStore();
}
default: {
if (tokenStoreInit && typeof tokenStoreInit === "object" && "headers" in tokenStoreInit) {
if (tokenStoreInit !== null && typeof tokenStoreInit === "object" && "headers" in tokenStoreInit) {
if (this._requestTokenStores.has(tokenStoreInit)) return this._requestTokenStores.get(tokenStoreInit)!;
const cookieHeader = tokenStoreInit.headers.get("cookie");
const parsed = cookie.parse(cookieHeader || "");
const res = new AsyncStore<TokenObject>({
const res = new Store<TokenObject>({
refreshToken: parsed['stack-refresh'] || null,
accessToken: parsed['stack-access'] || null,
});
@ -392,6 +403,64 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
}
/**
* A map from token stores and session keys to sessions.
*
* This isn't just a map from session keys to sessions for two reasons:
*
* - So we can garbage-collect Session objects when the token store is garbage-collected
* - So different token stores are separated and don't leak information between each other, eg. if the same user sends two requests to the same server they should get a different session object
*/
private _sessionsByTokenStoreAndSessionKey = new WeakMap<Store<TokenObject>, Map<string, Session>>();
protected _getSessionFromTokenStore(tokenStore: Store<TokenObject>): Session {
const tokenObj = tokenStore.get();
const sessionKey = Session.calculateSessionKey(tokenObj);
const existing = sessionKey ? this._sessionsByTokenStoreAndSessionKey.get(tokenStore)?.get(sessionKey) : null;
if (existing) return existing;
const session = this._interface.createSession({
refreshToken: tokenObj.refreshToken,
accessToken: tokenObj.accessToken,
});
session.onAccessTokenChange((newAccessToken) => {
tokenStore.update((old) => ({
...old,
accessToken: newAccessToken?.token ?? null
}));
});
session.onInvalidate(() => {
tokenStore.update((old) => ({
...old,
accessToken: null,
refreshToken: null,
}));
});
let sessionsBySessionKey = this._sessionsByTokenStoreAndSessionKey.get(tokenStore) ?? new Map();
this._sessionsByTokenStoreAndSessionKey.set(tokenStore, sessionsBySessionKey);
sessionsBySessionKey.set(sessionKey, session);
return session;
}
protected _getSession(overrideTokenStoreInit?: TokenStoreInit): Session {
const tokenStore = this._getOrCreateTokenStore(overrideTokenStoreInit);
return this._getSessionFromTokenStore(tokenStore);
}
protected _useSession(overrideTokenStoreInit?: TokenStoreInit): Session {
const tokenStore = this._getOrCreateTokenStore(overrideTokenStoreInit);
const subscribe = useCallback((cb: () => void) => {
const { unsubscribe } = tokenStore.onChange(() => cb());
return unsubscribe;
}, [tokenStore]);
const getSnapshot = useCallback(() => this._getSessionFromTokenStore(tokenStore), [tokenStore]);
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
protected async _signInToAccountWithTokens(tokens: { accessToken: string | null, refreshToken: string }) {
const tokenStore = this._getOrCreateTokenStore();
tokenStore.set(tokens);
}
protected _hasPersistentTokenStore(overrideTokenStoreInit?: TokenStoreInit): this is StackClientApp<true, ProjectId> {
return (overrideTokenStoreInit !== undefined ? overrideTokenStoreInit : this._tokenStoreInit) !== null;
}
@ -479,24 +548,25 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
});
},
async listTeams() {
const teams = await app._currentUserTeamsCache.getOrWait([app._getTokenStore()], "write-only");
const teams = await app._currentUserTeamsCache.getOrWait([app._getSession()], "write-only");
return teams.map((json) => app._teamFromJson(json));
},
useTeams() {
const teams = useCache(app._currentUserTeamsCache, [app._getTokenStore()], "user.useTeams()");
const session = app._useSession();
const teams = useAsyncCache(app._currentUserTeamsCache, [session], "user.useTeams()");
return useMemo(() => teams.map((json) => app._teamFromJson(json)), [teams]);
},
onTeamsChange(callback: (value: Team[], oldValue: Team[] | undefined) => void) {
return app._currentUserTeamsCache.onChange([app._getTokenStore()], (value, oldValue) => {
return app._currentUserTeamsCache.onChange([app._getSession()], (value, oldValue) => {
callback(value.map((json) => app._teamFromJson(json)), oldValue?.map((json) => app._teamFromJson(json)));
});
},
async listPermissions(scope: Team, options?: { direct?: boolean }): Promise<Permission[]> {
const permissions = await app._currentUserPermissionsCache.getOrWait([app._getTokenStore(), scope.id, 'team', !!options?.direct], "write-only");
const permissions = await app._currentUserPermissionsCache.getOrWait([app._getSession(), scope.id, 'team', !!options?.direct], "write-only");
return permissions.map((json) => app._permissionFromJson(json));
},
usePermissions(scope: Team, options?: { direct?: boolean }): Permission[] {
const permissions = useCache(app._currentUserPermissionsCache, [app._getTokenStore(), scope.id, 'team', !!options?.direct], "user.usePermissions()");
const permissions = useAsyncCache(app._currentUserPermissionsCache, [app._getSession(), scope.id, 'team', !!options?.direct], "user.usePermissions()");
return useMemo(() => permissions.map((json) => app._permissionFromJson(json)), [permissions]);
},
usePermission(scope: Team, permissionId: string): Permission | null {
@ -518,7 +588,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
protected _teamMemberFromJson(json: TeamMemberJson): TeamMember;
protected _teamMemberFromJson(json: TeamMemberJson | null): TeamMember | null;
protected _teamMemberFromJson(json: TeamMemberJson): TeamMember | null {
protected _teamMemberFromJson(json: TeamMemberJson | null): TeamMember | null {
if (json === null) return null;
return {
teamId: json.teamId,
@ -527,31 +597,28 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
};
}
protected _currentUserFromJson(json: UserJson, tokenStore: TokenStore): ProjectCurrentUser<ProjectId>;
protected _currentUserFromJson(json: UserJson | null, tokenStore: TokenStore): ProjectCurrentUser<ProjectId> | null;
protected _currentUserFromJson(json: UserJson | null, tokenStore: TokenStore): ProjectCurrentUser<ProjectId> | null {
protected _currentUserFromJson(json: UserJson, session: Session): ProjectCurrentUser<ProjectId>;
protected _currentUserFromJson(json: UserJson | null, session: Session): ProjectCurrentUser<ProjectId> | null;
protected _currentUserFromJson(json: UserJson | null, session: Session): ProjectCurrentUser<ProjectId> | null {
if (json === null) return null;
const app = this;
const currentUser: CurrentUser = {
...this._userFromJson(json),
tokenStore,
async refreshAccessToken() {
await app._interface.refreshAccessToken(tokenStore);
},
session,
async updateSelectedTeam(team: Team | null) {
await app._updateUser({ selectedTeamId: team?.id ?? null }, tokenStore);
await app._updateUser({ selectedTeamId: team?.id ?? null }, session);
},
update(update) {
return app._updateUser(update, tokenStore);
return app._updateUser(update, session);
},
signOut() {
return app._signOut(tokenStore);
return app._signOut(session);
},
sendVerificationEmail() {
return app._sendVerificationEmail(tokenStore);
return app._sendVerificationEmail(session);
},
updatePassword(options: { oldPassword: string, newPassword: string}) {
return app._updatePassword(options, tokenStore);
return app._updatePassword(options, session);
},
};
if (this._isInternalProject()) {
@ -616,13 +683,12 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
};
}
protected _createAdminInterface(forProjectId: string, tokenStore: TokenStore): StackAdminInterface {
protected _createAdminInterface(forProjectId: string, session: Session): StackAdminInterface {
return new StackAdminInterface({
baseUrl: this._interface.options.baseUrl,
projectId: forProjectId,
clientVersion,
projectOwnerTokens: tokenStore,
refreshProjectOwnerTokens: async () => await this._interface.refreshAccessToken(tokenStore),
projectOwnerSession: session,
});
}
@ -666,28 +732,28 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async redirectToAfterSignOut() { return await this._redirectTo("afterSignOut"); }
async redirectToAccountSettings() { return await this._redirectTo("accountSettings"); }
async sendForgotPasswordEmail(email: string): Promise<KnownErrors["UserNotFound"] | undefined> {
async sendForgotPasswordEmail(email: string): Promise<KnownErrors["UserNotFound"] | void> {
const redirectUrl = constructRedirectUrl(this.urls.passwordReset);
const error = await this._interface.sendForgotPasswordEmail(email, redirectUrl);
return error;
}
async sendMagicLinkEmail(email: string): Promise<KnownErrors["RedirectUrlNotWhitelisted"] | undefined> {
async sendMagicLinkEmail(email: string): Promise<KnownErrors["RedirectUrlNotWhitelisted"] | void> {
const magicLinkRedirectUrl = constructRedirectUrl(this.urls.magicLinkCallback);
const error = await this._interface.sendMagicLinkEmail(email, magicLinkRedirectUrl);
return error;
}
async resetPassword(options: { password: string, code: string }): Promise<KnownErrors["PasswordResetError"] | undefined> {
async resetPassword(options: { password: string, code: string }): Promise<KnownErrors["PasswordResetError"] | void> {
const error = await this._interface.resetPassword(options);
return error;
}
async verifyPasswordResetCode(code: string): Promise<KnownErrors["PasswordResetCodeError"] | undefined> {
async verifyPasswordResetCode(code: string): Promise<KnownErrors["PasswordResetCodeError"] | void> {
return await this._interface.verifyPasswordResetCode(code);
}
async verifyEmail(code: string): Promise<KnownErrors["EmailVerificationError"] | undefined> {
async verifyEmail(code: string): Promise<KnownErrors["EmailVerificationError"] | void> {
return await this._interface.verifyEmail(code);
}
@ -696,8 +762,8 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async getUser(options?: GetUserOptions): Promise<ProjectCurrentUser<ProjectId> | null>;
async getUser(options?: GetUserOptions): Promise<ProjectCurrentUser<ProjectId> | null> {
this._ensurePersistentTokenStore(options?.tokenStore);
const tokenStore = this._getTokenStore(options?.tokenStore);
const userJson = await this._currentUserCache.getOrWait([tokenStore], "write-only");
const session = this._getSession(options?.tokenStore);
const userJson = await this._currentUserCache.getOrWait([session], "write-only");
if (userJson === null) {
switch (options?.or) {
@ -714,7 +780,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
}
return this._currentUserFromJson(userJson, tokenStore);
return this._currentUserFromJson(userJson, session);
}
useUser(options: GetUserOptions & { or: 'redirect' }): ProjectCurrentUser<ProjectId>;
@ -724,8 +790,8 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
this._ensurePersistentTokenStore(options?.tokenStore);
const router = NextNavigation.useRouter();
const tokenStore = this._getTokenStore(options?.tokenStore);
const userJson = useCache(this._currentUserCache, [tokenStore], "useUser()");
const session = this._getSession(options?.tokenStore);
const userJson = useAsyncCache(this._currentUserCache, [session], "useUser()");
if (userJson === null) {
switch (options?.or) {
@ -750,21 +816,21 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
return useMemo(() => {
return this._currentUserFromJson(userJson, tokenStore);
}, [userJson, tokenStore, options?.or]);
return this._currentUserFromJson(userJson, session);
}, [userJson, session, options?.or]);
}
onUserChange(callback: (user: CurrentUser | null) => void) {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
return this._currentUserCache.onChange([tokenStore], (userJson) => {
callback(this._currentUserFromJson(userJson, tokenStore));
const session = this._getSession();
return this._currentUserCache.onChange([session], (userJson) => {
callback(this._currentUserFromJson(userJson, session));
});
}
protected async _updateUser(update: UserUpdateJson, tokenStore: TokenStore) {
const res = await this._interface.setClientUserCustomizableData(update, tokenStore);
await this._refreshUser(tokenStore);
protected async _updateUser(update: UserUpdateJson, session: Session) {
const res = await this._interface.setClientUserCustomizableData(update, session);
await this._refreshUser(session);
return res;
}
@ -776,42 +842,45 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async signInWithCredential(options: {
email: string,
password: string,
}): Promise<KnownErrors["EmailPasswordMismatch"] | undefined> {
}): Promise<KnownErrors["EmailPasswordMismatch"] | void> {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
const errorCode = await this._interface.signInWithCredential(options.email, options.password, tokenStore);
if (!errorCode) {
await this.redirectToAfterSignIn({ replace: true });
const session = this._getSession();
const result = await this._interface.signInWithCredential(options.email, options.password, session);
if (!(result instanceof KnownError)) {
await this._signInToAccountWithTokens(result);
return await this.redirectToAfterSignIn({ replace: true });
}
return errorCode;
return result;
}
async signUpWithCredential(options: {
email: string,
password: string,
}): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors['PasswordRequirementsNotMet'] | undefined> {
}): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors['PasswordRequirementsNotMet'] | void> {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
const session = this._getSession();
const emailVerificationRedirectUrl = constructRedirectUrl(this.urls.emailVerification);
const errorCode = await this._interface.signUpWithCredential(
const result = await this._interface.signUpWithCredential(
options.email,
options.password,
emailVerificationRedirectUrl,
tokenStore
session
);
if (!errorCode) {
await this.redirectToAfterSignUp({ replace: true });
if (!(result instanceof KnownError)) {
await this._signInToAccountWithTokens(result);
return await this.redirectToAfterSignUp({ replace: true });
}
return errorCode;
return result;
}
async signInWithMagicLink(code: string): Promise<KnownErrors["MagicLinkError"] | undefined> {
async signInWithMagicLink(code: string): Promise<KnownErrors["MagicLinkError"] | void> {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
const result = await this._interface.signInWithMagicLink(code, tokenStore);
const session = this._getSession();
const result = await this._interface.signInWithMagicLink(code, session);
if (result instanceof KnownError) {
return result;
}
await this._signInToAccountWithTokens(result);
if (result.newUser) {
await this.redirectToAfterSignUp({ replace: true });
} else {
@ -821,9 +890,9 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async callOAuthCallback() {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
const result = await callOAuthCallback(this._interface, tokenStore, this.urls.oauthCallback);
const result = await callOAuthCallback(this._interface, this.urls.oauthCallback);
if (result) {
await this._signInToAccountWithTokens(result);
if (result.newUser) {
await this.redirectToAfterSignUp({ replace: true });
return true;
@ -835,21 +904,21 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return false;
}
protected async _signOut(tokenStore: TokenStore): Promise<void> {
await this._interface.signOut(tokenStore);
protected async _signOut(session: Session): Promise<void> {
await this._interface.signOut(session);
await this.redirectToAfterSignOut();
}
protected async _sendVerificationEmail(tokenStore: TokenStore): Promise<KnownErrors["EmailAlreadyVerified"] | undefined> {
protected async _sendVerificationEmail(session: Session): Promise<KnownErrors["EmailAlreadyVerified"] | void> {
const emailVerificationRedirectUrl = constructRedirectUrl(this.urls.emailVerification);
return await this._interface.sendVerificationEmail(emailVerificationRedirectUrl, tokenStore);
return await this._interface.sendVerificationEmail(emailVerificationRedirectUrl, session);
}
protected async _updatePassword(
options: { oldPassword: string, newPassword: string },
tokenStore: TokenStore
): Promise<KnownErrors["PasswordMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | undefined> {
return await this._interface.updatePassword(options, tokenStore);
session: Session
): Promise<KnownErrors["PasswordMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | void> {
return await this._interface.updatePassword(options, session);
}
async signOut(): Promise<void> {
@ -864,7 +933,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
useProject(): ClientProjectJson {
return useCache(this._currentProjectCache, [], "useProject()");
return useAsyncCache(this._currentProjectCache, [], "useProject()");
}
onProjectChange(callback: (project: ClientProjectJson) => void) {
@ -873,53 +942,53 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
protected async _listOwnedProjects(): Promise<Project[]> {
this._ensureInternalProject();
const tokenStore = this._getTokenStore();
const json = await this._ownedProjectsCache.getOrWait([tokenStore], "write-only");
const session = this._getSession();
const json = await this._ownedProjectsCache.getOrWait([session], "write-only");
return json.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
this._createAdminInterface(j.id, session),
() => this._refreshOwnedProjects(session),
));
}
protected _useOwnedProjects(): Project[] {
this._ensureInternalProject();
const tokenStore = this._getTokenStore();
const json = useCache(this._ownedProjectsCache, [tokenStore], "useOwnedProjects()");
const session = this._getSession();
const json = useAsyncCache(this._ownedProjectsCache, [session], "useOwnedProjects()");
return useMemo(() => json.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
this._createAdminInterface(j.id, session),
() => this._refreshOwnedProjects(session),
)), [json]);
}
protected _onOwnedProjectsChange(callback: (projects: Project[]) => void) {
this._ensureInternalProject();
const tokenStore = this._getTokenStore();
return this._ownedProjectsCache.onChange([tokenStore], (projects) => {
const session = this._getSession();
return this._ownedProjectsCache.onChange([session], (projects) => {
callback(projects.map((j) => this._projectAdminFromJson(
j,
this._createAdminInterface(j.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
this._createAdminInterface(j.id, session),
() => this._refreshOwnedProjects(session),
)));
});
}
protected async _createProject(newProject: ProjectUpdateOptions & { displayName: string }): Promise<Project> {
this._ensureInternalProject();
const tokenStore = this._getTokenStore();
const json = await this._interface.createProject(newProject, tokenStore);
const session = this._getSession();
const json = await this._interface.createProject(newProject, session);
const res = this._projectAdminFromJson(
json,
this._createAdminInterface(json.id, tokenStore),
() => this._refreshOwnedProjects(tokenStore),
this._createAdminInterface(json.id, session),
() => this._refreshOwnedProjects(session),
);
await this._refreshOwnedProjects(tokenStore);
await this._refreshOwnedProjects(session);
return res;
}
protected async _refreshUser(tokenStore: TokenStore) {
await this._currentUserCache.refresh([tokenStore]);
protected async _refreshUser(session: Session) {
await this._currentUserCache.refresh([session]);
}
protected async _refreshUsers() {
@ -930,8 +999,8 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
await this._currentProjectCache.refresh([]);
}
protected async _refreshOwnedProjects(tokenStore: TokenStore) {
await this._ownedProjectsCache.refresh([tokenStore]);
protected async _refreshOwnedProjects(session: Session) {
await this._ownedProjectsCache.refresh([session]);
}
static get [stackAppInternalsSymbol]() {
@ -975,7 +1044,7 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
};
},
setCurrentUser: (userJsonPromise: Promise<UserJson | null>) => {
runAsynchronously(this._currentUserCache.forceSetCachedValueAsync([this._getTokenStore()], userJsonPromise));
runAsynchronously(this._currentUserCache.forceSetCachedValueAsync([this._getSession()], userJsonPromise));
},
};
};
@ -986,8 +1055,8 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
declare protected _interface: StackServerInterface;
// TODO override the client user cache to use the server user cache, so we save some requests
private readonly _currentServerUserCache = createCacheByTokenStore(async (tokenStore) => {
const user = await this._interface.getServerUserByToken(tokenStore);
private readonly _currentServerUserCache = createCacheBySession(async (session) => {
const user = await this._interface.getServerUserByToken(session);
return Result.or(user, null);
});
private readonly _serverUsersCache = createCache(async () => {
@ -1093,15 +1162,15 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
});
},
async listTeams() {
const teams = await app._serverTeamsCache.getOrWait([app._getTokenStore()], "write-only");
const teams = await app._serverTeamsCache.getOrWait([app._getSession()], "write-only");
return teams.map((json) => app._serverTeamFromJson(json));
},
useTeams() {
const teams = useCache(app._serverTeamsCache, [app._getTokenStore()], "user.useTeams()");
const teams = useAsyncCache(app._serverTeamsCache, [app._getSession()], "user.useTeams()");
return useMemo(() => teams.map((json) => app._serverTeamFromJson(json)), [teams]);
},
onTeamsChange(callback: (value: ServerTeam[], oldValue: ServerTeam[] | undefined) => void) {
return app._serverTeamsCache.onChange([app._getTokenStore()], (value, oldValue) => {
return app._serverTeamsCache.onChange([app._getSession()], (value, oldValue) => {
callback(value.map((json) => app._serverTeamFromJson(json)), oldValue?.map((json) => app._serverTeamFromJson(json)));
});
},
@ -1110,7 +1179,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
return permissions.map((json) => app._serverPermissionFromJson(json));
},
usePermissions(scope: Team, options?: { direct?: boolean }): ServerPermission[] {
const permissions = useCache(app._serverTeamUserPermissionsCache, [scope.id, json.id, 'team', !!options?.direct], "user.usePermissions()");
const permissions = useAsyncCache(app._serverTeamUserPermissionsCache, [scope.id, json.id, 'team', !!options?.direct], "user.usePermissions()");
return useMemo(() => permissions.map((json) => app._serverPermissionFromJson(json)), [permissions]);
},
usePermission(scope: Team, permissionId: string): ServerPermission | null {
@ -1131,21 +1200,18 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
};
}
protected _currentServerUserFromJson(json: ServerUserJson, tokenStore: TokenStore): ProjectCurrentSeverUser<ProjectId>;
protected _currentServerUserFromJson(json: ServerUserJson | null, tokenStore: TokenStore): ProjectCurrentSeverUser<ProjectId> | null;
protected _currentServerUserFromJson(json: ServerUserJson | null, tokenStore: TokenStore): ProjectCurrentSeverUser<ProjectId> | null {
protected _currentServerUserFromJson(json: ServerUserJson, session: Session): ProjectCurrentSeverUser<ProjectId>;
protected _currentServerUserFromJson(json: ServerUserJson | null, session: Session): ProjectCurrentSeverUser<ProjectId> | null;
protected _currentServerUserFromJson(json: ServerUserJson | null, session: Session): ProjectCurrentSeverUser<ProjectId> | null {
if (json === null) return null;
const app = this;
const nonCurrentServerUser = this._serverUserFromJson(json);
const currentUser: CurrentServerUser = {
...nonCurrentServerUser,
tokenStore,
async refreshAccessToken() {
await app._interface.refreshAccessToken(tokenStore);
},
session,
async delete() {
const res = await nonCurrentServerUser.delete();
await app._refreshUser(tokenStore);
await app._refreshUser(session);
return res;
},
async updateSelectedTeam(team: Team | null) {
@ -1153,20 +1219,20 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
},
async update(update: ServerUserUpdateJson) {
const res = await nonCurrentServerUser.update(update);
await app._refreshUser(tokenStore);
await app._refreshUser(session);
return res;
},
signOut() {
return app._signOut(tokenStore);
return app._signOut(session);
},
getClientUser() {
return app._currentUserFromJson(json, tokenStore);
return app._currentUserFromJson(json, session);
},
sendVerificationEmail() {
return app._sendVerificationEmail(tokenStore);
return app._sendVerificationEmail(session);
},
updatePassword(options: { oldPassword: string, newPassword: string}) {
return app._updatePassword(options, tokenStore);
return app._updatePassword(options, session);
},
};
@ -1227,7 +1293,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
await app._serverTeamsCache.refresh([]);
},
useMembers() {
const result = useCache(app._serverTeamMembersCache, [json.id], "team.useUsers()");
const result = useAsyncCache(app._serverTeamMembersCache, [json.id], "team.useUsers()");
return useMemo(() => result.map((u) => app._serverTeamMemberFromJson(u)), [result]);
},
async addUser(userId) {
@ -1252,9 +1318,9 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
async getServerUser(): Promise<ProjectCurrentSeverUser<ProjectId> | null> {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
const userJson = await this._currentServerUserCache.getOrWait([tokenStore], "write-only");
return this._currentServerUserFromJson(userJson, tokenStore);
const session = this._getSession();
const userJson = await this._currentServerUserCache.getOrWait([session], "write-only");
return this._currentServerUserFromJson(userJson, session);
}
async getServerUserById(userId: string): Promise<ServerUser | null> {
@ -1265,23 +1331,23 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
useServerUser(options?: { required: boolean }): ProjectCurrentSeverUser<ProjectId> | null {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
const userJson = useCache(this._currentServerUserCache, [tokenStore], "useServerUser()");
const session = this._getSession();
const userJson = useAsyncCache(this._currentServerUserCache, [session], "useServerUser()");
return useMemo(() => {
if (options?.required && userJson === null) {
use(this.redirectToSignIn());
}
return this._currentServerUserFromJson(userJson, tokenStore);
}, [userJson, tokenStore, options?.required]);
return this._currentServerUserFromJson(userJson, session);
}, [userJson, session, options?.required]);
}
onServerUserChange(callback: (user: CurrentServerUser | null) => void) {
this._ensurePersistentTokenStore();
const tokenStore = this._getTokenStore();
return this._currentServerUserCache.onChange([tokenStore], (userJson) => {
callback(this._currentServerUserFromJson(userJson, tokenStore));
const session = this._getSession();
return this._currentServerUserCache.onChange([session], (userJson) => {
callback(this._currentServerUserFromJson(userJson, session));
});
}
@ -1291,7 +1357,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
useServerUsers(): ServerUser[] {
const json = useCache(this._serverUsersCache, [], "useServerUsers()");
const json = useAsyncCache(this._serverUsersCache, [], "useServerUsers()");
return useMemo(() => {
return json.map((j) => this._serverUserFromJson(j));
}, [json]);
@ -1308,7 +1374,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
usePermissionDefinitions(): ServerPermissionDefinitionJson[] {
return useCache(this._serverTeamPermissionDefinitionsCache, [], "usePermissions()");
return useAsyncCache(this._serverTeamPermissionDefinitionsCache, [], "usePermissions()");
}
_serverPermissionFromJson(json: ServerPermissionDefinitionJson): ServerPermission {
@ -1348,7 +1414,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
useTeams(): ServerTeam[] {
const teams = useCache(this._serverTeamsCache, [], "useServerTeams()");
const teams = useAsyncCache(this._serverTeamsCache, [], "useServerTeams()");
return useMemo(() => {
return teams.map((t) => this._serverTeamFromJson(t));
}, [teams]);
@ -1366,10 +1432,10 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}, [teams, teamId]);
}
protected override async _refreshUser(tokenStore: TokenStore) {
protected override async _refreshUser(session: Session) {
await Promise.all([
super._refreshUser(tokenStore),
this._currentServerUserCache.refresh([tokenStore]),
super._refreshUser(session),
this._currentServerUserCache.refresh([session]),
]);
}
@ -1381,7 +1447,7 @@ class _StackServerAppImpl<HasTokenStore extends boolean, ProjectId extends strin
}
useEmailTemplates(): ListEmailTemplatesCrud['Server']['Read'] {
return useCache(this._serverEmailTemplatesCache, [], "useEmailTemplates()");
return useAsyncCache(this._serverEmailTemplatesCache, [], "useEmailTemplates()");
}
async listEmailTemplates(): Promise<ListEmailTemplatesCrud['Server']['Read']> {
@ -1416,9 +1482,8 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
baseUrl: options.baseUrl ?? getDefaultBaseUrl(),
projectId: options.projectId ?? getDefaultProjectId(),
clientVersion,
..."projectOwnerTokens" in options ? {
projectOwnerTokens: options.projectOwnerTokens,
refreshProjectOwnerTokens: options.refreshProjectOwnerTokens,
..."projectOwnerSession" in options ? {
projectOwnerSession: options.projectOwnerSession,
} : {
publishableClientKey: options.publishableClientKey ?? getDefaultPublishableClientKey(),
secretServerKey: options.secretServerKey ?? getDefaultSecretServerKey(),
@ -1482,7 +1547,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
}
useProjectAdmin(): Project {
const json = useCache(this._adminProjectCache, [], "useProjectAdmin()");
const json = useAsyncCache(this._adminProjectCache, [], "useProjectAdmin()");
return useMemo(() => this._projectAdminFromJson(
json,
this._interface,
@ -1506,7 +1571,7 @@ class _StackAdminAppImpl<HasTokenStore extends boolean, ProjectId extends string
}
useApiKeySets(): ApiKeySet[] {
const json = useCache(this._apiKeySetsCache, [], "useApiKeySets()");
const json = useAsyncCache(this._apiKeySetsCache, [], "useApiKeySets()");
return useMemo(() => {
return json.map((j) => this._createApiKeySetFromJson(j));
}, [json]);
@ -1541,13 +1606,12 @@ type RedirectToOptions = {
};
type Auth<T, C> = {
readonly tokenStore: TokenStore,
refreshAccessToken(this: T): Promise<void>,
readonly session: Session,
updateSelectedTeam(this: T, team: Team | null): Promise<void>,
update(this: T, user: C): Promise<void>,
signOut(this: T): Promise<void>,
sendVerificationEmail(this: T): Promise<KnownErrors["EmailAlreadyVerified"] | undefined>,
updatePassword(this: T, options: { oldPassword: string, newPassword: string}): Promise<KnownErrors["PasswordMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | undefined>,
sendVerificationEmail(this: T): Promise<KnownErrors["EmailAlreadyVerified"] | void>,
updatePassword(this: T, options: { oldPassword: string, newPassword: string}): Promise<KnownErrors["PasswordMismatch"] | KnownErrors["PasswordRequirementsNotMet"] | void>,
};
type InternalAuth<T> = {
@ -1765,15 +1829,15 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
readonly urls: Readonly<HandlerUrls>,
signInWithOAuth(provider: string): Promise<void>,
signInWithCredential(options: { email: string, password: string }): Promise<KnownErrors["EmailPasswordMismatch"] | undefined>,
signUpWithCredential(options: { email: string, password: string }): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"] | undefined>,
signInWithCredential(options: { email: string, password: string }): Promise<KnownErrors["EmailPasswordMismatch"] | void>,
signUpWithCredential(options: { email: string, password: string }): Promise<KnownErrors["UserEmailAlreadyExists"] | KnownErrors["PasswordRequirementsNotMet"] | void>,
callOAuthCallback(): Promise<boolean>,
sendForgotPasswordEmail(email: string): Promise<KnownErrors["UserNotFound"] | undefined>,
sendMagicLinkEmail(email: string): Promise<KnownErrors["RedirectUrlNotWhitelisted"] | undefined>,
resetPassword(options: { code: string, password: string }): Promise<KnownErrors["PasswordResetError"] | undefined>,
verifyPasswordResetCode(code: string): Promise<KnownErrors["PasswordResetCodeError"] | undefined>,
verifyEmail(code: string): Promise<KnownErrors["EmailVerificationError"] | undefined>,
signInWithMagicLink(code: string): Promise<KnownErrors["MagicLinkError"] | undefined>,
sendForgotPasswordEmail(email: string): Promise<KnownErrors["UserNotFound"] | void>,
sendMagicLinkEmail(email: string): Promise<KnownErrors["RedirectUrlNotWhitelisted"] | void>,
resetPassword(options: { code: string, password: string }): Promise<KnownErrors["PasswordResetError"] | void>,
verifyPasswordResetCode(code: string): Promise<KnownErrors["PasswordResetCodeError"] | void>,
verifyEmail(code: string): Promise<KnownErrors["EmailVerificationError"] | void>,
signInWithMagicLink(code: string): Promise<KnownErrors["MagicLinkError"] | void>,
[stackAppInternalsSymbol]: {
toClientJson(): StackClientAppJson<HasTokenStore, ProjectId>,

View File

@ -5,6 +5,7 @@ import { StackClientApp, StackClientAppJson, stackAppInternalsSymbol } from "../
import React from "react";
import { UserJson } from "@stackframe/stack-shared";
import { useStackApp } from "..";
import { globalVar } from "@stackframe/stack-shared/src/utils/globals";
export const StackContext = React.createContext<null | {
app: StackClientApp<true>,
@ -18,7 +19,7 @@ export function StackProviderClient(props: {
const app = StackClientApp[stackAppInternalsSymbol].fromClientJson(appJson);
if (process.env.NODE_ENV === "development") {
(globalThis as any).stackApp = app;
globalVar.stackApp = app;
}
return (

View File

@ -3,6 +3,7 @@
import React, { useState } from 'react';
import { useServerInsertedHTML } from 'next/navigation';
import { ServerStyleSheet, StyleSheetManager } from 'styled-components';
import { isBrowserLike } from '@stackframe/stack-shared/src/utils/env';
export default function StyledComponentsRegistry({
children,
@ -19,11 +20,11 @@ export default function StyledComponentsRegistry({
return <>{styles}</>;
});
if (typeof window !== 'undefined') return <>{children}</>;
if (isBrowserLike()) return <>{children}</>;
return (
<StyleSheetManager sheet={styledComponentsStyleSheet.instance}>
{children}
</StyleSheetManager>
);
}
}

View File

@ -1,4 +0,0 @@
export function isClient() {
// TODO improve function name (could be confused because this may return false in client components during SSR)
return typeof window !== "undefined";
}

View File

@ -14,6 +14,6 @@
"sourceMap": true,
"declarationMap": true
},
"include": ["next-env.d.ts", "src/**/*", ".next/types/**/*.ts"],
"include": ["next-env.d.ts", "src/**/*", ".next/types/**/*.ts", "../stack-shared/src/utils/browsers.tsx"],
"exclude": ["node_modules", "dist"]
}