From d28b846c7058c10ca22e7b916d29109ec7994ef7 Mon Sep 17 00:00:00 2001 From: ImalshaD Date: Fri, 30 Oct 2026 11:11:18 +0530 Subject: [PATCH] Post the flow's SSO sessions on the authorization callback A sign-in or sign-up that completes an authorization request posts its assertion to /oauth2/auth/callback. The flow's responses also return the SSO sessions its steps start, and the server keeps them with the code when the application asked for the device_sso scope, so the code exchange can return them as a device secret. Collect the sessions from every response of the authorization request, in memory only, drop the ones a later step ends, and post them with the assertion. A failed sign-in posts none. Refs thunder-id/thunderid#6144 --- .../executeEmbeddedSignInFlow.test.ts | 118 ++++++++++++++++++ .../executeEmbeddedSignUpFlow.test.ts | 46 +++++++ .../src/api/auth/executeEmbeddedSignInFlow.ts | 17 ++- .../src/api/auth/executeEmbeddedSignUpFlow.ts | 14 ++- .../javascript/src/api/auth/flowSessions.ts | 43 +++++++ packages/javascript/src/index.ts | 1 + .../javascript/src/models/embedded-flow.ts | 13 ++ .../src/models/embedded-signin-flow.ts | 12 ++ .../src/models/embedded-signup-flow.ts | 13 +- 9 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 packages/javascript/src/api/auth/flowSessions.ts diff --git a/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts index f5886395..c9077977 100644 --- a/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts +++ b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignInFlow.test.ts @@ -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): {json: () => Promise; ok: boolean} => ({ + json: () => Promise.resolve(body), + ok: true, + }); + + it('posts the sessions every response returned, INCOMPLETE ones included', async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { await expect(executeEmbeddedSignInFlow({url: URL})).rejects.toThrow('Authorization payload is required'); }); diff --git a/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts index e43197b3..c39541c7 100644 --- a/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts +++ b/packages/javascript/src/api/auth/__tests__/executeEmbeddedSignUpFlow.test.ts @@ -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): {json: () => Promise; ok: boolean} => ({ + json: () => Promise.resolve(body), + ok: true, + }); + + it('posts the sessions the registration flow started', async (): Promise => { + 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 => { + 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 => { await expect(executeEmbeddedSignUpFlow({url: URL})).rejects.toThrow('Registration payload is required'); }); diff --git a/packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts index 71a41228..d4299eba 100644 --- a/packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts +++ b/packages/javascript/src/api/auth/executeEmbeddedSignInFlow.ts @@ -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'; @@ -17,7 +18,7 @@ const readErrorAssertion = (body: string): string | undefined => { const postAuthCallback = async ( baseUrl: string | undefined, - body: Record, + body: Record, headers: HeadersInit | undefined, ): Promise> => { const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, { @@ -58,6 +59,9 @@ const relayFailure = async ( headers?: HeadersInit, flowResponse?: EmbeddedSignInFlowResponse, ): Promise => { + // A failed sign-in issues no code, so the sessions it collected are not posted. + takeFlowSessions(authId); + const oauth2Result: Record = await postAuthCallback( baseUrl, {assertion: errorAssertion, authId}, @@ -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 = await postAuthCallback( baseUrl, - {assertion: flowResponse.assertion, authId}, + {assertion: flowResponse.assertion, authId, ...(sessionData.length > 0 ? {sessionData} : {})}, requestConfig.headers, ); diff --git a/packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts b/packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts index f643193a..ab51b259 100644 --- a/packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts +++ b/packages/javascript/src/api/auth/executeEmbeddedSignUpFlow.ts @@ -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'; @@ -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: { diff --git a/packages/javascript/src/api/auth/flowSessions.ts b/packages/javascript/src/api/auth/flowSessions.ts new file mode 100644 index 00000000..a143d7b1 --- /dev/null +++ b/packages/javascript/src/api/auth/flowSessions.ts @@ -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>(); + +/** + * 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 = + collectedSessions.get(authId) ?? new Map(); + 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 | undefined = collectedSessions.get(authId); + collectedSessions.delete(authId); + + return held ? [...held.values()] : []; +}; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 9e1bb0e3..fb1ae711 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -443,6 +443,7 @@ export { EmbeddedFlowEventType, } from './models/embedded-flow'; export type { + EmbeddedFlowSessionData, EmbeddedFlowComponent, EmbeddedFlowComponentAction, EmbeddedFlowResponseData, diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index aecfe2ad..55756449 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -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. * diff --git a/packages/javascript/src/models/embedded-signin-flow.ts b/packages/javascript/src/models/embedded-signin-flow.ts index bff79add..54409614 100644 --- a/packages/javascript/src/models/embedded-signin-flow.ts +++ b/packages/javascript/src/models/embedded-signin-flow.ts @@ -4,6 +4,7 @@ import { EmbeddedFlowResponseData, EmbeddedFlowResponseType, + EmbeddedFlowSessionData, EmbeddedFlowType, FlowExecutionError, } from './embedded-flow'; @@ -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. diff --git a/packages/javascript/src/models/embedded-signup-flow.ts b/packages/javascript/src/models/embedded-signup-flow.ts index 6e26dde6..cd67231d 100644 --- a/packages/javascript/src/models/embedded-signup-flow.ts +++ b/packages/javascript/src/models/embedded-signup-flow.ts @@ -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. @@ -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. */