Skip to content
Open
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
18 changes: 18 additions & 0 deletions packages/javascript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
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/v2/embedded-signin-flow-v2';
import executeEmbeddedSignInFlowV2 from '../executeEmbeddedSignInFlowV2';

Expand Down Expand Up @@ -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<void> => {
(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<void> => {
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<void> => {
(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<void> => {
await expect(executeEmbeddedSignInFlowV2({url: URL})).rejects.toThrow('Authorization payload is required');
});
Expand Down
31 changes: 31 additions & 0 deletions packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,32 @@
*/

import ThunderIDAPIError from '../../errors/ThunderIDAPIError';
import ThunderIDRuntimeError from '../../errors/ThunderIDRuntimeError';
import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2} from '../../models/v2/embedded-flow-v2';
import {
EmbeddedSignInFlowResponse as EmbeddedSignInFlowResponseV2,
EmbeddedSignInFlowStatus as EmbeddedSignInFlowStatusV2,
} 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,
Expand Down Expand Up @@ -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<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 @@ -47,6 +47,14 @@ export type SignInProps = Pick<BaseSignInProps, 'className' | 'onSuccess' | 'onE
* A styled SignIn component that provides native authentication flow with pre-built styling.
* This component handles the API calls for authentication and delegates UI logic to BaseSignIn.
*
* @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
* ```tsx
* import { SignIn } from '@thunderid/react';
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 @@ -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
* `<SignInButton />` instead. This does not affect the hosted sign-in (Gate) experience, which
* continues a redirect-initiated flow.
*/
const SignIn: Component = defineComponent({
name: 'SignIn',
Expand Down
Loading