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
47 changes: 11 additions & 36 deletions packages/coded-action-app/src/coded-action-app-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class CodedActionAppService implements CodedActionAppServiceModel {
*
* @returns A promise that resolves with a {@link TaskCompleteResponse} object
* containing success and error message if any.
* @throws {Error} If called from an untrusted origin.
* @throws {Error} If the host origin (`basedomain` query parameter) is missing.
* @throws {Error} If a completeTask call is already in progress.
*/
@track('CodedActionApp.CompleteTask')
Expand All @@ -63,9 +63,9 @@ export class CodedActionAppService implements CodedActionAppServiceModel {
const content = { data, action: actionTaken };

return new Promise<TaskCompleteResponse>((resolve, reject) => {
if (!this.isValidOrigin(this.parentOrigin)) {
if (!this.parentOrigin) {
this.isCompletingTask = false;
reject(new Error('Discarding event from invalid origin'));
reject(new Error('Cannot complete task: basedomain query parameter is missing'));
return;
}

Expand Down Expand Up @@ -102,14 +102,14 @@ export class CodedActionAppService implements CodedActionAppServiceModel {
*
* @returns A promise that resolves with a {@link Task} object
* containing task metadata and data.
* @throws {Error} If called from an untrusted origin.
* @throws {Error} If the host origin (`basedomain` query parameter) is missing.
* @throws {Error} If Action Center does not respond within the allotted timeout.
*/
@track('CodedActionApp.GetTask')
getTask(): Promise<Task> {
return new Promise((resolve, reject) => {
if (!this.isValidOrigin(this.parentOrigin)) {
reject(new Error('Discarding event from invalid origin'));
if (!this.parentOrigin) {
reject(new Error('Cannot get task: basedomain query parameter is missing'));
return;
}

Expand Down Expand Up @@ -145,19 +145,19 @@ export class CodedActionAppService implements CodedActionAppServiceModel {
}

/**
* Posts a structured message to the parent (Action Center) frame.
* Skips the call if the parent origin is not trusted.
* Posts a structured message to the parent (Action Center) frame, pinned to the
* `basedomain` origin. Skips the call if `basedomain` is absent.
* On serialisation errors, forwards an error event which displays an error toast in Action Center
*
* @param eventType - The {@link ActionCenterEventNames} event identifier to send.
* @param content - Optional payload to include with the event.
*/
private sendMessageToParent(eventType: string, content?: unknown): void {
if (window.parent && this.isValidOrigin(this.parentOrigin)) {
if (window.parent && this.parentOrigin) {
try {
window.parent.postMessage(
{ eventType, content },
this.parentOrigin!,
this.parentOrigin,
);
} catch (error) {
window.parent.postMessage(
Expand All @@ -167,35 +167,10 @@ export class CodedActionAppService implements CodedActionAppServiceModel {
errorData: error,
}
},
this.parentOrigin!
this.parentOrigin
);
}
}
}

/**
* Validates that the given origin is a known UiPath environment or a local development server,
* guarding against cross-origin message spoofing.
*
* @param origin - The origin string to validate, sourced from the `basedomain` query parameter.
* @returns `true` if the origin is trusted, `false` otherwise.
*/
private isValidOrigin(origin: string | null): boolean {
const ALLOWED_ORIGINS = ['https://alpha.uipath.com', 'https://staging.uipath.com', 'https://cloud.uipath.com'];

if (!origin) {
return false;
}

if (ALLOWED_ORIGINS.includes(origin)) {
return true;
}

try {
const url = new URL(origin);
return url.hostname === 'localhost';
} catch {
return false;
}
}
}
4 changes: 2 additions & 2 deletions packages/coded-action-app/src/coded-action-app.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export interface CodedActionAppServiceModel {
*
* @returns A promise that resolves with a {@link TaskCompleteResponse} object
* containing success and error message if any.
* @throws {Error} If called from an untrusted origin.
* @throws {Error} If the host origin (`basedomain` query parameter) is missing.
* @throws {Error} If a completeTask call is already in progress.
* @example
* ```typescript
Expand Down Expand Up @@ -79,7 +79,7 @@ export interface CodedActionAppServiceModel {
*
* @returns A promise that resolves with a {@link Task} object
* containing task metadata and data.
* @throws {Error} If called from an untrusted origin.
* @throws {Error} If the host origin (`basedomain` query parameter) is missing.
* @throws {Error} If Action Center does not respond within the allotted timeout.
* @example
* ```typescript
Expand Down
18 changes: 3 additions & 15 deletions src/core/auth/action-center-token-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ActionCenterEventNames, ActionCenterEventResponsePayload } from '../../
import { TokenInfo } from './types';
import { AuthenticationError, HttpStatus } from '../errors';
import { Config } from '../config/config';
import { HostTokenResponse, isTokenExpired, isValidHostOrigin, requestHostToken } from './host-token-request';
import { HostTokenResponse, isTokenExpired, requestHostToken } from './host-token-request';

export class ActionCenterTokenManager {
private readonly parentOrigin = new URLSearchParams(window.location.search).get('basedomain');
Expand Down Expand Up @@ -32,18 +32,6 @@ export class ActionCenterTokenManager {
);
}

// Guard before requestHostToken registers the inbound listener — an untrusted
// basedomain would otherwise leave the listener live for the full timeout window,
// accepting a forged TOKENREFRESHED from that origin.
if (!isValidHostOrigin(parentOrigin)) {
return Promise.reject(
new AuthenticationError({
message: 'Cannot refresh token: basedomain is not a trusted UiPath host origin',
statusCode: HttpStatus.UNAUTHORIZED,
})
);
}

const { promise } = requestHostToken({
pinnedOrigin: parentOrigin,
sendRequest: () => this.sendMessageToParent(ActionCenterEventNames.REFRESHTOKEN, {
Expand All @@ -68,9 +56,9 @@ export class ActionCenterTokenManager {
}

private sendMessageToParent(eventType: string, content?: unknown): void {
if (window.parent && isValidHostOrigin(this.parentOrigin)) {
if (window.parent && this.parentOrigin) {
try {
window.parent.postMessage({ eventType, content }, this.parentOrigin!);
window.parent.postMessage({ eventType, content }, this.parentOrigin);
} catch (error) {
console.warn('ActionCenterTokenManager: postMessage to host failed', JSON.stringify(error));
}
Expand Down
6 changes: 3 additions & 3 deletions src/core/auth/embedded-token-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ function extractToken(data: unknown): HostTokenResponse | undefined {
* (e.g. Governance Portal, Insights UI).
*
* Detection: the host signals embedding via `?host=embed&basedomain=<origin>`
* in the iframe src URL. `parentOrigin` is read from `?basedomain=` and validated
* against the trusted UiPath host allowlist before this manager is constructed.
* This mirrors the mechanism used by ActionCenterTokenManager.
* in the iframe src URL. `parentOrigin` is read from `?basedomain=` and pinned:
* requests are sent only to it and only messages whose `event.origin` matches it
* are accepted. This mirrors the mechanism used by ActionCenterTokenManager.
*
* On every token expiry the SDK sends `UIP.refreshToken` with `clientId` and
* `scope`; the host performs silent SSO and responds with `UIP.tokenRefreshed`.
Expand Down
33 changes: 7 additions & 26 deletions src/core/auth/host-token-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,23 @@ import { embeddingOrigin, isHostEmbedded } from '../../utils/platform';

export const AUTHENTICATION_TIMEOUT = 8000;

const ALLOWED_HOST_ORIGINS = new Set([
'https://alpha.uipath.com',
'https://staging.uipath.com',
'https://cloud.uipath.com',
]);

/**
* Returns true if the origin is a trusted UiPath host that may initiate
* token delegation. Mirrors the same allowlist used by ActionCenterTokenManager.
*/
export function isValidHostOrigin(origin: string | null): boolean {
if (!origin) return false;
if (ALLOWED_HOST_ORIGINS.has(origin)) return true;
try {
return new URL(origin).hostname === 'localhost';
} catch {
console.warn('isValidHostOrigin: received a malformed origin URL', origin);
return false;
}
}

export function isTokenExpired(tokenInfo: TokenInfo): boolean {
if (!tokenInfo?.expiresAt) return true;
return new Date() >= tokenInfo.expiresAt;
}

/**
* The validated host origin when the app is running as a trusted, generic
* host-embedded app (`?host=embed&basedomain=<origin>` with an allowlisted
* UiPath origin); otherwise null. Shared by TokenManager (to create the
* The host origin when the app is running as a generic host-embedded app
* (`?host=embed&basedomain=<origin>`); otherwise null. Host origins are
* customer-configurable and follow no fixed pattern, so the origin is taken as
* given and only pinned — every request is sent to it and only messages whose
* `event.origin` matches it are accepted. Shared by TokenManager (to create the
* EmbeddedTokenManager) and UiPath init (to seed an empty token so getValidToken
* can bootstrap the postMessage token flow), which previously duplicated this
* condition inline.
*/
export const trustedEmbeddingOrigin: string | null =
isHostEmbedded && embeddingOrigin && isValidHostOrigin(embeddingOrigin) ? embeddingOrigin : null;
export const hostEmbeddingOrigin: string | null =
isHostEmbedded && embeddingOrigin ? embeddingOrigin : null;

export interface HostTokenResponse {
accessToken: string;
Expand Down
6 changes: 3 additions & 3 deletions src/core/auth/token-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Config } from '../config/config';
import { AuthenticationError, HttpStatus } from '../errors';
import { ActionCenterTokenManager } from './action-center-token-manager';
import { EmbeddedTokenManager } from './embedded-token-manager';
import { trustedEmbeddingOrigin } from './host-token-request';
import { hostEmbeddingOrigin } from './host-token-request';
import { telemetryClient } from '../telemetry';
import { extractUserIdFromToken } from '../../utils/encoding';

Expand Down Expand Up @@ -37,8 +37,8 @@ export class TokenManager {
if (isInActionCenter) {
this.actionCenterTokenManager = new ActionCenterTokenManager(config, (tokenInfo) => this.setToken(tokenInfo));
this.isOAuth = false;
} else if (trustedEmbeddingOrigin) {
this.embeddedTokenManager = new EmbeddedTokenManager(trustedEmbeddingOrigin, config, tokenInfo => this.setToken(tokenInfo));
} else if (hostEmbeddingOrigin) {
this.embeddedTokenManager = new EmbeddedTokenManager(hostEmbeddingOrigin, config, tokenInfo => this.setToken(tokenInfo));
this.isOAuth = false;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/uipath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { loadFromEnvironment } from './config/environment';
import { configFromFunctionContext, isFunctionContext, type CodedFunctionContext } from './config/function-context';
import type { IUiPath } from './types';
import { isInActionCenter } from '../utils/platform';
import { trustedEmbeddingOrigin } from './auth/host-token-request';
import { hostEmbeddingOrigin } from './auth/host-token-request';

/**
* UiPath - Core SDK class for authentication and configuration management.
Expand Down Expand Up @@ -185,7 +185,7 @@ export class UiPath implements IUiPath {
* initialize tokenInfo with an empty token so getValidToken() can bootstrap via postMessage.
* When an sdk call is made, the host passes the token to the sdk.
*/
if (hasSecretAuth || isInActionCenter || trustedEmbeddingOrigin) {
if (hasSecretAuth || isInActionCenter || hostEmbeddingOrigin) {
this.#authService.authenticateWithSecret(config.secret ?? '');
this.#initialized = true;
}
Expand Down
17 changes: 11 additions & 6 deletions tests/unit/core/auth/action-center-token-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,21 @@ describe('ActionCenterTokenManager', () => {
expect(mock.parentPostMessage).not.toHaveBeenCalled();
});

it('rejects before registering a listener when basedomain is not a trusted origin', async () => {
mock = makeWindowMock('https://evil.example.com');
// Host origins are customer-configurable and follow no fixed pattern, so the
// basedomain is used as given — pinned as both the postMessage target and the
// only accepted event.origin.
it('pins the refresh to a non-uipath.com basedomain', async () => {
const customerOrigin = 'https://automation.customer-sf.internal';
mock = makeWindowMock(customerOrigin);
global.window = mock as unknown as Window & typeof globalThis;
manager = new ActionCenterTokenManager(MOCK_CONFIG, onTokenRefreshed);

const expired: TokenInfo = { token: 'tok-old', type: 'secret', expiresAt: new Date(0) };
await expect(manager.refreshAccessToken(expired)).rejects.toBeInstanceOf(AuthenticationError);
// No listener registered — the inbound listener window is never opened
expect(mock.addEventListener).not.toHaveBeenCalledWith('message', expect.any(Function));
expect(mock.parentPostMessage).not.toHaveBeenCalled();
const refreshPromise = manager.refreshAccessToken(expired);
mock.dispatch(makeRefreshedEvent(customerOrigin, 'tok-new'));

expect(await refreshPromise).toBe('tok-new');
expect(mock.parentPostMessage).toHaveBeenCalledWith(expect.any(Object), customerOrigin);
});

// ---- refresh flow ----
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,23 @@ interface PlatformMock {
embeddingOrigin: string | null;
}

// trustedEmbeddingOrigin is a module-level const evaluated at import time from the
// hostEmbeddingOrigin is a module-level const evaluated at import time from the
// platform flags, so each scenario re-mocks platform and re-imports the module.
async function loadTrustedEmbeddingOrigin(platform: PlatformMock): Promise<string | null> {
async function loadHostEmbeddingOrigin(platform: PlatformMock): Promise<string | null> {
vi.resetModules();
vi.doMock('@/utils/platform', () => platform);
const mod = await import('@/core/auth/host-token-request');
return mod.trustedEmbeddingOrigin;
return mod.hostEmbeddingOrigin;
}

describe('trustedEmbeddingOrigin', () => {
describe('hostEmbeddingOrigin', () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock('@/utils/platform');
});

it('resolves to the origin when host-embedded with a trusted UiPath origin', async () => {
const origin = await loadTrustedEmbeddingOrigin({
it('resolves to the origin when host-embedded', async () => {
const origin = await loadHostEmbeddingOrigin({
isBrowser: true,
isInActionCenter: false,
isHostEmbedded: true,
Expand All @@ -32,8 +32,8 @@ describe('trustedEmbeddingOrigin', () => {
expect(origin).toBe('https://cloud.uipath.com');
});

it('is null when not host-embedded even with a valid origin', async () => {
const origin = await loadTrustedEmbeddingOrigin({
it('is null when not host-embedded even with an origin present', async () => {
const origin = await loadHostEmbeddingOrigin({
isBrowser: true,
isInActionCenter: false,
isHostEmbedded: false,
Expand All @@ -42,18 +42,20 @@ describe('trustedEmbeddingOrigin', () => {
expect(origin).toBeNull();
});

it('is null when the embedding origin is not a trusted UiPath host', async () => {
const origin = await loadTrustedEmbeddingOrigin({
// Host origins are customer-configurable and follow no fixed pattern, so any
// origin the host supplies is taken as given and simply pinned.
it('resolves to the origin for a non-uipath.com host domain', async () => {
const origin = await loadHostEmbeddingOrigin({
isBrowser: true,
isInActionCenter: false,
isHostEmbedded: true,
embeddingOrigin: 'https://evil.example.com',
embeddingOrigin: 'https://automation.customer-sf.internal',
});
expect(origin).toBeNull();
expect(origin).toBe('https://automation.customer-sf.internal');
});

it('is null when embeddingOrigin is null', async () => {
const origin = await loadTrustedEmbeddingOrigin({
const origin = await loadHostEmbeddingOrigin({
isBrowser: true,
isInActionCenter: false,
isHostEmbedded: true,
Expand Down
Loading
Loading