Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions scripts/start-example.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ yarn
rm -rf node_modules/oidc-spa
cp -r ../../dist node_modules/oidc-spa
rm -rf node_modules/.vite
rm -rf node_modules/.vite-temp
rm -rf .angular/cache

yarn $2
127 changes: 94 additions & 33 deletions src/core/createOidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { type OidcMetadata, fetchOidcMetadata } from "./OidcMetadata";
import { assert, type Equals } from "../tools/tsafe/assert";
import { id } from "../tools/tsafe/id";
import { Reflect } from "../tools/tsafe/Reflect";
import { Deferred } from "../tools/Deferred";
import { createEvtIsUserActive } from "./evtIsUserActive";
import { createStartCountdown } from "../tools/startCountdown";
Expand Down Expand Up @@ -266,14 +267,51 @@ 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>()
type GlobalContextActual = {
prOidcByConfigId: Map<string, Promise<Oidc<any>>>;
hasLogoutBeenCalled: boolean;
exports_earlyInit: Exports_earlyInit;
exports_tokenSubstitution: Exports_tokenSubstitution | undefined;
exports_DPoP: Exports_DPoP | undefined;
};

declare global {
interface Window {
"__oidc-spa:globalContext:createOidc": {
dEarlyInitResolved: Deferred<void>;
actual_exposedForMicroFrontendSetup:
| GlobalContextActual
| "not a micro frontend setup"
| undefined;
};
}
}

window["__oidc-spa:globalContext:createOidc"] ??= {
dEarlyInitResolved: new Deferred(),
actual_exposedForMicroFrontendSetup: undefined
};

const globalContext = window["__oidc-spa:globalContext:createOidc"];
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const globalContext_actual_moduleScoped: GlobalContextActual = {
prOidcByConfigId: new Map(),
hasLogoutBeenCalled: false,
exports_earlyInit: Reflect<Exports_earlyInit>(),
exports_tokenSubstitution: undefined,
exports_DPoP: undefined
};

function getGlobalContextActual(): GlobalContextActual {
assert(globalContext.actual_exposedForMicroFrontendSetup !== undefined);

if (globalContext.actual_exposedForMicroFrontendSetup === "not a micro frontend setup") {
return globalContext_actual_moduleScoped;
}

return globalContext.actual_exposedForMicroFrontendSetup;
}

export type Exports_earlyInit =
| { shouldLoadApp: false }
| {
Expand All @@ -287,8 +325,23 @@ 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_exposedForMicroFrontendSetup !== undefined) {
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();
}
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 +360,8 @@ export namespace Exports_tokenSubstitution {
}

export function registerExports_tokenSubstitution(exports: Exports_tokenSubstitution): void {
globalContext.dExports_tokenSubstitution.resolve(exports);
assert(!globalContext_actual_moduleScoped.exports_earlyInit);
globalContext_actual_moduleScoped.exports_tokenSubstitution = exports;
}

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

export function registerExports_DPoP(exports: Exports_DPoP): void {
globalContext.dExports_DPoP.resolve(exports);
assert(!globalContext_actual_moduleScoped.exports_earlyInit);
globalContext_actual_moduleScoped.exports_DPoP = exports;
}

/** @see: https://docs.oidc-spa.dev/v/v10/usage */
Expand Down Expand Up @@ -384,7 +439,30 @@ export async function createOidc<

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

const { prOidcByConfigId } = globalContext;
wait_early_init: {
const { dEarlyInitResolved } = globalContext;

if (dEarlyInitResolved.getState().hasResolved) {
break wait_early_init;
}

const timer = window.setTimeout(() => {
console.warn(
[
"oidc-spa: Setup error.",
"oidcEarlyInit() wasn't called.",
"This is supposed to be handled by the oidc-spa Vite plugin",
"or manually in other environments."
].join(" ")
);
}, 3_000);

await dEarlyInitResolved.pr;

window.clearTimeout(timer);
}

const { prOidcByConfigId } = getGlobalContextActual();

use_previous_instance: {
const prOidc = prOidcByConfigId.get(configId);
Expand Down Expand Up @@ -452,24 +530,7 @@ export async function createOidc_nonMemoized<
BASE_URL: BASE_URL_params
} = params;

const exports_earlyInit = await (async () => {
const timer = window.setTimeout(() => {
console.warn(
[
"oidc-spa: Setup error.",
"oidcEarlyInit() wasn't called.",
"This is supposed to be handled by the oidc-spa Vite plugin",
"or manually in other environments."
].join(" ")
);
}, 3_000);

const exports_earlyInit = await globalContext.dExports_earlyInit.pr;

window.clearTimeout(timer);

return exports_earlyInit;
})();
const { exports_earlyInit } = getGlobalContextActual();

if (!exports_earlyInit.shouldLoadApp) {
return new Promise<never>(() => {});
Expand All @@ -484,9 +545,9 @@ export async function createOidc_nonMemoized<
const sessionRestorationMethod =
sessionRestorationMethod_params ?? sessionRestorationMethod_earlyInit ?? "auto";

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

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

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

Expand Down Expand Up @@ -1532,12 +1593,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