Skip to content

Commit 800732c

Browse files
authored
Merge 04b506c into 09a7aea
2 parents 09a7aea + 04b506c commit 800732c

File tree

21 files changed

+605
-12
lines changed

21 files changed

+605
-12
lines changed

.changeset/honest-donuts-allow.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@forgerock/iframe-manager': minor
3+
'@forgerock/sdk-oidc': minor
4+
'@forgerock/sdk-types': minor
5+
---
6+
7+
Adds IFrame manager package to be able to create iframes and parse search params from the iframe url.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# IFrame Manager (`@pingidentity/sdk-effects/iframe-manager`)
2+
3+
## Overview
4+
5+
The IFrame Manager Effect provides a mechanism to perform operations within a hidden `<iframe>` that involve navigating to an external URL and waiting for a redirect back to the application's origin. It's commonly used for flows like silent authentication or fetching tokens where user interaction is not required, and the result is communicated via query parameters in the final redirect URL.
6+
7+
The core functionality involves:
8+
9+
1. Creating a hidden `<iframe>` dynamically.
10+
1. Navigating the iframe to a specified URL.
11+
1. Monitoring the iframe's `load` events to detect navigation changes.
12+
1. Once a navigation occurs back to the **same origin** as the parent application, parsing the query parameters from the iframe's URL.
13+
1. Resolving or rejecting a Promise based on the presence of predefined "success" or "error" query parameters.
14+
1. Handling timeouts and potential errors (like cross-origin access restrictions).
15+
16+
**Key Constraint: Same-Origin Policy**
17+
18+
This utility fundamentally relies on the browser's **Same-Origin Policy**. The final URL that the iframe is redirected to (the one containing the expected `successParams` or `errorParams`) **MUST** be on the exact same origin (protocol, hostname, and port) as the main application window. Attempting to access the location (`contentWindow.location`) of an iframe pointing to a different origin will be blocked by the browser, causing the operation to fail.
19+
20+
## Installation
21+
22+
This effect is typically part of a larger SDK. Assume it's imported or available within your project structure like so (adjust path as necessary):
23+
24+
```typescript
25+
import iFrameManager from './path/to/iframe-manager.effects'; // Adjust path as needed
26+
27+
const iframeMgr = iFrameManager();
28+
```
29+
30+
## API Reference
31+
32+
### `iFrameManager()`
33+
34+
This is the main factory function that initializes the effect.
35+
36+
- **Returns:** `object` - An object containing the API methods for managing iframe requests.
37+
38+
### `iframeMgr.getParamsByRedirect(options: GetParamsFromIFrameOptions): Promise<ResolvedParams>`
39+
40+
This method creates a hidden iframe, initiates navigation, and waits for a redirect back to the application's origin containing specific query parameters.
41+
42+
- **`options`**: `GetParamsFromIFrameOptions` - An object containing configuration for the iframe request.
43+
44+
- **`url: string`**: The initial URL to load within the hidden iframe. This URL is expected to eventually redirect back to the application's origin.
45+
- **`timeout: number`**: The maximum time in milliseconds to wait for the entire operation to complete successfully (i.e., for a redirect containing success or error parameters). If the timeout is reached before completion, the promise rejects.
46+
47+
* **`successParams: string[]`**: An array of query parameter _keys_. If the final redirect URL (on the same origin) contains **at least one** of these keys in its query string, the promise will **resolve**.
48+
* **`errorParams: string[]`**: An array of query parameter _keys_. If the final redirect URL (on the same origin) contains **any** of these keys in its query string, the promise will **reject**. Error parameters are checked _before_ success parameters.
49+
- _Note:_ Both `successParams` and `errorParams` must be provided and contain at least one key.
50+
51+
- **Returns**: `Promise<ResolvedParams>`
52+
53+
- **On Success**: Resolves with `ResolvedParams`, an object containing _all_ query parameters parsed from the final redirect URL's query string. This occurs when the iframe redirects back to the same origin and its URL contains at least one key listed in `successParams` (and no keys listed in `errorParams`).
54+
- **On Failure**: Rejects with:
55+
- `ResolvedParams`: An object containing _all_ parsed query parameters if the final redirect URL contains any key listed in `errorParams`.
56+
- An object `{ type: 'internal_error', message: 'iframe timed out' }` if the specified `timeout` is reached before a success or error condition is met.
57+
- An object `{ type: 'internal_error', message: 'unexpected failure' }` if there's an error accessing the iframe's content window (most likely due to a cross-origin redirect that wasn't expected or handled).
58+
- An object `{ type: 'internal_error', message: 'error setting up iframe' }` if there was an issue creating or configuring the iframe initially.
59+
- An `Error` if `successParams` or `errorParams` are missing or empty during setup.
60+
61+
- **`ResolvedParams`**: `Record<string, string>` - A simple key-value object representing the parsed query parameters.
62+
63+
## Usage Example
64+
65+
```typescript
66+
import iFrameManager from './path/to/iframe-manager.effects'; // Adjust path
67+
68+
const iframeMgr = iFrameManager();
69+
70+
async function performSilentLogin(authUrl: string) {
71+
const options = {
72+
url: authUrl, // e.g., 'https://auth.example.com/authorize?prompt=none&client_id=...'
73+
timeout: 10000, // 10 seconds timeout
74+
successParams: ['code', 'id_token', 'session_state'], // Expect one of these on success
75+
errorParams: ['error', 'error_description'], // Expect one of these on failure
76+
};
77+
78+
try {
79+
console.log('Attempting silent login via iframe...');
80+
// The promise resolves/rejects when the iframe redirects back to *this* app's origin
81+
// with appropriate query parameters.
82+
const resultParams = await iframeMgr.getParamsByRedirect(options);
83+
84+
// Success case: 'code', 'id_token', or 'session_state' was present
85+
console.log('Silent login successful. Received params:', resultParams);
86+
// Process the received parameters (e.g., exchange code for token)
87+
// const code = resultParams.code;
88+
// const state = resultParams.state; // Other params are included too
89+
} catch (errorResult) {
90+
// Failure case: Check if it's a known error from the server or an internal error
91+
if (errorResult && errorResult.type === 'internal_error') {
92+
// Timeout or iframe access error
93+
console.error(`Iframe operation failed: ${errorResult.message}`);
94+
} else if (errorResult && (errorResult.error || errorResult.error_description)) {
95+
// Error reported by the authorization server via errorParams
96+
console.error('Silent login failed. Server returned error:', errorResult);
97+
// const errorCode = errorResult.error;
98+
// const errorDesc = errorResult.error_description;
99+
} else {
100+
// Other unexpected error
101+
console.error('An unexpected error occurred:', errorResult);
102+
}
103+
}
104+
}
105+
106+
// Example usage:
107+
// Assuming your app is running on https://app.example.com
108+
// and the auth server will redirect back to https://app.example.com/callback?code=... or ?error=...
109+
const authorizationUrl =
110+
'https://auth.example.com/authorize?prompt=none&client_id=abc&redirect_uri=https://app.example.com/callback&response_type=code&scope=openid';
111+
performSilentLogin(authorizationUrl);
112+
```
113+
114+
## Important Considerations
115+
116+
1. **Same-Origin Redirect:** This cannot be stressed enough. The URL specified in `options.url` _must_ eventually redirect back to a URL on the **same origin** as your main application for this mechanism to work. Cross-origin restrictions will prevent the script from reading the final URL's parameters otherwise.
117+
1. **Timeout:** Choose a reasonable `timeout` value. If the external service is slow or the redirect chain is long, the operation might time out prematurely. Conversely, too long a timeout might delay feedback to the user if something goes wrong.
118+
1. **Intermediate Redirects:** The code handles intermediate redirects (pages loaded within the iframe that don't contain success or error parameters) by simply waiting for the next `load` event. The process only completes upon detecting success/error parameters or timing out.
119+
1. **Cleanup:** The utility ensures the iframe element is removed from the DOM and the timeout timer is cleared upon completion (resolve, reject, or timeout) to prevent memory leaks.
120+
1. **Error Parameter Precedence:** Error parameters (`errorParams`) are checked before success parameters (`successParams`). If a redirect URL contains both an error parameter and a success parameter, the promise will be **rejected**.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import baseConfig from '../../../eslint.config.mjs';
2+
3+
export default [
4+
...baseConfig,
5+
{
6+
files: ['**/*.json'],
7+
rules: {
8+
'@nx/dependency-checks': [
9+
'error',
10+
{
11+
ignoredFiles: [
12+
'{projectRoot}/eslint.config.{js,cjs,mjs}',
13+
'{projectRoot}/vite.config.{js,ts,mjs,mts}',
14+
],
15+
},
16+
],
17+
},
18+
languageOptions: {
19+
parser: await import('jsonc-eslint-parser'),
20+
},
21+
},
22+
];
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@forgerock/iframe-manager",
3+
"version": "0.0.1",
4+
"private": false,
5+
"type": "module",
6+
"main": "./dist/src/index.js",
7+
"module": "./dist/src/index.js",
8+
"types": "./dist/src/index.d.ts",
9+
"exports": {
10+
"./package.json": "./package.json",
11+
".": {
12+
"types": "./dist/src/index.d.ts",
13+
"import": "./dist/src/index.js",
14+
"default": "./dist/src/index.js"
15+
}
16+
},
17+
"dependencies": {
18+
"@forgerock/sdk-request-middleware": "workspace:*",
19+
"@forgerock/sdk-utilities": "workspace:*",
20+
"@forgerock/sdk-types": "workspace:*",
21+
"tslib": "^2.3.0"
22+
},
23+
"nx": {
24+
"tags": ["scope:sdk-effects"]
25+
}
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lib/iframe-manager.effects.js';
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/*
2+
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
3+
*
4+
* This software may be modified and distributed under the terms
5+
* of the MIT license. See the LICENSE file for details.
6+
*/
7+
8+
/* eslint-disable @typescript-eslint/no-empty-function */
9+
10+
export interface GetParamsFromIFrameOptions {
11+
/** The URL to load in the iframe. */
12+
url: string;
13+
/** Timeout in milliseconds for the entire operation. */
14+
timeout: number;
15+
/** Array of query parameter keys expected upon successful completion. */
16+
successParams: string[];
17+
/** Array of query parameter keys indicating an error occurred. */
18+
errorParams: string[];
19+
}
20+
21+
type ResolvedParams = Record<string, string>;
22+
23+
type Noop = () => void;
24+
25+
function hasErrorParams(params: URLSearchParams, errorParams: string[]): boolean {
26+
for (const key of errorParams) {
27+
if (params.has(key)) {
28+
return true;
29+
}
30+
}
31+
return false;
32+
}
33+
34+
// Helper function to check if all required success params are present
35+
function hasSomeSuccessParams(params: URLSearchParams, successParams: string[]): boolean {
36+
return successParams.some((key) => params.has(key));
37+
}
38+
39+
function searchParamsToRecord(params: URLSearchParams): ResolvedParams {
40+
const result: ResolvedParams = {};
41+
params.forEach((value, key) => {
42+
result[key] = value;
43+
});
44+
return result;
45+
}
46+
47+
/**
48+
* Initializes the Iframe Manager effect.
49+
* @returns An object containing the API for managing iframe requests.
50+
*/
51+
export default function iFrameManager() {
52+
/**
53+
* Creates a hidden iframe to navigate to the specified URL,
54+
* waits for a redirect back to the application's origin,
55+
* and resolves/rejects based on the query parameters found in the redirect URL.
56+
* IMPORTANT: This relies on the final redirect target being on the SAME ORIGIN
57+
* as the parent window due to browser security restrictions (Same-Origin Policy).
58+
* Accessing contentWindow.location of a cross-origin iframe will fail.
59+
*
60+
* @param options - The options for the iframe request (URL, timeout, success/error params).
61+
* @returns A Promise that resolves with the parsed query parameters on success,
62+
* or rejects on error, timeout, or if unable to access iframe content.
63+
*/
64+
return {
65+
getParamsByRedirect: (options: GetParamsFromIFrameOptions): Promise<ResolvedParams> => {
66+
const { url, timeout, successParams, errorParams } = options;
67+
68+
if (
69+
!successParams ||
70+
!errorParams ||
71+
successParams.length === 0 ||
72+
errorParams.length === 0
73+
) {
74+
const error = new Error('successParams and errorParams must be provided');
75+
throw error;
76+
}
77+
78+
return new Promise<ResolvedParams>((resolve, reject) => {
79+
let iframe: HTMLIFrameElement | null = null;
80+
let timerId: ReturnType<typeof setTimeout> | null = null;
81+
82+
let onLoadHandler: () => void = () => {};
83+
let cleanup: Noop = () => {};
84+
85+
cleanup = (): void => {
86+
if (!iframe && !timerId) return;
87+
88+
if (timerId) {
89+
clearTimeout(timerId);
90+
timerId = null;
91+
}
92+
if (iframe) {
93+
iframe.removeEventListener('load', onLoadHandler);
94+
if (iframe.parentNode) {
95+
iframe.remove();
96+
}
97+
iframe = null;
98+
}
99+
onLoadHandler = () => {};
100+
cleanup = () => {};
101+
};
102+
103+
onLoadHandler = (): void => {
104+
try {
105+
if (iframe && iframe.contentWindow) {
106+
const currentIframeHref = iframe.contentWindow.location.href;
107+
108+
if (currentIframeHref === 'about:blank' || !currentIframeHref) {
109+
return; // Wait for actual navigation
110+
}
111+
112+
const redirectUrl = new URL(currentIframeHref);
113+
const searchParams = redirectUrl.searchParams;
114+
const parsedParams = searchParamsToRecord(searchParams);
115+
116+
// 1. Check for Error Parameters
117+
if (hasErrorParams(searchParams, errorParams)) {
118+
cleanup();
119+
reject(parsedParams); // Reject with all parsed params for context
120+
return;
121+
}
122+
123+
// 2. Check for Success Parameters
124+
if (hasSomeSuccessParams(searchParams, successParams)) {
125+
cleanup();
126+
resolve(parsedParams); // Resolve with all parsed params
127+
return;
128+
}
129+
130+
/*
131+
* 3. Neither Error nor Success: Intermediate Redirect?
132+
* If neither error nor all required success params are found,
133+
* assume it's an intermediate step in the redirect flow.
134+
* Do nothing, let the timeout eventually handle non-resolving states
135+
* or wait for the next 'load' event.
136+
*/
137+
}
138+
} catch {
139+
// This likely means a cross-origin navigation occurred where access is denied.
140+
cleanup();
141+
reject({
142+
type: 'internal_error',
143+
message: 'unexpected failure',
144+
});
145+
}
146+
};
147+
148+
try {
149+
iframe = document.createElement('iframe');
150+
iframe.style.display = 'none'; // Hide the iframe
151+
iframe.addEventListener('load', onLoadHandler);
152+
document.body.appendChild(iframe);
153+
154+
timerId = setTimeout(() => {
155+
cleanup();
156+
reject({
157+
type: 'internal_error',
158+
message: 'iframe timed out',
159+
});
160+
}, timeout);
161+
162+
iframe.src = url;
163+
} catch {
164+
cleanup(); // Attempt cleanup even if setup failed partially
165+
reject({
166+
type: 'internal_error',
167+
message: 'error setting up iframe',
168+
});
169+
}
170+
});
171+
},
172+
};
173+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"extends": "../../../tsconfig.base.json",
3+
"files": [],
4+
"include": [],
5+
"references": [
6+
{
7+
"path": "../../sdk-types"
8+
},
9+
{
10+
"path": "../../sdk-utilities"
11+
},
12+
{
13+
"path": "../sdk-request-middleware"
14+
},
15+
{
16+
"path": "./tsconfig.lib.json"
17+
},
18+
{
19+
"path": "./tsconfig.spec.json"
20+
}
21+
],
22+
"nx": {
23+
"addTypecheckTarget": false
24+
}
25+
}

0 commit comments

Comments
 (0)