Skip to content
Closed
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
17 changes: 17 additions & 0 deletions packages/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<void> => {
(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<void> => {
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<void> => {
(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<void> => {
await expect(executeEmbeddedSignInFlow({url: URL})).rejects.toThrow('Authorization payload is required');
});
Expand Down
31 changes: 31 additions & 0 deletions packages/javascript/src/api/executeEmbeddedSignInFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, unknown> = isNewFlowStart
? injectRequestedPermissions(cleanPayload as Record<string, unknown>)
: (cleanPayload as Record<string, unknown>);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
* [`<SignInButton />`](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
Expand Down
7 changes: 7 additions & 0 deletions packages/vue/src/components/auth/sign-in/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
* `<SignInButton />` instead. This does not affect the hosted sign-in (Gate) experience, which
* continues a redirect-initiated flow.
*
* @example
* ```vue
* <!-- Default UI -->
Expand Down
Loading