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 7885d3b2f..1f02fad09 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 @@ -16,7 +16,7 @@ const createAdminApp = cacheFunction((baseUrl: string, projectId: string, userId baseUrl, projectId, tokenStore: null, - projectOwnerSession: usersMap.get(userId)!.session, + projectOwnerSession: usersMap.get(userId)!._internalSession, }); }); diff --git a/packages/stack-shared/src/interface/adminInterface.ts b/packages/stack-shared/src/interface/adminInterface.ts index b8240ad8c..a189112fa 100644 --- a/packages/stack-shared/src/interface/adminInterface.ts +++ b/packages/stack-shared/src/interface/adminInterface.ts @@ -1,6 +1,6 @@ import { ServerAuthApplicationOptions, StackServerInterface } from "./serverInterface"; import { EmailConfigJson, ProjectJson, SharedProvider, StandardProvider } from "./clientInterface"; -import { Session } from "../sessions"; +import { InternalSession } from "../sessions"; export type AdminAuthApplicationOptions = Readonly< ServerAuthApplicationOptions & @@ -9,7 +9,7 @@ export type AdminAuthApplicationOptions = Readonly< superSecretAdminKey: string, } | { - projectOwnerSession: Session, + projectOwnerSession: InternalSession, } ) > @@ -86,7 +86,7 @@ export class StackAdminInterface extends StackServerInterface { super(options); } - protected async sendAdminRequest(path: string, options: RequestInit, session: Session | null, requestType: "admin" = "admin") { + protected async sendAdminRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "admin" = "admin") { return await this.sendServerRequest( path, { @@ -169,7 +169,7 @@ export class StackAdminInterface extends StackServerInterface { ); } - async getApiKeySet(id: string, session: Session): Promise { + async getApiKeySet(id: string, session: InternalSession): 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 a3eaa4acd..967cce72e 100644 --- a/packages/stack-shared/src/interface/clientInterface.ts +++ b/packages/stack-shared/src/interface/clientInterface.ts @@ -8,7 +8,7 @@ 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 { AccessToken, RefreshToken, InternalSession } from '../sessions'; import { globalVar } from '../utils/globals'; import { logged } from '../utils/proxies'; @@ -56,7 +56,7 @@ export type ClientInterfaceOptions = { } & ({ publishableClientKey: string, } | { - projectOwnerSession: Session, + projectOwnerSession: InternalSession, }); export type SharedProvider = "shared-github" | "shared-google" | "shared-facebook" | "shared-microsoft" | "shared-spotify"; @@ -242,7 +242,7 @@ export class StackClientInterface { protected async sendClientRequest( path: string, requestOptions: RequestInit, - session: Session | null, + session: InternalSession | null, requestType: "client" | "server" | "admin" = "client", ) { session ??= this.createSession({ @@ -259,8 +259,8 @@ export class StackClientInterface { ); } - public createSession(options: Omit[0], "refreshAccessTokenCallback">): Session { - const session = new Session({ + public createSession(options: Omit[0], "refreshAccessTokenCallback">): InternalSession { + const session = new InternalSession({ refreshAccessTokenCallback: async (refreshToken) => await this.fetchNewAccessToken(refreshToken), ...options, }); @@ -270,7 +270,7 @@ export class StackClientInterface { protected async sendClientRequestAndCatchKnownError( path: string, requestOptions: RequestInit, - tokenStoreOrNull: Session | null, + tokenStoreOrNull: InternalSession | null, errorsToCatch: readonly E[], ): Promise { const res = await this.sendClientRequestAndCatchKnownError( "/auth/send-verification-email", @@ -541,7 +541,7 @@ export class StackClientInterface { async updatePassword( options: { oldPassword: string, newPassword: string }, - session: Session + session: InternalSession ): Promise { const res = await this.sendClientRequestAndCatchKnownError( "/auth/update-password", @@ -593,7 +593,7 @@ export class StackClientInterface { async signInWithCredential( email: string, password: string, - session: Session + session: InternalSession ): Promise { const res = await this.sendClientRequestAndCatchKnownError( "/auth/signin", @@ -626,7 +626,7 @@ export class StackClientInterface { email: string, password: string, emailVerificationRedirectUrl: string, - session: Session, + session: InternalSession, ): Promise { const res = await this.sendClientRequestAndCatchKnownError( "/auth/signup", @@ -656,7 +656,7 @@ export class StackClientInterface { }; } - async signInWithMagicLink(code: string, session: Session): Promise { + async signInWithMagicLink(code: string, session: InternalSession): Promise { const res = await this.sendClientRequestAndCatchKnownError( "/auth/magic-link-verification", { @@ -766,7 +766,7 @@ export class StackClientInterface { }; } - async signOut(session: Session): Promise { + async signOut(session: InternalSession): Promise { const tokenObj = await session.getPotentiallyExpiredTokens(); if (tokenObj) { if (!tokenObj.refreshToken) { @@ -789,10 +789,10 @@ export class StackClientInterface { await res.json(); } } - session.invalidate(); + session.markInvalid(); } - async getClientUserByToken(tokenStore: Session): Promise> { + async getClientUserByToken(tokenStore: InternalSession): Promise> { const response = await this.sendClientRequest( "/current-user", {}, @@ -809,7 +809,7 @@ export class StackClientInterface { type: 'global' | 'team', direct: boolean, }, - session: Session + session: InternalSession ): Promise { const response = await this.sendClientRequest( `/current-user/teams/${options.teamId}/permissions?type=${options.type}&direct=${options.direct}`, @@ -820,7 +820,7 @@ export class StackClientInterface { return permissions; } - async listClientUserTeams(session: Session): Promise { + async listClientUserTeams(session: InternalSession): Promise { const response = await this.sendClientRequest( "/current-user/teams", {}, @@ -837,7 +837,7 @@ export class StackClientInterface { return Result.ok(project); } - async setClientUserCustomizableData(update: UserUpdateJson, session: Session) { + async setClientUserCustomizableData(update: UserUpdateJson, session: InternalSession) { await this.sendClientRequest( "/current-user", { @@ -851,7 +851,7 @@ export class StackClientInterface { ); } - async listProjects(session: Session): Promise { + async listProjects(session: InternalSession): Promise { const response = await this.sendClientRequest("/projects", {}, session); if (!response.ok) { throw new Error("Failed to list projects: " + response.status + " " + (await response.text())); @@ -863,7 +863,7 @@ export class StackClientInterface { async createProject( project: ProjectUpdateOptions & { displayName: string }, - session: Session, + session: InternalSession, ): Promise { const fetchResponse = await this.sendClientRequest( "/projects", diff --git a/packages/stack-shared/src/interface/serverInterface.ts b/packages/stack-shared/src/interface/serverInterface.ts index 94a184dc2..13aeec3a4 100644 --- a/packages/stack-shared/src/interface/serverInterface.ts +++ b/packages/stack-shared/src/interface/serverInterface.ts @@ -11,7 +11,7 @@ import { import { Result } from "../utils/results"; import { ReadonlyJson } from "../utils/json"; import { EmailTemplateCrud, ListEmailTemplatesCrud } from "./crud/email-templates"; -import { Session } from "../sessions"; +import { InternalSession } from "../sessions"; export type ServerUserJson = UserJson & { serverMetadata: ReadonlyJson, @@ -50,7 +50,7 @@ export type ServerAuthApplicationOptions = ( readonly secretServerKey: string, } | { - readonly projectOwnerSession: Session, + readonly projectOwnerSession: InternalSession, } ) ); @@ -63,7 +63,7 @@ export class StackServerInterface extends StackClientInterface { super(options); } - protected async sendServerRequest(path: string, options: RequestInit, session: Session | null, requestType: "server" | "admin" = "server") { + protected async sendServerRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "server" | "admin" = "server") { return await this.sendClientRequest( path, { @@ -78,7 +78,7 @@ export class StackServerInterface extends StackClientInterface { ); } - async getServerUserByToken(session: Session): Promise> { + async getServerUserByToken(session: InternalSession): Promise> { const response = await this.sendServerRequest( "/current-user?server=true", {}, @@ -106,7 +106,7 @@ export class StackServerInterface extends StackClientInterface { type: 'global' | 'team', direct: boolean, }, - session: Session + session: InternalSession ): Promise { const response = await this.sendServerRequest( `/current-user/teams/${options.teamId}/permissions?type=${options.type}&direct=${options.direct}&server=true`, @@ -117,7 +117,7 @@ export class StackServerInterface extends StackClientInterface { return permissions; } - async listServerUserTeams(session: Session): Promise { + async listServerUserTeams(session: InternalSession): Promise { const response = await this.sendServerRequest( "/current-user/teams?server=true", {}, diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts index 22db38e06..a0e4f10e3 100644 --- a/packages/stack-shared/src/sessions.ts +++ b/packages/stack-shared/src/sessions.ts @@ -16,17 +16,17 @@ export class RefreshToken { } /** - * 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. + * An InternalSession 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. + * A session never changes which user or session it belongs to, but the tokens in it may change over time. */ -export class Session { +export class InternalSession { /** * 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. + * This is useful for caching and indexing sessions. */ public readonly sessionKey: string; @@ -52,7 +52,7 @@ export class Session { }) { 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 }); + this.sessionKey = InternalSession.calculateSessionKey({ accessToken: _options.accessToken ?? null, refreshToken: _options.refreshToken }); } static calculateSessionKey(ofTokens: { refreshToken: string | null, accessToken?: string | null }): string { @@ -65,7 +65,10 @@ export class Session { } } - invalidate() { + /** + * Marks the session object as invalid, meaning that the refresh and access tokens can no longer be used. + */ + markInvalid() { this._accessToken.set(null); this._knownToBeInvalid.set(true); } @@ -74,12 +77,28 @@ export class Session { return this._knownToBeInvalid.onChange(() => callback()); } + /** + * Returns the access token if it is found in the cache, fetching it otherwise. + * + * This is usually the function you want to call to get an access token. When using the access token, you should catch errors that occur if it expires, and call `markAccessTokenExpired` to mark the token as expired if so (after which a call to this function will always refetch the token). + * + * @returns null if the session is known to be invalid, cached tokens if they exist in the cache (which may or may not be valid still), or new tokens otherwise. + */ 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> { + /** + * Fetches new tokens that are, at the time of fetching, guaranteed to be valid. + * + * The newly generated tokens are shortlived, so it's good practice not to rely on their validity (if possible). However, this function is useful in some cases where you only want to pass access tokens to a service, and you want to make sure said access token has the longest possible lifetime. + * + * In most cases, you should prefer `getPotentiallyExpiredTokens` with a fallback to `markAccessTokenExpired` and a retry mechanism if the endpoint rejects the token. + * + * @returns null if the session is known to be invalid, or new tokens otherwise (which, at the time of fetching, are guaranteed to be valid). + */ + async fetchNewTokens(): Promise<{ accessToken: AccessToken, refreshToken: RefreshToken | null } | null> { const accessToken = await this._getNewlyFetchedAccessToken(); return accessToken ? { accessToken, refreshToken: this._refreshToken } : null; } @@ -132,7 +151,7 @@ export class Session { this._refreshPromise = null; this._accessToken.set(accessToken); if (!accessToken) { - this.invalidate(); + this.markInvalid(); } } return accessToken; diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index 8e06a60cc..6d16c8daa 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -22,8 +22,9 @@ 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 { Session } from "@stackframe/stack-shared/dist/sessions"; +import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; import { useTrigger } from "@stackframe/stack-shared/dist/hooks/use-trigger"; +import { pick } from "@stackframe/stack-shared/src/utils/objects"; // NextNavigation.useRouter does not exist in react-server environments and some bundlers try to be helpful and throw a warning. Ignore the warning. @@ -137,7 +138,7 @@ export type StackAdminAppConstructorOptions, "publishableClientKey" | "secretServerKey"> & { - projectOwnerSession: Session, + projectOwnerSession: InternalSession, } ) ); @@ -249,8 +250,8 @@ const createCache = (fetcher: (dependencies: D) => Promise(fetcher: (session: Session, extraDependencies: D) => Promise ) => { - return new AsyncCache<[Session, ...D], T>( +const createCacheBySession = (fetcher: (session: InternalSession, extraDependencies: D) => Promise ) => { + return new AsyncCache<[InternalSession, ...D], T>( async ([session, ...extraDependencies]) => await fetcher(session, extraDependencies), { onSubscribe: ([session], refresh) => { @@ -384,12 +385,30 @@ class _StackClientAppImpl({ @@ -398,6 +417,11 @@ class _StackClientAppImpl({ + refreshToken: tokenStoreInit.refreshToken, + accessToken: tokenStoreInit.accessToken, + }); } throw new Error(`Invalid token store ${tokenStoreInit}`); @@ -413,10 +437,10 @@ class _StackClientAppImpl, Map>(); - protected _getSessionFromTokenStore(tokenStore: Store): Session { + private _sessionsByTokenStoreAndSessionKey = new WeakMap, Map>(); + protected _getSessionFromTokenStore(tokenStore: Store): InternalSession { const tokenObj = tokenStore.get(); - const sessionKey = Session.calculateSessionKey(tokenObj); + const sessionKey = InternalSession.calculateSessionKey(tokenObj); const existing = sessionKey ? this._sessionsByTokenStoreAndSessionKey.get(tokenStore)?.get(sessionKey) : null; if (existing) return existing; @@ -443,11 +467,11 @@ class _StackClientAppImpl void) => { const { unsubscribe } = tokenStore.onChange(() => cb()); @@ -456,7 +480,6 @@ class _StackClientAppImpl this._getSessionFromTokenStore(tokenStore), [tokenStore]); return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } - protected async _signInToAccountWithTokens(tokens: { accessToken: string | null, refreshToken: string }) { const tokenStore = this._getOrCreateTokenStore(); @@ -599,14 +622,23 @@ class _StackClientAppImpl; - protected _currentUserFromJson(json: UserJson | null, session: Session): ProjectCurrentUser | null; - protected _currentUserFromJson(json: UserJson | null, session: Session): ProjectCurrentUser | null { + protected _currentUserFromJson(json: UserJson, session: InternalSession): ProjectCurrentUser; + protected _currentUserFromJson(json: UserJson | null, session: InternalSession): ProjectCurrentUser | null; + protected _currentUserFromJson(json: UserJson | null, session: InternalSession): ProjectCurrentUser | null { if (json === null) return null; const app = this; const currentUser: CurrentUser = { ...this._userFromJson(json), - session, + _internalSession: session, + currentSession: { + async getTokens() { + const tokens = await session.getPotentiallyExpiredTokens(); + return { + accessToken: tokens?.accessToken.token ?? null, + refreshToken: tokens?.refreshToken?.token ?? null, + }; + }, + }, async updateSelectedTeam(team: Team | null) { await app._updateUser({ selectedTeamId: team?.id ?? null }, session); }, @@ -685,7 +717,7 @@ class _StackClientAppImpl { + return { + "x-stack-auth": JSON.stringify(await this.getCrossOriginTokenObject()), + }; + } + + async getCrossOriginTokenObject(): Promise<{ accessToken: string | null, refreshToken: string | null }> { + const user = await this.getUser(); + if (!user) return { accessToken: null, refreshToken: null }; + const tokens = await user.currentSession.getTokens(); + return tokens; + } + protected async _redirectTo(handlerName: keyof HandlerUrls, options?: RedirectToOptions) { const url = this.urls[handlerName]; if (!url) { @@ -828,7 +873,7 @@ class _StackClientAppImpl { + protected async _signOut(session: InternalSession): Promise { await this._interface.signOut(session); await this.redirectToAfterSignOut(); } - protected async _sendVerificationEmail(session: Session): Promise { + protected async _sendVerificationEmail(session: InternalSession): Promise { const emailVerificationRedirectUrl = constructRedirectUrl(this.urls.emailVerification); return await this._interface.sendVerificationEmail(emailVerificationRedirectUrl, session); } protected async _updatePassword( options: { oldPassword: string, newPassword: string }, - session: Session + session: InternalSession ): Promise { return await this._interface.updatePassword(options, session); } @@ -987,7 +1032,7 @@ class _StackClientAppImpl; - protected _currentServerUserFromJson(json: ServerUserJson | null, session: Session): ProjectCurrentSeverUser | null; - protected _currentServerUserFromJson(json: ServerUserJson | null, session: Session): ProjectCurrentSeverUser | null { + protected _currentServerUserFromJson(json: ServerUserJson, session: InternalSession): ProjectCurrentSeverUser; + protected _currentServerUserFromJson(json: ServerUserJson | null, session: InternalSession): ProjectCurrentSeverUser | null; + protected _currentServerUserFromJson(json: ServerUserJson | null, session: InternalSession): ProjectCurrentSeverUser | null { if (json === null) return null; const app = this; const nonCurrentServerUser = this._serverUserFromJson(json); const currentUser: CurrentServerUser = { ...nonCurrentServerUser, - session, + _internalSession: session, + currentSession: { + async getTokens() { + const tokens = await session.getPotentiallyExpiredTokens(); + return { + accessToken: tokens?.accessToken.token ?? null, + refreshToken: tokens?.refreshToken?.token ?? null, + }; + }, + }, async delete() { const res = await nonCurrentServerUser.delete(); await app._refreshUser(session); @@ -1433,7 +1487,7 @@ class _StackServerAppImpl, +}; + +/** + * Contains everything related to the current user session. + */ type Auth = { - readonly session: Session, - updateSelectedTeam(this: T, team: Team | null): Promise, - update(this: T, user: C): Promise, + readonly _internalSession: InternalSession, + readonly currentSession: Session, signOut(this: T): Promise, + + // TODO these should not actually be here + update(this: T, user: C): Promise, + updateSelectedTeam(this: T, team: Team | null): Promise, sendVerificationEmail(this: T): Promise, updatePassword(this: T, options: { oldPassword: string, newPassword: string}): Promise, }; @@ -1840,6 +1904,66 @@ export type StackClientApp, signInWithMagicLink(code: string): Promise, + /** + * With most browsers now disabling third-party cookies by default, the best way to send authenticated requests + * across different origins is to pass the tokens in a header. + * + * This function returns a header object that can be used with `fetch` or other HTTP request libraries to send + * authenticated requests. + * + * On the server, you can then pass in the `Request` object to the `tokenStore` option + * on your Stack app to fetch user details. Please note that CORS by default does not allow custom headers, so you + * must set the [`Access-Control-Allow-Headers` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) + * to include `x-stack-auth` in the CORS preflight response. + * + * Example: + * + * ```ts + * // client + * const res = await fetch("https://api.example.com", { + * headers: { + * ...await stackApp.getCrossOriginHeaders() + * // you can also add your own headers here + * }, + * }); + * + * // server + * function handleRequest(req: Request) { + * const user = await stackServerApp.getUser({ tokenStore: req }); + * return new Response("Welcome, " + user.displayName); + * } + * ``` + */ + getCrossOriginHeaders(): Promise<{ "x-stack-auth": string }>, + + /** + * With most browsers now disabling third-party cookies by default, there need to be new ways to send authenticated + * requests across different origins. While `getCrossOriginHeaders` is the recommended way to do this, there + * are some cases where you might want to send the tokens differently, for example when you are using WebSockets + * or non-HTTP protocols. + * + * This function returns a token object that can be JSON-serialized and sent to the server in any way you like. + * There, you can use the `tokenStore` option on your Stack app to fetch user details. + * + * Example: + * + * ```ts + * // client + * const res = await rpcCall(rpcEndpoint, { + * data: { + * auth: await stackApp.getCrossOriginTokenObject(), + * }, + * }); + * + * // server + * function handleRequest(data) { + * const user = await stackServerApp.getUser({ tokenStore: data.auth }); + * return new Response("Welcome, " + user.displayName); + * } + * ``` + */ + getCrossOriginTokenObject(): Promise<{ accessToken: string | null, refreshToken: string | null }>, + [stackAppInternalsSymbol]: { toClientJson(): StackClientAppJson, setCurrentUser(userJsonPromise: Promise): void,