From d95696ee96bca563d28e290f7dfa1bc7cce24bff Mon Sep 17 00:00:00 2001 From: Stan Wohlwend Date: Thu, 6 Jun 2024 12:30:17 +0200 Subject: [PATCH] Refactor TokenStore into Session --- eslint-configs/defaults.js | 1 + .../projects/[projectId]/use-admin-app.tsx | 5 +- .../src/components/dev-error-notifier.tsx | 12 +- .../src/hooks/use-animation-frame.tsx | 3 +- .../src/route-handlers/smart-request.tsx | 2 +- .../src/interface/adminInterface.ts | 13 +- .../src/interface/clientInterface.ts | 265 +++++----- .../src/interface/serverInterface.ts | 21 +- packages/stack-shared/src/sessions.ts | 142 ++++++ packages/stack-shared/src/utils/crypto.tsx | 3 +- packages/stack-shared/src/utils/env.tsx | 6 +- packages/stack-shared/src/utils/errors.tsx | 5 + packages/stack-shared/src/utils/globals.tsx | 4 +- packages/stack-shared/src/utils/proxies.tsx | 59 +++ packages/stack-shared/src/utils/react.tsx | 3 +- packages/stack-shared/src/utils/results.tsx | 16 +- packages/stack-shared/src/utils/stores.tsx | 48 ++ packages/stack-shared/src/utils/strings.tsx | 39 ++ packages/stack-shared/src/utils/uuids.tsx | 4 +- packages/stack/src/lib/auth.ts | 3 - packages/stack/src/lib/stack-app.ts | 466 ++++++++++-------- .../src/providers/stack-provider-client.tsx | 3 +- .../providers/styled-components-registry.tsx | 5 +- packages/stack/src/utils/next.tsx | 4 - packages/stack/tsconfig.json | 2 +- 25 files changed, 751 insertions(+), 383 deletions(-) create mode 100644 packages/stack-shared/src/sessions.ts create mode 100644 packages/stack-shared/src/utils/proxies.tsx delete mode 100644 packages/stack/src/utils/next.tsx diff --git a/eslint-configs/defaults.js b/eslint-configs/defaults.js index dafc825a8..f5f3ed5fd 100644 --- a/eslint-configs/defaults.js +++ b/eslint-configs/defaults.js @@ -31,6 +31,7 @@ module.exports = { multilineDetection: "brackets", }, ], + "@typescript-eslint/no-unnecessary-condition": ["error", { allowConstantLoopConditions: true }], "no-restricted-syntax": [ "error", { diff --git a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx index 62fb965fe..7885d3b2f 100644 --- a/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx +++ b/packages/stack-server/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx @@ -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, }); }); diff --git a/packages/stack-server/src/components/dev-error-notifier.tsx b/packages/stack-server/src/components/dev-error-notifier.tsx index 7cac700c1..87f633f77 100644 --- a/packages/stack-server/src/components/dev-error-notifier.tsx +++ b/packages/stack-server/src/components/dev-error-notifier.tsx @@ -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)); + } }; } } diff --git a/packages/stack-server/src/hooks/use-animation-frame.tsx b/packages/stack-server/src/hooks/use-animation-frame.tsx index 72f0df718..7ab000199 100644 --- a/packages/stack-server/src/hooks/use-animation-frame.tsx +++ b/packages/stack-server/src/hooks/use-animation-frame.tsx @@ -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) => { diff --git a/packages/stack-server/src/route-handlers/smart-request.tsx b/packages/stack-server/src/route-handlers/smart-request.tsx index bed14a9d9..b188ac82e 100644 --- a/packages/stack-server/src/route-handlers/smart-request.tsx +++ b/packages/stack-server/src/route-handlers/smart-request.tsx @@ -48,7 +48,7 @@ async function validate(obj: unknown, schema: yup.Schema, 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")} diff --git a/packages/stack-shared/src/interface/adminInterface.ts b/packages/stack-shared/src/interface/adminInterface.ts index df4beb21c..b8240ad8c 100644 --- a/packages/stack-shared/src/interface/adminInterface.ts +++ b/packages/stack-shared/src/interface/adminInterface.ts @@ -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 { - const response = await this.sendAdminRequest(`/api-keys/${id}`, {}, tokenStore); + async getApiKeySet(id: string, session: Session): Promise { + const response = await this.sendAdminRequest(`/api-keys/${id}`, {}, session); return await response.json(); } } diff --git a/packages/stack-shared/src/interface/clientInterface.ts b/packages/stack-shared/src/interface/clientInterface.ts index 587f391c2..713fc3d92 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -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, + 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; -export type TokenStore = AsyncStore; - -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({ - 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[0], "refreshAccessTokenCallback">): Session { + const session = new Session({ + refreshAccessTokenCallback: async (refreshToken) => await this.fetchNewAccessToken(refreshToken), + ...options, + }); + return session; + } + protected async sendClientRequestAndCatchKnownError( path: string, requestOptions: RequestInit, - tokenStoreOrNull: TokenStore | null, + tokenStoreOrNull: Session | null, errorsToCatch: readonly E[], ): Promise >> { @@ -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> { - 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 { 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 { 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 { + session: Session + ): Promise { 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 { + session: Session, + ): Promise { 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 { + async signInWithMagicLink(code: string, session: Session): Promise { 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 { - 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 { + 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> { + async getClientUserByToken(tokenStore: Session): Promise> { const response = await this.sendClientRequest( "/current-user", {}, @@ -822,22 +809,22 @@ export class StackClientInterface { type: 'global' | 'team', direct: boolean, }, - tokenStore: TokenStore + session: Session ): Promise { 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 { + async listClientUserTeams(session: Session): Promise { 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 { - const response = await this.sendClientRequest("/projects", {}, tokenStore); + async listProjects(session: Session): Promise { + 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 { 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())); diff --git a/packages/stack-shared/src/interface/serverInterface.ts b/packages/stack-shared/src/interface/serverInterface.ts index 576c3e805..94a184dc2 100644 --- a/packages/stack-shared/src/interface/serverInterface.ts +++ b/packages/stack-shared/src/interface/serverInterface.ts @@ -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> { + async getServerUserByToken(session: Session): Promise> { 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 { 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 { + async listServerUserTeams(session: Session): Promise { const response = await this.sendServerRequest( "/current-user/teams?server=true", {}, - tokenStore, + session, ); const teams: ServerTeamJson[] = await response.json(); return teams; diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts new file mode 100644 index 000000000..22db38e06 --- /dev/null +++ b/packages/stack-shared/src/sessions.ts @@ -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; + 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(false); + + private _refreshPromise: Promise | null = null; + + constructor(private readonly _options: { + refreshAccessTokenCallback(refreshToken: RefreshToken): Promise, + 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 { + 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 { + 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 = 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; + } +} diff --git a/packages/stack-shared/src/utils/crypto.tsx b/packages/stack-shared/src/utils/crypto.tsx index 19a5a2fca..902b53e0b 100644 --- a/packages/stack-shared/src/utils/crypto.tsx +++ b/packages/stack-shared/src/utils/crypto.tsx @@ -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(); } diff --git a/packages/stack-shared/src/utils/env.tsx b/packages/stack-shared/src/utils/env.tsx index d15c29134..2b57190de 100644 --- a/packages/stack-shared/src/utils/env.tsx +++ b/packages/stack-shared/src/utils/env.tsx @@ -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. diff --git a/packages/stack-shared/src/utils/errors.tsx b/packages/stack-shared/src/utils/errors.tsx index 8f3f96d7e..3cfb77bee 100644 --- a/packages/stack-shared/src/utils/errors.tsx +++ b/packages/stack-shared/src/utils/errors.tsx @@ -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) { diff --git a/packages/stack-shared/src/utils/globals.tsx b/packages/stack-shared/src/utils/globals.tsx index c240fb8ca..d73807f44 100644 --- a/packages/stack-shared/src/utils/globals.tsx +++ b/packages/stack-shared/src/utils/globals.tsx @@ -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 { diff --git a/packages/stack-shared/src/utils/proxies.tsx b/packages/stack-shared/src/utils/proxies.tsx new file mode 100644 index 000000000..f2c667451 --- /dev/null +++ b/packages/stack-shared/src/utils/proxies.tsx @@ -0,0 +1,59 @@ +import { nicify } from "./strings"; + +export function logged(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; +} diff --git a/packages/stack-shared/src/utils/react.tsx b/packages/stack-shared/src/utils/react.tsx index 141f1b2f4..ebae0fc9d 100644 --- a/packages/stack-shared/src/utils/react.tsx +++ b/packages/stack-shared/src/utils/react.tsx @@ -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. diff --git a/packages/stack-shared/src/utils/results.tsx b/packages/stack-shared/src/utils/results.tsx index 6beafebea..b6c0ae0d5 100644 --- a/packages/stack-shared/src/utils/results.tsx +++ b/packages/stack-shared/src/utils/results.tsx @@ -1,4 +1,5 @@ import { wait } from "./promises"; +import { deindent } from "./strings"; export type Result = | { @@ -109,9 +110,20 @@ function mapResult(result: AsyncResult, } -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"; } diff --git a/packages/stack-shared/src/utils/stores.tsx b/packages/stack-shared/src/utils/stores.tsx index ca177ea59..bb51ca5a8 100644 --- a/packages/stack-shared/src/utils/stores.tsx +++ b/packages/stack-shared/src/utils/stores.tsx @@ -2,6 +2,54 @@ import { AsyncResult, Result } from "./results"; import { generateUuid } from "./uuids"; import { ReactPromise, pending, rejected, resolved } from "./promises"; +export type ReadonlyStore = { + 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 implements ReadonlyStore { + private readonly _callbacks: Map 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 = { isAvailable(): boolean, get(): AsyncResult, diff --git a/packages/stack-shared/src/utils/strings.tsx b/packages/stack-shared/src/utils/strings.tsx index baaaddd31..cc6755844 100644 --- a/packages/stack-shared/src/utils/strings.tsx +++ b/packages/stack-shared/src/utils/strings.tsx @@ -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}>`; + } + } +} diff --git a/packages/stack-shared/src/utils/uuids.tsx b/packages/stack-shared/src/utils/uuids.tsx index 7beda2aa5..984d04095 100644 --- a/packages/stack-shared/src/utils/uuids.tsx +++ b/packages/stack-shared/src/utils/uuids.tsx @@ -1,3 +1,5 @@ +import { globalVar } from "./globals"; + export function generateUuid() { - return globalThis.crypto.randomUUID(); + return globalVar.crypto.randomUUID(); } diff --git a/packages/stack/src/lib/auth.ts b/packages/stack/src/lib/auth.ts index bf08d8074..c5129d451 100644 --- a/packages/stack/src/lib/auth.ts +++ b/packages/stack/src/lib/auth.ts @@ -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 }); diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index 36fbe6cde..0ed0ead63 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -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 = | "nextjs-cookie" | "memory" | RequestLike - + | { accessToken: string, refreshToken: string } ) : HasTokenStore extends false ? null : TokenStoreInit | TokenStoreInit; @@ -134,8 +135,7 @@ export type StackAdminAppConstructorOptions, "publishableClientKey" | "secretServerKey"> & { - projectOwnerTokens: TokenStore, - refreshProjectOwnerTokens: () => Promise, + projectOwnerSession: Session, } ) ); @@ -147,54 +147,62 @@ export type StackClientAppJson({ + return new Store({ refreshToken: null, accessToken: null, }); } -let cookieTokenStore: TokenStore | null = null; -const cookieTokenStoreInitializer = (): TokenStore => { - if (!isClient()) { +let storedCookieTokenStore: Store | null = null; +const getCookieTokenStore = (): Store => { + if (!isBrowserLike()) { throw new Error("Cannot use cookie token store on the server!"); } - if (cookieTokenStore === null) { - cookieTokenStore = new AsyncStore(); + if (storedCookieTokenStore === null) { + const getCurrentValue = () => ({ + refreshToken: getCookie('stack-refresh'), + accessToken: getCookie('stack-access'), + }); + storedCookieTokenStore = new Store(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(cache: AsyncCache, dependencies: D, caller: string): T { +function useAsyncCache(cache: AsyncCache, dependencies: D, caller: string): T { // we explicitly don't want to run this hook in SSR suspendIfSsr(caller); @@ -218,6 +226,16 @@ function useCache(cache: AsyncCache, dependencies: D, } } +function useStore(store: Store): 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]>(); @@ -229,19 +247,13 @@ const createCache = (fetcher: (dependencies: D) => Promise(fetcher: (tokenStore: TokenStore, extraDependencies: D) => Promise ) => { - return new AsyncCache<[TokenStore, ...D], T>( - async ([tokenStore, ...extraDependencies]) => await fetcher(tokenStore, extraDependencies), +const createCacheBySession = (fetcher: (session: Session, extraDependencies: D) => Promise ) => { + 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; protected readonly _urlOptions: Partial; - 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(); - protected _getTokenStore(overrideTokenStoreInit?: TokenStoreInit): TokenStore { + private _requestTokenStores = new WeakMap>(); + protected _getOrCreateTokenStore(overrideTokenStoreInit?: TokenStoreInit): Store { 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(); - store.set({ + const store = new Store({ refreshToken: getCookie('stack-refresh'), accessToken: getCookie('stack-access'), }); @@ -375,11 +386,11 @@ class _StackClientAppImpl({ + const res = new Store({ refreshToken: parsed['stack-refresh'] || null, accessToken: parsed['stack-access'] || null, }); @@ -392,6 +403,64 @@ class _StackClientAppImpl, Map>(); + protected _getSessionFromTokenStore(tokenStore: Store): 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 { return (overrideTokenStoreInit !== undefined ? overrideTokenStoreInit : this._tokenStoreInit) !== null; } @@ -479,24 +548,25 @@ class _StackClientAppImpl 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 { - 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; - protected _currentUserFromJson(json: UserJson | null, tokenStore: TokenStore): ProjectCurrentUser | null; - protected _currentUserFromJson(json: UserJson | null, tokenStore: TokenStore): ProjectCurrentUser | null { + protected _currentUserFromJson(json: UserJson, session: Session): ProjectCurrentUser; + protected _currentUserFromJson(json: UserJson | null, session: Session): ProjectCurrentUser | null; + protected _currentUserFromJson(json: UserJson | null, session: Session): ProjectCurrentUser | 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 await this._interface.refreshAccessToken(tokenStore), + projectOwnerSession: session, }); } @@ -666,28 +732,28 @@ class _StackClientAppImpl { + async sendForgotPasswordEmail(email: string): Promise { const redirectUrl = constructRedirectUrl(this.urls.passwordReset); const error = await this._interface.sendForgotPasswordEmail(email, redirectUrl); return error; } - async sendMagicLinkEmail(email: string): Promise { + async sendMagicLinkEmail(email: string): Promise { const magicLinkRedirectUrl = constructRedirectUrl(this.urls.magicLinkCallback); const error = await this._interface.sendMagicLinkEmail(email, magicLinkRedirectUrl); return error; } - async resetPassword(options: { password: string, code: string }): Promise { + async resetPassword(options: { password: string, code: string }): Promise { const error = await this._interface.resetPassword(options); return error; } - async verifyPasswordResetCode(code: string): Promise { + async verifyPasswordResetCode(code: string): Promise { return await this._interface.verifyPasswordResetCode(code); } - async verifyEmail(code: string): Promise { + async verifyEmail(code: string): Promise { return await this._interface.verifyEmail(code); } @@ -696,8 +762,8 @@ class _StackClientAppImpl | null>; async getUser(options?: GetUserOptions): Promise | 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; @@ -724,8 +790,8 @@ class _StackClientAppImpl { - 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 { + }): Promise { 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 { + }): Promise { 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 { + async signInWithMagicLink(code: string): Promise { 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 { - await this._interface.signOut(tokenStore); + protected async _signOut(session: Session): Promise { + await this._interface.signOut(session); await this.redirectToAfterSignOut(); } - protected async _sendVerificationEmail(tokenStore: TokenStore): Promise { + protected async _sendVerificationEmail(session: Session): Promise { 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 { - return await this._interface.updatePassword(options, tokenStore); + session: Session + ): Promise { + return await this._interface.updatePassword(options, session); } async signOut(): Promise { @@ -864,7 +933,7 @@ class _StackClientAppImpl void) { @@ -873,53 +942,53 @@ class _StackClientAppImpl { 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 { 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) => { - runAsynchronously(this._currentUserCache.forceSetCachedValueAsync([this._getTokenStore()], userJsonPromise)); + runAsynchronously(this._currentUserCache.forceSetCachedValueAsync([this._getSession()], userJsonPromise)); }, }; }; @@ -986,8 +1055,8 @@ class _StackServerAppImpl { - 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 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 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; - protected _currentServerUserFromJson(json: ServerUserJson | null, tokenStore: TokenStore): ProjectCurrentSeverUser | null; - protected _currentServerUserFromJson(json: ServerUserJson | null, tokenStore: TokenStore): ProjectCurrentSeverUser | null { + protected _currentServerUserFromJson(json: ServerUserJson, session: Session): ProjectCurrentSeverUser; + protected _currentServerUserFromJson(json: ServerUserJson | null, session: Session): ProjectCurrentSeverUser | null; + protected _currentServerUserFromJson(json: ServerUserJson | null, session: Session): ProjectCurrentSeverUser | 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 result.map((u) => app._serverTeamMemberFromJson(u)), [result]); }, async addUser(userId) { @@ -1252,9 +1318,9 @@ class _StackServerAppImpl | 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 { @@ -1265,23 +1331,23 @@ class _StackServerAppImpl | 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 { return json.map((j) => this._serverUserFromJson(j)); }, [json]); @@ -1308,7 +1374,7 @@ class _StackServerAppImpl { return teams.map((t) => this._serverTeamFromJson(t)); }, [teams]); @@ -1366,10 +1432,10 @@ class _StackServerAppImpl { @@ -1416,9 +1482,8 @@ class _StackAdminAppImpl this._projectAdminFromJson( json, this._interface, @@ -1506,7 +1571,7 @@ class _StackAdminAppImpl { return json.map((j) => this._createApiKeySetFromJson(j)); }, [json]); @@ -1541,13 +1606,12 @@ type RedirectToOptions = { }; type Auth = { - readonly tokenStore: TokenStore, - refreshAccessToken(this: T): Promise, + readonly session: Session, updateSelectedTeam(this: T, team: Team | null): Promise, update(this: T, user: C): Promise, signOut(this: T): Promise, - sendVerificationEmail(this: T): Promise, - updatePassword(this: T, options: { oldPassword: string, newPassword: string}): Promise, + sendVerificationEmail(this: T): Promise, + updatePassword(this: T, options: { oldPassword: string, newPassword: string}): Promise, }; type InternalAuth = { @@ -1765,15 +1829,15 @@ export type StackClientApp, signInWithOAuth(provider: string): Promise, - signInWithCredential(options: { email: string, password: string }): Promise, - signUpWithCredential(options: { email: string, password: string }): Promise, + signInWithCredential(options: { email: string, password: string }): Promise, + signUpWithCredential(options: { email: string, password: string }): Promise, callOAuthCallback(): Promise, - sendForgotPasswordEmail(email: string): Promise, - sendMagicLinkEmail(email: string): Promise, - resetPassword(options: { code: string, password: string }): Promise, - verifyPasswordResetCode(code: string): Promise, - verifyEmail(code: string): Promise, - signInWithMagicLink(code: string): Promise, + sendForgotPasswordEmail(email: string): Promise, + sendMagicLinkEmail(email: string): Promise, + resetPassword(options: { code: string, password: string }): Promise, + verifyPasswordResetCode(code: string): Promise, + verifyEmail(code: string): Promise, + signInWithMagicLink(code: string): Promise, [stackAppInternalsSymbol]: { toClientJson(): StackClientAppJson, diff --git a/packages/stack/src/providers/stack-provider-client.tsx b/packages/stack/src/providers/stack-provider-client.tsx index 6c851d6fa..66ca93b95 100644 --- a/packages/stack/src/providers/stack-provider-client.tsx +++ b/packages/stack/src/providers/stack-provider-client.tsx @@ -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, @@ -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 ( diff --git a/packages/stack/src/providers/styled-components-registry.tsx b/packages/stack/src/providers/styled-components-registry.tsx index 9f2a5d395..3c0fbadfa 100644 --- a/packages/stack/src/providers/styled-components-registry.tsx +++ b/packages/stack/src/providers/styled-components-registry.tsx @@ -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 ( {children} ); -} \ No newline at end of file +} diff --git a/packages/stack/src/utils/next.tsx b/packages/stack/src/utils/next.tsx deleted file mode 100644 index 7e5213bca..000000000 --- a/packages/stack/src/utils/next.tsx +++ /dev/null @@ -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"; -} diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index 2f14b6e74..87d69d3f8 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -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"] }