Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions examples/tanstack-start/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export interface FileRoutesByFullPath {
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
Expand Down Expand Up @@ -130,7 +130,7 @@ export interface FileRouteTypes {
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr'
| '/demo/start/ssr/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
Expand Down Expand Up @@ -227,7 +227,7 @@ declare module '@tanstack/react-router' {
'/demo/start/ssr/': {
id: '/demo/start/ssr/'
path: '/demo/start/ssr'
fullPath: '/demo/start/ssr'
fullPath: '/demo/start/ssr/'
preLoaderRoute: typeof DemoStartSsrIndexRouteImport
parentRoute: typeof rootRouteImport
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oidc-spa",
"version": "10.2.0",
"version": "10.3.0-rc.1",
"description": "OpenID Connect / OAuth2 solution for client-first Web Applications",
"repository": {
"type": "git",
Expand Down
85 changes: 69 additions & 16 deletions src/core/createOidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,44 @@ export type ParamsOfCreateOidc<
disableDPoP?: true;
};

const globalContext = {
prOidcByConfigId: new Map<string, Promise<Oidc<any>>>(),
hasLogoutBeenCalled: id<boolean>(false),
dExports_earlyInit: new Deferred<Exports_earlyInit>(),
dExports_tokenSubstitution: new Deferred<Exports_tokenSubstitution>(),
dExports_DPoP: new Deferred<Exports_DPoP>()
declare global {
interface Window {
"__oidc-spa:globalContext:createOidc": {
dEarlyInitResolved: Deferred<void>;
actual:
| {
prOidcByConfigId: Map<string, Promise<Oidc<any>>>;
hasLogoutBeenCalled: boolean;
dExports_earlyInit: Deferred<Exports_earlyInit>;
dExports_tokenSubstitution: Deferred<Exports_tokenSubstitution>;
dExports_DPoP: Deferred<Exports_DPoP>;
}
| undefined
| null;
};
}
}

window["__oidc-spa:globalContext:createOidc"] ??= {
dEarlyInitResolved: new Deferred(),
actual: undefined
};
const globalContext = window["__oidc-spa:globalContext:createOidc"];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const globalContext_actual_moduleScoped: NonNullable<
Window["__oidc-spa:globalContext:createOidc"]["actual"]
> = {
prOidcByConfigId: new Map(),
hasLogoutBeenCalled: false,
dExports_earlyInit: new Deferred(),
dExports_tokenSubstitution: new Deferred(),
dExports_DPoP: new Deferred()
};

function getGlobalContextActual(): NonNullable<Window["__oidc-spa:globalContext:createOidc"]["actual"]> {
assert(globalContext.actual !== undefined);
return globalContext.actual ?? globalContext_actual_moduleScoped;
}

export type Exports_earlyInit =
| { shouldLoadApp: false }
Expand All @@ -287,8 +318,21 @@ export type Exports_earlyInit =
sessionRestorationMethod: "iframe" | "full page redirect" | "auto" | undefined;
};

export function registerExports_earlyInit(exports: Exports_earlyInit): void {
globalContext.dExports_earlyInit.resolve(exports);
export function registerExports_earlyInit(params: {
exports: Exports_earlyInit;
isMicroFrontendSetup: boolean;
}): void {
const { exports, isMicroFrontendSetup } = params;

if (globalContext.actual !== undefined) {
throw new Error("oidc-spa: Wrong micro frontend setup");
}

globalContext.actual = isMicroFrontendSetup ? globalContext_actual_moduleScoped : null;

globalContext.dEarlyInitResolved.resolve();

globalContext_actual_moduleScoped.dExports_earlyInit.resolve(exports);
}
Comment on lines +328 to 345

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Allow repeated isMicroFrontendSetup: true registrations.

Every earlyInit() call is forwarded here, so after the first micro-frontend sets actual_exposedForMicroFrontendSetup, the next bundle that does earlyInit({ isMicroFrontendSetup: true }) hits Lines 334-336 and throws "Wrong micro frontend setup". That makes the new MFE mode effectively single-bundle only. Repeated MFE registrations should be idempotent and reuse the already-exposed shared context; only real mode mismatches should error.

💡 Minimal fix
 export function registerExports_earlyInit(params: {
     exports: Exports_earlyInit;
     isMicroFrontendSetup: boolean;
 }): void {
     const { exports, isMicroFrontendSetup } = params;
+    const existing = globalContext.actual_exposedForMicroFrontendSetup;

-    if (globalContext.actual_exposedForMicroFrontendSetup !== undefined) {
-        throw new Error("oidc-spa: Wrong micro frontend setup");
+    if (existing !== undefined) {
+        if (existing !== "not a micro frontend setup" && isMicroFrontendSetup) {
+            return;
+        }
+
+        throw new Error("oidc-spa: Wrong micro frontend setup");
     }

     globalContext.actual_exposedForMicroFrontendSetup = isMicroFrontendSetup
         ? globalContext_actual_moduleScoped
         : "not a micro frontend setup";

     globalContext_actual_moduleScoped.exports_earlyInit = exports;

     globalContext.dEarlyInitResolved.resolve();
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/createOidc.ts` around lines 328 - 345, registerExports_earlyInit
currently throws whenever globalContext.actual_exposedForMicroFrontendSetup is
already set, preventing multiple micro-frontend bundles from calling earlyInit
with isMicroFrontendSetup: true; change the logic so it only errors when there
is a real mode mismatch (existing value !== incoming isMicroFrontendSetup) but
allows repeated registrations when incoming is true and the existing value is
also the micro-frontend marker. Specifically, in registerExports_earlyInit check
globalContext.actual_exposedForMicroFrontendSetup: if undefined, set it as
before; if already set, only throw when its boolean/mode differs from the new
isMicroFrontendSetup flag; otherwise treat it as idempotent (reuse existing
globalContext_actual_moduleScoped and assign exports_earlyInit and resolve
dEarlyInitResolved as currently done).


export type Exports_tokenSubstitution = {
Expand All @@ -307,7 +351,7 @@ export namespace Exports_tokenSubstitution {
}

export function registerExports_tokenSubstitution(exports: Exports_tokenSubstitution): void {
globalContext.dExports_tokenSubstitution.resolve(exports);
globalContext_actual_moduleScoped.dExports_tokenSubstitution.resolve(exports);
}

export type Exports_DPoP = {
Expand Down Expand Up @@ -339,7 +383,7 @@ export namespace Exports_DPoP {
}

export function registerExports_DPoP(exports: Exports_DPoP): void {
globalContext.dExports_DPoP.resolve(exports);
globalContext_actual_moduleScoped.dExports_DPoP.resolve(exports);
}

/** @see: https://docs.oidc-spa.dev/v/v10/usage */
Expand All @@ -360,6 +404,14 @@ export async function createOidc<

const { issuerUri: issuerUri_params, clientId, debugLogs, ...rest } = params;

{
const { dEarlyInitResolved } = globalContext;

if (!dEarlyInitResolved.getState().hasResolved) {
await dEarlyInitResolved.pr;
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const issuerUri = toFullyQualifiedUrl({
urlish: issuerUri_params,
doAssertNoQueryParams: true,
Expand All @@ -384,7 +436,7 @@ export async function createOidc<

const configId = getConfigId({ issuerUri, clientId });

const { prOidcByConfigId } = globalContext;
const { prOidcByConfigId } = getGlobalContextActual();

use_previous_instance: {
const prOidc = prOidcByConfigId.get(configId);
Expand Down Expand Up @@ -464,7 +516,7 @@ export async function createOidc_nonMemoized<
);
}, 3_000);

const exports_earlyInit = await globalContext.dExports_earlyInit.pr;
const exports_earlyInit = await getGlobalContextActual().dExports_earlyInit.pr;

window.clearTimeout(timer);

Expand All @@ -484,9 +536,10 @@ export async function createOidc_nonMemoized<
const sessionRestorationMethod =
sessionRestorationMethod_params ?? sessionRestorationMethod_earlyInit ?? "auto";

const { value: exports_tokenSubstitution } = globalContext.dExports_tokenSubstitution.getState();
const { value: exports_tokenSubstitution } =
getGlobalContextActual().dExports_tokenSubstitution.getState();

const { value: exports_DPoP } = globalContext.dExports_DPoP.getState();
const { value: exports_DPoP } = getGlobalContextActual().dExports_DPoP.getState();

const scopes = Array.from(new Set(["openid", ...(params.scopes ?? ["profile"])]));

Expand Down Expand Up @@ -1532,12 +1585,12 @@ export async function createOidc_nonMemoized<
},
getDecodedIdToken: () => currentTokens.decodedIdToken,
logout: async params => {
if (globalContext.hasLogoutBeenCalled) {
if (getGlobalContextActual().hasLogoutBeenCalled) {
log?.("logout() has already been called, ignoring the call");
return new Promise<never>(() => {});
}

globalContext.hasLogoutBeenCalled = true;
getGlobalContextActual().hasLogoutBeenCalled = true;

const rootRelativePostLogoutRedirectUrl: string = (() => {
switch (params.redirectTo) {
Expand Down
12 changes: 10 additions & 2 deletions src/core/earlyInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export type ParamsOfEarlyInit = {
enableDPoP?: () => void;
enableTokenSubstitution?: () => void;
};

/** Default: false */
isMicroFrontendSetup?: boolean;
};

let shouldLoadApp: boolean | undefined = undefined;
Expand All @@ -73,7 +76,12 @@ export function oidcEarlyInit(params?: ParamsOfEarlyInit) {
}

function oidcEarlyInit_nonMemoized(params: ParamsOfEarlyInit | undefined) {
const { BASE_URL, sessionRestorationMethod, securityDefenses = {} } = params ?? {};
const {
BASE_URL,
sessionRestorationMethod,
securityDefenses = {},
isMicroFrontendSetup = false
} = params ?? {};

if (!isBrowser) {
return { shouldLoadApp: true };
Expand Down Expand Up @@ -185,7 +193,7 @@ function oidcEarlyInit_nonMemoized(params: ParamsOfEarlyInit | undefined) {
}

prModuleCreateOidc.then(({ registerExports_earlyInit }) => {
registerExports_earlyInit(exports_earlyInit);
registerExports_earlyInit({ exports: exports_earlyInit, isMicroFrontendSetup });
});

return { shouldLoadApp };
Expand Down
13 changes: 12 additions & 1 deletion src/core/evtIsUserActive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@ import { subscribeToUserInteraction } from "../tools/subscribeToUserInteraction"
import { assert, is } from "../tools/tsafe/assert";
import { id } from "../tools/tsafe/id";

const globalContext = {
declare global {
interface Window {
"__oidc-spa:globalContext:evtIsUserActive": {
appInstanceId: string;
evtIsUserActiveBySessionId: Map<string, NonPostableEvt<EventData>>;
};
}
}

window["__oidc-spa:globalContext:evtIsUserActive"] ??= {
appInstanceId: Math.random().toString(36).slice(2),
evtIsUserActiveBySessionId: new Map<string, NonPostableEvt<EventData>>()
};

const globalContext = window["__oidc-spa:globalContext:evtIsUserActive"];

type EventData = { isUserActive: true } | { isUserActive: false; hasBeenInactiveForHowLongMs: number };

export function createEvtIsUserActive(params: {
Expand Down
14 changes: 12 additions & 2 deletions src/core/loginOrGoToAuthServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,26 @@ import { assert, type Equals } from "../tools/tsafe/assert";
import { noUndefined } from "../tools/tsafe/noUndefined";
import type { StateData } from "./StateData";
import type { NonPostableEvt } from "../tools/Evt";
import { createStatefulEvt } from "../tools/StatefulEvt";
import { createStatefulEvt, StatefulEvt } from "../tools/StatefulEvt";
import { Deferred } from "../tools/Deferred";
import { addOrUpdateSearchParam, getAllSearchParams } from "../tools/urlSearchParams";
import { getIsOnline } from "../tools/getIsOnline";
import { setStateDataCookieIfEnabled } from "./StateDataCookie";

const globalContext = {
declare global {
interface Window {
"__oidc-spa:globalContext:loginOrGoToAuthServer": {
evtHasLoginBeenCalled: StatefulEvt<boolean>;
};
}
}

window["__oidc-spa:globalContext:loginOrGoToAuthServer"] ??= {
evtHasLoginBeenCalled: createStatefulEvt(() => false)
};

const globalContext = window["__oidc-spa:globalContext:loginOrGoToAuthServer"];
Comment on lines +21 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Window access at module evaluation time breaks SSR.

Same issue as the other files in this PR - immediate window access at import time causes errors in SSR/Node.js environments.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/loginOrGoToAuthServer.ts` around lines 21 - 25, The module currently
reads window["__oidc-spa:globalContext:loginOrGoToAuthServer"] at import time
which breaks SSR; change this to a lazy runtime guard: create a function (e.g.,
getLoginOrGoToAuthServerGlobalContext) that checks typeof window !== "undefined"
before accessing window, and if undefined returns a safe fallback (or
initializes the global using createStatefulEvt only when window exists), and
replace the direct globalContext const with a call to that function so
createStatefulEvt and the window key
("__oidc-spa:globalContext:loginOrGoToAuthServer") are only accessed/executed at
runtime in the browser.


type Params = Params.Login | Params.GoToAuthServer;

namespace Params {
Expand Down
12 changes: 11 additions & 1 deletion src/core/loginPropagationToOtherTabs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { assert, is } from "../tools/tsafe/assert";
import { Deferred } from "../tools/Deferred";

const globalContext = {
declare global {
interface Window {
"__oidc-spa.createOidc.loginPropagationToOtherTabs": {
appInstanceId: string;
};
}
}

window["__oidc-spa.createOidc.loginPropagationToOtherTabs"] ??= {
appInstanceId: Math.random().toString(36).slice(2)
};

const globalContext = window["__oidc-spa.createOidc.loginPropagationToOtherTabs"];

type Message = {
appInstanceId: string;
configId: string;
Expand Down
12 changes: 11 additions & 1 deletion src/core/logoutPropagationToOtherTabs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { assert, is } from "../tools/tsafe/assert";
import { Deferred } from "../tools/Deferred";

const globalContext = {
declare global {
interface Window {
"__oidc-spa:globalContext:logoutPropagationToOtherTabs": {
appInstanceId: string;
};
}
}

window["__oidc-spa:globalContext:logoutPropagationToOtherTabs"] ??= {
appInstanceId: Math.random().toString(36).slice(2)
};

const globalContext = window["__oidc-spa:globalContext:logoutPropagationToOtherTabs"];

type Message = {
appInstanceId: string;
configId: string;
Expand Down
18 changes: 14 additions & 4 deletions src/core/ongoingLoginOrRefreshProcesses.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { Deferred } from "../tools/Deferred";
import { assert } from "../tools/tsafe/assert";
import { id } from "../tools/tsafe/id";

const globalContext = {
prDone_arr: id<Promise<void>[]>([]),
prUnlock: id<Promise<void>>(Promise.resolve())
declare global {
interface Window {
"__oidc-spa:globalContext:ongoingLoginOrRefreshProcesses": {
prDone_arr: Promise<void>[];
prUnlock: Promise<void>;
};
}
}

window["__oidc-spa:globalContext:ongoingLoginOrRefreshProcesses"] ??= {
prDone_arr: [],
prUnlock: Promise.resolve()
};

const globalContext = window["__oidc-spa:globalContext:ongoingLoginOrRefreshProcesses"];

export async function startLoginOrRefreshProcess(): Promise<{
completeLoginOrRefreshProcess: () => void;
}> {
Expand Down
Loading