diff --git a/packages/javascript/README.md b/packages/javascript/README.md index b1091389..96f17337 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -17,6 +17,23 @@ 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 +`executeEmbeddedSignInFlow` 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/__tests__/executeEmbeddedSignInFlow.test.ts b/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts index bca79089..ba705892 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts +++ b/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.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/embedded-signin-flow'; import executeEmbeddedSignInFlow from '../executeEmbeddedSignInFlow'; @@ -134,6 +134,44 @@ describe('executeEmbeddedSignInFlow', (): 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( + executeEmbeddedSignInFlow({ + 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 executeEmbeddedSignInFlow({ + 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 executeEmbeddedSignInFlow({ + payload: {executionId: 'exec-abc'}, + url: URL, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + 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/executeEmbeddedSignInFlow.ts b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts index 156c8b26..5dc4f111 100644 --- a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts +++ b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts @@ -17,10 +17,29 @@ */ import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import ThunderIDRuntimeError from '../errors/ThunderIDRuntimeError'; import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../models/embedded-signin-flow'; import injectRequestedPermissions from '../utils/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 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 executeEmbeddedSignInFlow = async ({ url, baseUrl, @@ -61,6 +80,18 @@ const executeEmbeddedSignInFlow = 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.', + 'executeEmbeddedSignInFlow-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 be9ed296..3e5bde86 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -162,6 +162,14 @@ interface PasskeyState { * This component handles the flow API calls for authentication and delegates UI logic to BaseSignIn. * It automatically transforms simple input-based responses into component-driven UI format. * + * @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 + * [``](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 * // Default UI * ```tsx diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index a59c8992..b2e5d4a1 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -81,6 +81,13 @@ export interface SignInRenderProps { * Initializes the authentication flow, handles passkey authentication/registration, * OAuth redirect flows, and renders the UI via `BaseSignIn` or a scoped slot. * + * @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. + * * @example * ```vue *