Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,124 @@ describe('executeEmbeddedSignInFlow', (): void => {
});
});

describe('session data posted with the callback', (): void => {
const BASE_URL = 'https://localhost:8090';

const respond = (body: Record<string, unknown>): {json: () => Promise<unknown>; ok: boolean} => ({
json: () => Promise.resolve(body),
ok: true,
});

it('posts the sessions every response returned, INCOMPLETE ones included', async (): Promise<void> => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(
respond({
flowStatus: EmbeddedSignInFlowStatus.Incomplete,
sessionData: [{expiresIn: 28800, flowIdentifier: 'flow-a', sessionHandle: 'handle-a'}],
}),
)
.mockResolvedValueOnce(
respond({
assertion: 'signed-assertion',
flowStatus: EmbeddedSignInFlowStatus.Complete,
sessionData: [{expiresIn: 28800, flowIdentifier: 'flow-b', sessionHandle: 'handle-b'}],
}),
)
.mockResolvedValueOnce(respond({redirect_uri: 'https://client.example.com/cb?code=xyz'}));

await executeEmbeddedSignInFlow({authId: 'auth-sessions', baseUrl: BASE_URL, payload: {executionId: 'e-1'}});
await executeEmbeddedSignInFlow({
authId: 'auth-sessions',
baseUrl: BASE_URL,
payload: {action: 'submit', executionId: 'e-1'},
});

expect(captureRequestBody()).toEqual({
assertion: 'signed-assertion',
authId: 'auth-sessions',
sessionData: [
{flowIdentifier: 'flow-a', sessionHandle: 'handle-a'},
{flowIdentifier: 'flow-b', sessionHandle: 'handle-b'},
],
});
});

it('does not post a session a later step cleared', async (): Promise<void> => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(
respond({
flowStatus: EmbeddedSignInFlowStatus.Incomplete,
sessionData: [{flowIdentifier: 'flow-a', sessionHandle: 'handle-a'}],
}),
)
.mockResolvedValueOnce(
respond({
assertion: 'signed-assertion',
clearedSessions: ['flow-a'],
flowStatus: EmbeddedSignInFlowStatus.Complete,
}),
)
.mockResolvedValueOnce(respond({redirect_uri: 'https://client.example.com/cb?code=xyz'}));

await executeEmbeddedSignInFlow({authId: 'auth-cleared', baseUrl: BASE_URL, payload: {executionId: 'e-2'}});
await executeEmbeddedSignInFlow({
authId: 'auth-cleared',
baseUrl: BASE_URL,
payload: {action: 'submit', executionId: 'e-2'},
});

expect(captureRequestBody()).toEqual({assertion: 'signed-assertion', authId: 'auth-cleared'});
});

it('keeps the sessions of one authorization request out of another', async (): Promise<void> => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(
respond({
flowStatus: EmbeddedSignInFlowStatus.Incomplete,
sessionData: [{flowIdentifier: 'flow-a', sessionHandle: 'handle-a'}],
}),
)
.mockResolvedValueOnce(respond({assertion: 'signed-assertion', flowStatus: EmbeddedSignInFlowStatus.Complete}))
.mockResolvedValueOnce(respond({redirect_uri: 'https://client.example.com/cb?code=xyz'}));

await executeEmbeddedSignInFlow({authId: 'auth-one', baseUrl: BASE_URL, payload: {executionId: 'e-3'}});
await executeEmbeddedSignInFlow({
authId: 'auth-other',
baseUrl: BASE_URL,
payload: {action: 'submit', executionId: 'e-4'},
});

expect(captureRequestBody()).toEqual({assertion: 'signed-assertion', authId: 'auth-other'});
});

it('does not post the collected sessions with a failure', async (): Promise<void> => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(
respond({
flowStatus: EmbeddedSignInFlowStatus.Incomplete,
sessionData: [{flowIdentifier: 'flow-a', sessionHandle: 'handle-a'}],
}),
)
.mockResolvedValueOnce(
respond({errorAssertion: 'signed-error-assertion', flowStatus: EmbeddedSignInFlowStatus.Error}),
)
.mockResolvedValueOnce(respond({redirect_uri: 'https://client.example.com/cb?error=access_denied'}));

await executeEmbeddedSignInFlow({authId: 'auth-failed', baseUrl: BASE_URL, payload: {executionId: 'e-5'}});
await executeEmbeddedSignInFlow({
authId: 'auth-failed',
baseUrl: BASE_URL,
payload: {action: 'submit', executionId: 'e-5'},
});

expect(captureRequestBody()).toEqual({assertion: 'signed-error-assertion', authId: 'auth-failed'});
});
});

it('throws when payload is missing', async (): Promise<void> => {
await expect(executeEmbeddedSignInFlow({url: URL})).rejects.toThrow('Authorization payload is required');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,52 @@ describe('executeEmbeddedSignUpFlow', (): void => {
});
});

describe('session data posted with the callback', (): void => {
const BASE_URL = 'https://localhost:8090';

const respond = (body: Record<string, unknown>): {json: () => Promise<unknown>; ok: boolean} => ({
json: () => Promise.resolve(body),
ok: true,
});

it('posts the sessions the registration flow started', async (): Promise<void> => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(
respond({
flowStatus: EmbeddedSignUpFlowStatus.Incomplete,
sessionData: [{expiresIn: 28800, flowIdentifier: 'flow-a', sessionHandle: 'handle-a'}],
}),
)
.mockResolvedValueOnce(respond({assertion: 'signed-assertion', flowStatus: EmbeddedSignUpFlowStatus.Complete}))
.mockResolvedValueOnce(respond({redirect_uri: 'https://client.example.com/cb?code=xyz'}));

await executeEmbeddedSignUpFlow({authId: 'auth-signup', baseUrl: BASE_URL, payload: {executionId: 'e-1'}});
await executeEmbeddedSignUpFlow({
authId: 'auth-signup',
baseUrl: BASE_URL,
payload: {action: 'submit', executionId: 'e-1'},
});

expect(captureRequestBody()).toEqual({
assertion: 'signed-assertion',
authId: 'auth-signup',
sessionData: [{flowIdentifier: 'flow-a', sessionHandle: 'handle-a'}],
});
});

it('posts no sessionData when the flow started none', async (): Promise<void> => {
global.fetch = vi
.fn()
.mockResolvedValueOnce(respond({assertion: 'signed-assertion', flowStatus: EmbeddedSignUpFlowStatus.Complete}))
.mockResolvedValueOnce(respond({redirect_uri: 'https://client.example.com/cb?code=xyz'}));

await executeEmbeddedSignUpFlow({authId: 'auth-none', baseUrl: BASE_URL, payload: {executionId: 'e-2'}});

expect(captureRequestBody()).toEqual({assertion: 'signed-assertion', authId: 'auth-none'});
});
});

it('throws when payload is missing', async (): Promise<void> => {
await expect(executeEmbeddedSignUpFlow({url: URL})).rejects.toThrow('Registration payload is required');
});
Expand Down
17 changes: 14 additions & 3 deletions packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {collectFlowSessions, takeFlowSessions} from './flowSessions';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow';
import {EmbeddedFlowExecuteRequestConfig, EmbeddedFlowSessionData} from '../../models/embedded-flow';
import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow';
import injectRequestedPermissions from '../../utils/injectRequestedPermissions';
import logger from '../../utils/logger';
Expand All @@ -17,7 +18,7 @@ const readErrorAssertion = (body: string): string | undefined => {

const postAuthCallback = async (
baseUrl: string | undefined,
body: Record<string, string>,
body: Record<string, unknown>,
headers: HeadersInit | undefined,
): Promise<Record<string, unknown>> => {
const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, {
Expand Down Expand Up @@ -58,6 +59,9 @@ const relayFailure = async (
headers?: HeadersInit,
flowResponse?: EmbeddedSignInFlowResponse,
): Promise<EmbeddedSignInFlowResponse> => {
// A failed sign-in issues no code, so the sessions it collected are not posted.
takeFlowSessions(authId);

const oauth2Result: Record<string, unknown> = await postAuthCallback(
baseUrl,
{assertion: errorAssertion, authId},
Expand Down Expand Up @@ -158,13 +162,20 @@ const executeEmbeddedSignInFlow = async ({

const flowResponse: EmbeddedSignInFlowResponse = await response.json();

if (authId) {
collectFlowSessions(authId, flowResponse);
}

// IMPORTANT: Only applicable for ThunderID V2 platform.
// Check if the flow is complete and has an assertion and authId is provided, then call OAuth2 auth callback.
if (flowResponse.flowStatus === EmbeddedSignInFlowStatus.Complete && flowResponse.assertion && authId) {
try {
// When the application asked for the device_sso scope, the server keeps the posted sessions with
// the code, so the code exchange can return them as the device secret.
const sessionData: EmbeddedFlowSessionData[] = takeFlowSessions(authId);
const oauth2Result: Record<string, unknown> = await postAuthCallback(
baseUrl,
{assertion: flowResponse.assertion, authId},
{assertion: flowResponse.assertion, authId, ...(sessionData.length > 0 ? {sessionData} : {})},
requestConfig.headers,
);

Expand Down
14 changes: 13 additions & 1 deletion packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {collectFlowSessions, takeFlowSessions} from './flowSessions';
import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../../models/embedded-flow';
import {
EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig,
EmbeddedFlowSessionData,
} from '../../models/embedded-flow';
import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../models/embedded-signup-flow';
import injectRequestedPermissions from '../../utils/injectRequestedPermissions';

Expand Down Expand Up @@ -83,14 +87,22 @@ const executeEmbeddedSignUpFlow = async ({

const flowResponse: EmbeddedSignUpFlowResponse = await response.json();

if (authId) {
collectFlowSessions(authId, flowResponse);
}

// IMPORTANT: Only applicable for ThunderID V2 platform.
// Check if the flow is complete and has an assertion and authId is provided, then call OAuth2 auth callback.
if (flowResponse.flowStatus === EmbeddedSignUpFlowStatus.Complete && (flowResponse as any).assertion && authId) {
try {
// When the application asked for the device_sso scope, the server keeps the posted sessions with
// the code, so the code exchange can return them as the device secret.
const sessionData: EmbeddedFlowSessionData[] = takeFlowSessions(authId);
const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, {
body: JSON.stringify({
assertion: (flowResponse as any).assertion,
authId,
...(sessionData.length > 0 ? {sessionData} : {}),
}),
credentials: 'include',
headers: {
Expand Down
43 changes: 43 additions & 0 deletions packages/javascript/src/api/auth/flowSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {EmbeddedFlowSessionData} from '../../models/embedded-flow';

/**
* The part of a flow step response that reports SSO sessions.
*/
interface FlowSessionUpdate {
clearedSessions?: string[];
sessionData?: EmbeddedFlowSessionData[];
}

// The SSO sessions each authorization request's flow responses returned, held in memory until the
// callback posts them. A session can start on any step, not only the last, so every response is read.
// They are never persisted, so a reload in the middle of the flow drops them.
const collectedSessions = new Map<string, Map<string, EmbeddedFlowSessionData>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@coderabbitai Check whether this can cause any issues when the code is run on a SSR app.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -H '^(AGENTS\.md|flowSessions\.ts)$' . --exclude node_modules --exclude .git
ast-grep outline packages/javascript/src/api/auth/flowSessions.ts --items all
rg -n -C 4 'flowSessions|collectedSessions' packages

Length of output: 5677


🏁 Script executed:

#!/bin/bash
cat AGENTS.md
ast-grep outline packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts --items all
ast-grep outline packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts --items all
nl -ba packages/javascript/src/api/auth/flowSessions.ts
nl -ba packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts | sed -n '1,260p'
nl -ba packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts | sed -n '1,270p'
rg -n -C 3 '\bexecuteEmbeddedSign(In|Up)Flow\s*\(|\binterface EmbeddedFlowSessionData\b|authId:' packages --glob '*.{ts,tsx}' | head -240

Length of output: 54630


🏁 Script executed:

#!/bin/bash
ast-grep outline packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts --items all
nl -ba packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts | sed -n '75,155p'
rg -n -C 3 'executeEmbeddedSignInFlow|executeEmbeddedSignUpFlow' packages/javascript/src --glob '*.ts' --glob '!**/__tests__/**'
rg -n -C 3 'EmbeddedFlowSessionData|authId\??:' packages/javascript/src/models/embedded-flow* 
rg -n -C 3 'executeEmbeddedSignInFlow|executeEmbeddedSignUpFlow' packages/node packages/nextjs packages/nuxt --glob '*.{ts,tsx}' --glob '!**/__tests__/**'

Length of output: 18676


@brionmario Yes—if the embedded flow runs on the server with authId, this module-level Map can cause issues. Importing the module during SSR does not itself cause a browser-API error.

I checked the collection logic and both flow executors.

Risk Finding
Shared server state In flowSessions.ts, Line 17, the Map belongs to the loaded module, not an HTTP request or SDK client. All requests in that server instance share it. Distinct authId values separate entries, but the key has no server or tenant namespace. This alone does not prove cross-user leakage.
Unbounded credential retention Cleanup depends on takeFlowSessions(). Abandoned flows and some error paths leave session handles in memory without expiry or a size limit. A browser reload does not clear the server’s map, despite the comment at Line 16.
Missing sessions across instances If later flow steps reach another worker or serverless instance, that instance cannot retrieve earlier sessions. The callback can then omit sessions collected on earlier steps.
No SSR-to-browser transfer Server memory and browser memory are separate. If a flow starts during SSR and continues after hydration, the browser cannot access the server’s collected sessions.

There is also a retry issue that is not specific to SSR: both executors call takeFlowSessions() before the callback succeeds. If the callback fails, the collected sessions have already been deleted.

Current Nuxt behavior: the inspected server-side signIn() call in packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts, Line 121, does not pass authId. That path does not populate this map. The exported core APIs still permit server callers to pass authId.

Recommendation: make the collection explicit and scoped to the authorization flow. If flows span server requests or instances, use a protected server-side store with tenant/server namespacing, expiry, and terminal cleanup. A request-local map alone will not preserve a multi-request flow. If collection is intended only for browser execution, enforce that boundary explicitly.

This assessment is based on source inspection. I did not run runtime tests.

You are interacting with an AI system.


/**
* Holds the sessions a flow step started under its authorization request, and drops the ones it ended.
*/
export const collectFlowSessions = (authId: string, step: FlowSessionUpdate): void => {
if (!step.sessionData?.length && !step.clearedSessions?.length) {
return;
}
const held: Map<string, EmbeddedFlowSessionData> =
collectedSessions.get(authId) ?? new Map<string, EmbeddedFlowSessionData>();
step.sessionData?.forEach(({flowIdentifier, sessionHandle}: EmbeddedFlowSessionData) => {
held.set(flowIdentifier, {flowIdentifier, sessionHandle});
});
step.clearedSessions?.forEach((flowIdentifier: string) => held.delete(flowIdentifier));
collectedSessions.set(authId, held);
};

/**
* Returns the sessions held for an authorization request and forgets them.
*/
export const takeFlowSessions = (authId: string): EmbeddedFlowSessionData[] => {
const held: Map<string, EmbeddedFlowSessionData> | undefined = collectedSessions.get(authId);
collectedSessions.delete(authId);

return held ? [...held.values()] : [];
};
1 change: 1 addition & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,7 @@ export {
EmbeddedFlowEventType,
} from './models/embedded-flow';
export type {
EmbeddedFlowSessionData,
EmbeddedFlowComponent,
EmbeddedFlowComponentAction,
EmbeddedFlowResponseData,
Expand Down
13 changes: 13 additions & 0 deletions packages/javascript/src/models/embedded-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ export enum EmbeddedFlowResponseType {
View = 'VIEW',
}

/**
* An SSO session a flow step started, as the flow execution API returns it. The handle resumes the
* session without authentication, so it is held as a credential.
*/
export interface EmbeddedFlowSessionData {
/** Seconds until the session reaches its absolute timeout. Present on a session a step started. */
expiresIn?: number;
/** Identifies the flow the session belongs to. */
flowIdentifier: string;
/** The session handle. */
sessionHandle: string;
}

/**
* Base request configuration for executing embedded flow operations.
*
Expand Down
12 changes: 12 additions & 0 deletions packages/javascript/src/models/embedded-signin-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import {
EmbeddedFlowResponseData,
EmbeddedFlowResponseType,
EmbeddedFlowSessionData,
EmbeddedFlowType,
FlowExecutionError,
} from './embedded-flow';
Expand Down Expand Up @@ -164,6 +165,17 @@ export interface EmbeddedSignInFlowResponse extends ExtendedEmbeddedSignInFlowRe
*/
errorAssertion?: string;

/**
* SSO sessions this step started. A step that starts one need not be the last, so they can arrive on
* an INCOMPLETE response.
*/
sessionData?: EmbeddedFlowSessionData[];

/**
* Flow identifiers whose SSO session this step ended.
*/
clearedSessions?: string[];

/**
* Per-step challenge token for replay protection.
* Must be included in the next request to continue this flow.
Expand Down
13 changes: 12 additions & 1 deletion packages/javascript/src/models/embedded-signup-flow.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2025-2026 The ThunderID Authors
// SPDX-License-Identifier: Apache-2.0

import {EmbeddedFlowResponseType, EmbeddedFlowType, FlowExecutionError} from './embedded-flow';
import {EmbeddedFlowResponseType, EmbeddedFlowSessionData, EmbeddedFlowType, FlowExecutionError} from './embedded-flow';

/**
* Status enumeration for ThunderID embedded sign-up flow operations.
Expand Down Expand Up @@ -174,6 +174,17 @@ export interface EmbeddedSignUpFlowResponse extends ExtendedEmbeddedSignUpFlowRe
*/
flowStatus: EmbeddedSignUpFlowStatus;

/**
* SSO sessions this step started. A step that starts one need not be the last, so they can arrive on
* an INCOMPLETE response.
*/
sessionData?: EmbeddedFlowSessionData[];

/**
* Flow identifiers whose SSO session this step ended.
*/
clearedSessions?: string[];

/**
* Type of response, indicating the expected user interaction.
*/
Expand Down
Loading