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
103 changes: 30 additions & 73 deletions src/core/auth/action-center-token-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { ActionCenterEventNames, ActionCenterEventResponsePayload } from '../../
import { TokenInfo } from './types';
import { AuthenticationError, HttpStatus } from '../errors';
import { Config } from '../config/config';

const AUTHENTICATION_TIMEOUT = 8000;
import { HostTokenResponse, isTokenExpired, isValidHostOrigin, requestHostToken } from './host-token-request';

export class ActionCenterTokenManager {
private readonly parentOrigin = new URLSearchParams(window.location.search).get('basedomain');
Expand All @@ -15,96 +14,54 @@ export class ActionCenterTokenManager {
) {}

async refreshAccessToken(tokenInfo: TokenInfo): Promise<string> {
if (!this.isTokenExpired(tokenInfo)) {
if (!isTokenExpired(tokenInfo)) {
return tokenInfo.token;
}

if (this.refreshPromise) {
return this.refreshPromise;
}

this.refreshPromise = new Promise<string>((resolve, reject) => {
const content = {
clientId: this.config.clientId,
scope: this.config.scope,
}
this.sendMessageToParent(ActionCenterEventNames.REFRESHTOKEN, content);

const messageListener = (event: MessageEvent<ActionCenterEventResponsePayload>) => {
if (event.origin !== this.parentOrigin) return;
if (event.data?.eventType !== ActionCenterEventNames.TOKENREFRESHED) return;

clearTimeout(timer);

if (event.data?.content?.token) {
const { accessToken, expiresAt } = event.data.content.token;
this.onTokenRefreshed({ token: accessToken, type: 'secret', expiresAt });
resolve(accessToken);
} else {
reject(new AuthenticationError({
message: 'Failed to fetch access token',
statusCode: HttpStatus.UNAUTHORIZED,
}));
}

this.refreshPromise = null;
this.cleanup(messageListener);
};

const timer = setTimeout(() => {
reject(new AuthenticationError({
message: 'Failed to fetch access token',
const parentOrigin = this.parentOrigin;
if (!parentOrigin) {
return Promise.reject(
new AuthenticationError({
message: 'Cannot refresh token: basedomain query parameter is missing',
statusCode: HttpStatus.UNAUTHORIZED,
}));

this.refreshPromise = null;
this.cleanup(messageListener);
}, AUTHENTICATION_TIMEOUT);
})
);
}

window.addEventListener('message', messageListener);
const { promise } = requestHostToken({
pinnedOrigin: parentOrigin,
sendRequest: () => this.sendMessageToParent(ActionCenterEventNames.REFRESHTOKEN, {
clientId: this.config.clientId,
scope: this.config.scope,
}),
responseEventType: ActionCenterEventNames.TOKENREFRESHED,
extractToken: (data): HostTokenResponse | undefined => {
const token = (data as ActionCenterEventResponsePayload)?.content?.token;
if (!token?.accessToken) return undefined;
return { accessToken: token.accessToken, expiresAt: token.expiresAt };
},
onTokenRefreshed: this.onTokenRefreshed,
});

return this.refreshPromise;
}

private isTokenExpired(tokenInfo: TokenInfo): boolean {
if (!tokenInfo?.expiresAt) {
return true;
this.refreshPromise = promise;
try {
return await this.refreshPromise;
} finally {
this.refreshPromise = null;
}

return new Date() >= tokenInfo.expiresAt;
}

private sendMessageToParent(eventType: string, content?: unknown): void {
if (window.parent && this.isValidOrigin(this.parentOrigin)) {
if (window.parent && isValidHostOrigin(this.parentOrigin)) {
try {
window.parent.postMessage({ eventType, content }, this.parentOrigin!);
} catch (error) {
console.warn('Failed to send message to Action Center', JSON.stringify(error));
console.warn('ActionCenterTokenManager: postMessage to host failed', JSON.stringify(error));
}
}
}

private cleanup(messageListener: (event: MessageEvent<ActionCenterEventResponsePayload>) => void): void {
window.removeEventListener('message', messageListener);
}

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;
}
}
}
80 changes: 80 additions & 0 deletions src/core/auth/embedded-token-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { UipEmbeddedEventNames, UipEmbeddedRefreshTokenPayload, UipEmbeddedTokenRefreshedPayload } from './uip-embedded-protocol';
import { TokenInfo } from './types';
import { Config } from '../config/config';
import { HostTokenResponse, isTokenExpired, requestHostToken } from './host-token-request';

function parseExpiresAt(raw: string): Date {
const d = new Date(raw);
// Malformed date → treat as already expired (safe default)
return isNaN(d.getTime()) ? new Date(0) : d;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address this

}

function extractToken(data: unknown): HostTokenResponse | undefined {
const token = (data as UipEmbeddedTokenRefreshedPayload)?.content?.token;
if (!token?.accessToken) return undefined;
return { accessToken: token.accessToken, expiresAt: parseExpiresAt(token.expiresAt) };
}

/**
* Handles token delegation for coded apps embedded inside a UiPath host
* (e.g. Governance Portal, Insights UI).
*
* Detection: the parent frame's origin is identified from `document.referrer`
* at startup and validated against the known UiPath host allowlist. This
* mirrors exactly how Action Center embedding works — app-initiated, no
* upfront token push from the host.
*
* On every token expiry the SDK sends `UIP.refreshToken` with `clientId` and
* `scope`; the host performs silent SSO and responds with `UIP.tokenRefreshed`.
*/
export class EmbeddedTokenManager {
private refreshPromise: Promise<string> | null = null;

/**
* @param parentOrigin Validated UiPath host origin derived from `document.referrer`.
* @param config SDK configuration — `clientId` and `scope` are forwarded in
* every `UIP.refreshToken` request so the host knows which OAuth client to use.
* @param onTokenRefreshed Called with the refreshed TokenInfo so the caller
* can persist it in the execution context.
*/
constructor(
private readonly parentOrigin: string,
private readonly config: Config,
private readonly onTokenRefreshed: (tokenInfo: TokenInfo) => void
) {}

async refreshAccessToken(tokenInfo: TokenInfo): Promise<string> {
if (!isTokenExpired(tokenInfo)) {
return tokenInfo.token;
}

if (this.refreshPromise) {
return this.refreshPromise;
}

const { promise } = requestHostToken({
pinnedOrigin: this.parentOrigin,
sendRequest: () => {
try {
const message: UipEmbeddedRefreshTokenPayload = {
eventType: UipEmbeddedEventNames.REFRESH_TOKEN,
content: { clientId: this.config.clientId, scope: this.config.scope },

Check failure on line 61 in src/core/auth/embedded-token-manager.ts

View workflow job for this annotation

GitHub Actions / test-and-build

Type 'string | undefined' is not assignable to type 'string'.

Check failure on line 61 in src/core/auth/embedded-token-manager.ts

View workflow job for this annotation

GitHub Actions / test-and-build

Type 'string | undefined' is not assignable to type 'string'.
};
window.parent.postMessage(message, this.parentOrigin);
} catch (error) {
console.warn('EmbeddedTokenManager: postMessage to host failed', error);
}
},
responseEventType: UipEmbeddedEventNames.TOKEN_REFRESHED,
extractToken,
onTokenRefreshed: this.onTokenRefreshed,
});

this.refreshPromise = promise;
try {
return await this.refreshPromise;
} finally {
this.refreshPromise = null;
}
}
}
138 changes: 138 additions & 0 deletions src/core/auth/host-token-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { TokenInfo } from './types';
import { AuthenticationError, HttpStatus } from '../errors';

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 {
return false;
Comment on lines +21 to +22
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we have error here?

}
}

export function isTokenExpired(tokenInfo: TokenInfo): boolean {
if (!tokenInfo.expiresAt) return true;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

* if (!tokenInfo?.expiresAt)

return new Date() >= tokenInfo.expiresAt;
}

export interface HostTokenResponse {
accessToken: string;
expiresAt: Date;
}

export interface HostTokenRequestOptions {
/** Origin the request is sent to and responses accepted from. */
pinnedOrigin: string;
/** Sends the refresh request to the parent frame. */
sendRequest: () => void;
/** Event type string the host sends back with the refreshed token. */
responseEventType: string;
/** Extracts the token from the host response message. Returns undefined if the payload is malformed. */
extractToken: (data: unknown) => HostTokenResponse | undefined;
/** Called with the refreshed TokenInfo before the promise resolves. */
onTokenRefreshed: (tokenInfo: TokenInfo) => void;
}

export interface HostTokenRequest {
readonly promise: Promise<string>;
/** Immediately rejects the promise and removes the response listener. */
readonly cancel: () => void;
}

/**
* Waits for the next window message that satisfies `filter`.
* Rejects if the AbortSignal fires before a matching message arrives.
*/
function waitForMessage(
filter: (event: MessageEvent) => boolean,
signal: AbortSignal
): Promise<MessageEvent> {
return new Promise((resolve, reject) => {
const handler = (event: MessageEvent): void => {
if (!filter(event)) return;
window.removeEventListener('message', handler);
resolve(event);
};

signal.addEventListener('abort', () => {
window.removeEventListener('message', handler);
reject(signal.reason);
}, { once: true });

window.addEventListener('message', handler);
});
}

/**
* Sends a token-refresh request to a parent host frame and waits for the
* response. Handles timeout, origin filtering, and listener cleanup.
*
* Both ActionCenterTokenManager and EmbeddedTokenManager delegate to this
* function; they differ only in the event names and message shape they use.
*/
export function requestHostToken(options: HostTokenRequestOptions): HostTokenRequest {
const { pinnedOrigin, sendRequest, responseEventType, extractToken, onTokenRefreshed } = options;

const controller = new AbortController();

const cancel = (): void =>
controller.abort(
new AuthenticationError({
message: 'Token refresh cancelled',
statusCode: HttpStatus.UNAUTHORIZED,
})
);

const promise = (async (): Promise<string> => {
const timer = setTimeout(
() =>
controller.abort(
new AuthenticationError({
message: `Token refresh timed out after ${AUTHENTICATION_TIMEOUT}ms waiting for host response`,
statusCode: HttpStatus.UNAUTHORIZED,
})
),
AUTHENTICATION_TIMEOUT
);

try {
// Register listener before sending — avoids any race between send and response
const responsePromise = waitForMessage(
event => event.origin === pinnedOrigin && event.data?.eventType === responseEventType,
controller.signal
);

sendRequest();

const event = await responsePromise;

const token = extractToken(event.data);
if (!token) {
throw new AuthenticationError({
message: 'Host responded but did not include a valid access token',
statusCode: HttpStatus.UNAUTHORIZED,
});
}

onTokenRefreshed({ token: token.accessToken, type: 'secret', expiresAt: token.expiresAt });
return token.accessToken;
} finally {
clearTimeout(timer);
}
})();

return { promise, cancel };
}
Loading
Loading