From cd34ac2635d237b1938d39e9f6e1ce303fe95111 Mon Sep 17 00:00:00 2001 From: Malith-19 Date: Tue, 23 Jun 2026 09:43:24 +0530 Subject: [PATCH] Block direct sign-in flow initiation from browser SPAs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser SPA initiating a sign-in flow directly via POST /flow/execute passes a public applicationId, which the server cannot use to authenticate the client. Browser SPAs must use the redirect-based authorization_code + PKCE flow, which has a true equivalent in the OAuth /authorize handler. - executeEmbeddedSignInFlowV2 now throws a ThunderIDRuntimeError when a new sign-in flow is initiated (applicationId + flowType) in a browser context. The react, vue and browser SDKs reach this via the shared core, so all are covered. Server-side (confidential client) initiation is still allowed (non-browser), and browser-side continuation with an executionId — the hosted Gate path — is unaffected. - Scoped to sign-in only: /authorize initiates authentication only, so registration and recovery have no redirect-based equivalent and are left unchanged. - Add JSDoc guidance on the react and vue embedded SignIn components and a note in the JavaScript SDK README; update tests to assert the browser block, server-side allowance, and browser continuation. Refs thunder-id/thunderid#3217 --- packages/javascript/README.md | 18 +++++++++ .../executeEmbeddedSignInFlowV2.test.ts | 40 ++++++++++++++++++- .../src/api/v2/executeEmbeddedSignInFlowV2.ts | 31 ++++++++++++++ .../presentation/auth/SignIn/SignIn.tsx | 8 ++++ .../vue/src/components/auth/sign-in/SignIn.ts | 7 ++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/packages/javascript/README.md b/packages/javascript/README.md index b109138..37a9de8 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -17,6 +17,24 @@ pnpm add @thunderid/javascript yarn add @thunderid/javascript ``` +## Browser SPAs and the sign-in flow + +Initiating a sign-in flow directly from a **browser SPA** via `POST /flow/execute` — i.e. calling +`executeEmbeddedSignInFlowV2` with `applicationId` and `flowType` — is **not supported**. When this +is attempted in a browser, the SDK throws a `ThunderIDRuntimeError`. + +Browser SPAs must use the redirect-based OAuth2 `authorization_code` + PKCE flow instead: configure +your application for the `authorization_code` grant with a registered `redirect_uri` and sign in via +the redirect-based flow (for example using `@thunderid/browser`'s `signIn()` or `@thunderid/react`'s +`SignInButton`). See +[Register an application](https://thunderid.dev/guides/getting-started/register-an-application). + +This does **not** affect: + +- continuing an existing flow with an `executionId` (the path the hosted sign-in/Gate UI uses after + the OAuth `/authorize` handler initiates the flow server-side), or +- server-side (confidential client) usage, where the flow may still be initiated directly. + ## License This project is licensed under the [Apache License 2.0](https://github.com/thunder-id/thunderid/blob/main/LICENSE). diff --git a/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts b/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts index 566300b..af08691 100644 --- a/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts +++ b/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts @@ -16,7 +16,7 @@ * under the License. */ -import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../../models/v2/embedded-signin-flow-v2'; import executeEmbeddedSignInFlowV2 from '../executeEmbeddedSignInFlowV2'; @@ -134,6 +134,44 @@ describe('executeEmbeddedSignInFlowV2', (): void => { }); }); + describe('browser SPA sign-in initiation is blocked', (): void => { + afterEach((): void => { + delete (globalThis as {window?: unknown}).window; + }); + + it('throws when a browser SPA initiates a new flow with applicationId and flowType', async (): Promise => { + (globalThis as {window?: unknown}).window = {document: {}}; + + await expect( + executeEmbeddedSignInFlowV2({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, + url: URL, + }), + ).rejects.toThrow(/cannot initiate a sign-in flow directly/); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('allows server-side (non-browser) initiation with applicationId and flowType', async (): Promise => { + await executeEmbeddedSignInFlowV2({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, + url: URL, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('allows a browser SPA to continue an existing flow with executionId', async (): Promise => { + (globalThis as {window?: unknown}).window = {document: {}}; + + await executeEmbeddedSignInFlowV2({ + payload: {executionId: 'exec-abc'}, + url: URL, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + it('throws when payload is missing', async (): Promise => { await expect(executeEmbeddedSignInFlowV2({url: URL})).rejects.toThrow('Authorization payload is required'); }); diff --git a/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts b/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts index ccc819b..0e722a4 100644 --- a/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts +++ b/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts @@ -17,6 +17,7 @@ */ import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; +import ThunderIDRuntimeError from '../../errors/ThunderIDRuntimeError'; import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2} from '../../models/v2/embedded-flow-v2'; import { EmbeddedSignInFlowResponse as EmbeddedSignInFlowResponseV2, @@ -24,6 +25,24 @@ import { } from '../../models/v2/embedded-signin-flow-v2'; import injectRequestedPermissions from '../../utils/v2/injectRequestedPermissions'; +/** + * Detects whether the SDK is executing inside a browser. + */ +const isBrowser = (): boolean => + typeof window !== 'undefined' && typeof (window as {document?: unknown}).document !== 'undefined'; + +/** + * Executes a step of the V2 embedded sign-in flow against `POST /flow/execute`. + * + * @remarks + * Initiating a new sign-in flow directly from a **browser SPA** (by passing `applicationId` and + * `flowType`) is not supported — browser SPAs must use the redirect-based OAuth2 + * `authorization_code` + PKCE flow, where the IdP enforces redirection to a pre-registered + * `redirect_uri`. Attempting it in a browser throws a {@link ThunderIDRuntimeError}. + * + * Continuing an existing flow with an `executionId` — the path the hosted sign-in (Gate) UI uses — + * is unaffected, and server-side (confidential client) code may still initiate the flow. + */ const executeEmbeddedSignInFlowV2 = async ({ url, baseUrl, @@ -64,6 +83,18 @@ const executeEmbeddedSignInFlowV2 = async ({ 'executionId' in cleanPayload && Object.keys(cleanPayload).length === 1; + // Browser SPAs must not initiate a sign-in flow directly; they must use the redirect-based + // authorization_code + PKCE flow. Server-side (confidential client) initiation and browser-side + // continuation with an executionId remain supported. + if (isNewFlowStart && isBrowser()) { + throw new ThunderIDRuntimeError( + 'Browser single-page applications cannot initiate a sign-in flow directly via ' + + '"POST /flow/execute". Use the redirect-based OAuth2 authorization_code + PKCE flow instead.', + 'executeEmbeddedSignInFlowV2-SPAInitiationNotSupported', + 'javascript', + ); + } + const basePayload: Record = isNewFlowStart ? injectRequestedPermissions(cleanPayload as Record) : (cleanPayload as Record); diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 1a463b3..90d6728 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -47,6 +47,14 @@ export type SignInProps = Pick`](https://thunderid.dev/sdks/react/apis/components/sign-in-button) instead. + * This does not affect the hosted sign-in (Gate) experience, which continues a redirect-initiated + * flow. + * * @example * ```tsx * import { SignIn } from '@thunderid/react'; diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index 4e3c5a8..2fb18ae 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -29,6 +29,13 @@ export type {SignInRenderProps} from './v2/SignIn'; * * Routes to the V1 (authenticator-based) flow by default or the V2 * (component-driven) flow when `platform` is set to `Platform.ThunderID`. + * + * @remarks + * Using this component to **initiate** a sign-in flow standalone in a browser SPA (i.e. when it is + * not driven by an `executionId` from a redirect) is **not supported** and throws at runtime. + * Browser SPAs should sign in with the redirect-based OAuth2 `authorization_code` + PKCE flow via + * `` instead. This does not affect the hosted sign-in (Gate) experience, which + * continues a redirect-initiated flow. */ const SignIn: Component = defineComponent({ name: 'SignIn',