Skip to content

Commit 2c004f1

Browse files
feat(auth): generalised postMessage auth protocol for coded app embedding
Adds a UIP.* postMessage token-delegation protocol so any UiPath host (Governance Portal, Insights UI, etc.) can embed a coded app with seamless authentication — without requiring hosts to fake ?source=ActionCenter. Detection: mirrors Action Center's app-initiated pattern. The embedding host is identified from document.referrer at startup; if it is a known UiPath origin the SDK activates EmbeddedTokenManager automatically — no URL params, no meta tags, no host-side configuration required. Protocol (app-initiated, same as AC): App → Host: UIP.refreshToken { clientId, scope } Host → App: UIP.tokenRefreshed { token: { accessToken, expiresAt } } Changes: - platform.ts: add embeddingOrigin derived from document.referrer - uip-embedded-protocol.ts: UIP.refreshToken / UIP.tokenRefreshed events and payload types (no UIP.init — host push not needed) - embedded-token-manager.ts: takes validated parentOrigin; no init listener, no state machine — structurally identical to AC manager - token-manager.ts: instantiates EmbeddedTokenManager when embeddingOrigin passes isValidHostOrigin; sets isOAuth=false to prevent OAuth race - host-token-request.ts: shared requestHostToken + isValidHostOrigin (explicit allowlist: alpha/staging/cloud.uipath.com + localhost) - action-center-token-manager.ts: delegates to requestHostToken/isValidHostOrigin - IUiPath + UiPath: expose destroy() to release any in-flight resources - Removed: platformHosted config field, uipath:platform-hosted meta tag, UIP.init event, handleInit listener, isActive() state - 50 unit tests across 6 files Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2d7fbc4 commit 2c004f1

17 files changed

Lines changed: 1001 additions & 77 deletions

src/core/auth/action-center-token-manager.ts

Lines changed: 30 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import { ActionCenterEventNames, ActionCenterEventResponsePayload } from '../../
22
import { TokenInfo } from './types';
33
import { AuthenticationError, HttpStatus } from '../errors';
44
import { Config } from '../config/config';
5-
6-
const AUTHENTICATION_TIMEOUT = 8000;
5+
import { HostTokenResponse, isTokenExpired, isValidHostOrigin, requestHostToken } from './host-token-request';
76

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

1716
async refreshAccessToken(tokenInfo: TokenInfo): Promise<string> {
18-
if (!this.isTokenExpired(tokenInfo)) {
17+
if (!isTokenExpired(tokenInfo)) {
1918
return tokenInfo.token;
2019
}
2120

2221
if (this.refreshPromise) {
2322
return this.refreshPromise;
2423
}
2524

26-
this.refreshPromise = new Promise<string>((resolve, reject) => {
27-
const content = {
28-
clientId: this.config.clientId,
29-
scope: this.config.scope,
30-
}
31-
this.sendMessageToParent(ActionCenterEventNames.REFRESHTOKEN, content);
32-
33-
const messageListener = (event: MessageEvent<ActionCenterEventResponsePayload>) => {
34-
if (event.origin !== this.parentOrigin) return;
35-
if (event.data?.eventType !== ActionCenterEventNames.TOKENREFRESHED) return;
36-
37-
clearTimeout(timer);
38-
39-
if (event.data?.content?.token) {
40-
const { accessToken, expiresAt } = event.data.content.token;
41-
this.onTokenRefreshed({ token: accessToken, type: 'secret', expiresAt });
42-
resolve(accessToken);
43-
} else {
44-
reject(new AuthenticationError({
45-
message: 'Failed to fetch access token',
46-
statusCode: HttpStatus.UNAUTHORIZED,
47-
}));
48-
}
49-
50-
this.refreshPromise = null;
51-
this.cleanup(messageListener);
52-
};
53-
54-
const timer = setTimeout(() => {
55-
reject(new AuthenticationError({
56-
message: 'Failed to fetch access token',
25+
const parentOrigin = this.parentOrigin;
26+
if (!parentOrigin) {
27+
return Promise.reject(
28+
new AuthenticationError({
29+
message: 'Cannot refresh token: basedomain query parameter is missing',
5730
statusCode: HttpStatus.UNAUTHORIZED,
58-
}));
59-
60-
this.refreshPromise = null;
61-
this.cleanup(messageListener);
62-
}, AUTHENTICATION_TIMEOUT);
31+
})
32+
);
33+
}
6334

64-
window.addEventListener('message', messageListener);
35+
const { promise } = requestHostToken({
36+
pinnedOrigin: parentOrigin,
37+
sendRequest: () => this.sendMessageToParent(ActionCenterEventNames.REFRESHTOKEN, {
38+
clientId: this.config.clientId,
39+
scope: this.config.scope,
40+
}),
41+
responseEventType: ActionCenterEventNames.TOKENREFRESHED,
42+
extractToken: (data): HostTokenResponse | undefined => {
43+
const token = (data as ActionCenterEventResponsePayload)?.content?.token;
44+
if (!token?.accessToken) return undefined;
45+
return { accessToken: token.accessToken, expiresAt: token.expiresAt };
46+
},
47+
onTokenRefreshed: this.onTokenRefreshed,
6548
});
6649

67-
return this.refreshPromise;
68-
}
69-
70-
private isTokenExpired(tokenInfo: TokenInfo): boolean {
71-
if (!tokenInfo?.expiresAt) {
72-
return true;
50+
this.refreshPromise = promise;
51+
try {
52+
return await this.refreshPromise;
53+
} finally {
54+
this.refreshPromise = null;
7355
}
74-
75-
return new Date() >= tokenInfo.expiresAt;
7656
}
7757

7858
private sendMessageToParent(eventType: string, content?: unknown): void {
79-
if (window.parent && this.isValidOrigin(this.parentOrigin)) {
59+
if (window.parent && isValidHostOrigin(this.parentOrigin)) {
8060
try {
8161
window.parent.postMessage({ eventType, content }, this.parentOrigin!);
8262
} catch (error) {
83-
console.warn('Failed to send message to Action Center', JSON.stringify(error));
63+
console.warn('ActionCenterTokenManager: postMessage to host failed', JSON.stringify(error));
8464
}
8565
}
8666
}
87-
88-
private cleanup(messageListener: (event: MessageEvent<ActionCenterEventResponsePayload>) => void): void {
89-
window.removeEventListener('message', messageListener);
90-
}
91-
92-
private isValidOrigin(origin: string | null): boolean {
93-
const ALLOWED_ORIGINS = ['https://alpha.uipath.com', 'https://staging.uipath.com', 'https://cloud.uipath.com'];
94-
95-
if (!origin) {
96-
return false;
97-
}
98-
99-
if (ALLOWED_ORIGINS.includes(origin)) {
100-
return true;
101-
}
102-
103-
try {
104-
const url = new URL(origin);
105-
return url.hostname === 'localhost';
106-
} catch {
107-
return false;
108-
}
109-
}
11067
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { UipEmbeddedEventNames, UipEmbeddedRefreshTokenPayload, UipEmbeddedTokenRefreshedPayload } from './uip-embedded-protocol';
2+
import { TokenInfo } from './types';
3+
import { Config } from '../config/config';
4+
import { HostTokenResponse, isTokenExpired, requestHostToken } from './host-token-request';
5+
6+
function parseExpiresAt(raw: string): Date {
7+
const d = new Date(raw);
8+
// Malformed date → treat as already expired (safe default)
9+
return isNaN(d.getTime()) ? new Date(0) : d;
10+
}
11+
12+
function extractToken(data: unknown): HostTokenResponse | undefined {
13+
const token = (data as UipEmbeddedTokenRefreshedPayload)?.content?.token;
14+
if (!token?.accessToken) return undefined;
15+
return { accessToken: token.accessToken, expiresAt: parseExpiresAt(token.expiresAt) };
16+
}
17+
18+
/**
19+
* Handles token delegation for coded apps embedded inside a UiPath host
20+
* (e.g. Governance Portal, Insights UI).
21+
*
22+
* Detection: the parent frame's origin is identified from `document.referrer`
23+
* at startup and validated against the known UiPath host allowlist. This
24+
* mirrors exactly how Action Center embedding works — app-initiated, no
25+
* upfront token push from the host.
26+
*
27+
* On every token expiry the SDK sends `UIP.refreshToken` with `clientId` and
28+
* `scope`; the host performs silent SSO and responds with `UIP.tokenRefreshed`.
29+
*/
30+
export class EmbeddedTokenManager {
31+
private refreshPromise: Promise<string> | null = null;
32+
33+
/**
34+
* @param parentOrigin Validated UiPath host origin derived from `document.referrer`.
35+
* @param config SDK configuration — `clientId` and `scope` are forwarded in
36+
* every `UIP.refreshToken` request so the host knows which OAuth client to use.
37+
* @param onTokenRefreshed Called with the refreshed TokenInfo so the caller
38+
* can persist it in the execution context.
39+
*/
40+
constructor(
41+
private readonly parentOrigin: string,
42+
private readonly config: Config,
43+
private readonly onTokenRefreshed: (tokenInfo: TokenInfo) => void
44+
) {}
45+
46+
async refreshAccessToken(tokenInfo: TokenInfo): Promise<string> {
47+
if (!isTokenExpired(tokenInfo)) {
48+
return tokenInfo.token;
49+
}
50+
51+
if (this.refreshPromise) {
52+
return this.refreshPromise;
53+
}
54+
55+
const { promise } = requestHostToken({
56+
pinnedOrigin: this.parentOrigin,
57+
sendRequest: () => {
58+
try {
59+
const message: UipEmbeddedRefreshTokenPayload = {
60+
eventType: UipEmbeddedEventNames.REFRESH_TOKEN,
61+
content: { clientId: this.config.clientId, scope: this.config.scope },
62+
};
63+
window.parent.postMessage(message, this.parentOrigin);
64+
} catch (error) {
65+
console.warn('EmbeddedTokenManager: postMessage to host failed', error);
66+
}
67+
},
68+
responseEventType: UipEmbeddedEventNames.TOKEN_REFRESHED,
69+
extractToken,
70+
onTokenRefreshed: this.onTokenRefreshed,
71+
});
72+
73+
this.refreshPromise = promise;
74+
try {
75+
return await this.refreshPromise;
76+
} finally {
77+
this.refreshPromise = null;
78+
}
79+
}
80+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { TokenInfo } from './types';
2+
import { AuthenticationError, HttpStatus } from '../errors';
3+
4+
export const AUTHENTICATION_TIMEOUT = 8000;
5+
6+
const ALLOWED_HOST_ORIGINS = new Set([
7+
'https://alpha.uipath.com',
8+
'https://staging.uipath.com',
9+
'https://cloud.uipath.com',
10+
]);
11+
12+
/**
13+
* Returns true if the origin is a trusted UiPath host that may initiate
14+
* token delegation. Mirrors the same allowlist used by ActionCenterTokenManager.
15+
*/
16+
export function isValidHostOrigin(origin: string | null): boolean {
17+
if (!origin) return false;
18+
if (ALLOWED_HOST_ORIGINS.has(origin)) return true;
19+
try {
20+
return new URL(origin).hostname === 'localhost';
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
export function isTokenExpired(tokenInfo: TokenInfo): boolean {
27+
if (!tokenInfo.expiresAt) return true;
28+
return new Date() >= tokenInfo.expiresAt;
29+
}
30+
31+
export interface HostTokenResponse {
32+
accessToken: string;
33+
expiresAt: Date;
34+
}
35+
36+
export interface HostTokenRequestOptions {
37+
/** Origin the request is sent to and responses accepted from. */
38+
pinnedOrigin: string;
39+
/** Sends the refresh request to the parent frame. */
40+
sendRequest: () => void;
41+
/** Event type string the host sends back with the refreshed token. */
42+
responseEventType: string;
43+
/** Extracts the token from the host response message. Returns undefined if the payload is malformed. */
44+
extractToken: (data: unknown) => HostTokenResponse | undefined;
45+
/** Called with the refreshed TokenInfo before the promise resolves. */
46+
onTokenRefreshed: (tokenInfo: TokenInfo) => void;
47+
}
48+
49+
export interface HostTokenRequest {
50+
readonly promise: Promise<string>;
51+
/** Immediately rejects the promise and removes the response listener. */
52+
readonly cancel: () => void;
53+
}
54+
55+
/**
56+
* Waits for the next window message that satisfies `filter`.
57+
* Rejects if the AbortSignal fires before a matching message arrives.
58+
*/
59+
function waitForMessage(
60+
filter: (event: MessageEvent) => boolean,
61+
signal: AbortSignal
62+
): Promise<MessageEvent> {
63+
return new Promise((resolve, reject) => {
64+
const handler = (event: MessageEvent): void => {
65+
if (!filter(event)) return;
66+
window.removeEventListener('message', handler);
67+
resolve(event);
68+
};
69+
70+
signal.addEventListener('abort', () => {
71+
window.removeEventListener('message', handler);
72+
reject(signal.reason);
73+
}, { once: true });
74+
75+
window.addEventListener('message', handler);
76+
});
77+
}
78+
79+
/**
80+
* Sends a token-refresh request to a parent host frame and waits for the
81+
* response. Handles timeout, origin filtering, and listener cleanup.
82+
*
83+
* Both ActionCenterTokenManager and EmbeddedTokenManager delegate to this
84+
* function; they differ only in the event names and message shape they use.
85+
*/
86+
export function requestHostToken(options: HostTokenRequestOptions): HostTokenRequest {
87+
const { pinnedOrigin, sendRequest, responseEventType, extractToken, onTokenRefreshed } = options;
88+
89+
const controller = new AbortController();
90+
91+
const cancel = (): void =>
92+
controller.abort(
93+
new AuthenticationError({
94+
message: 'Token refresh cancelled',
95+
statusCode: HttpStatus.UNAUTHORIZED,
96+
})
97+
);
98+
99+
const promise = (async (): Promise<string> => {
100+
const timer = setTimeout(
101+
() =>
102+
controller.abort(
103+
new AuthenticationError({
104+
message: `Token refresh timed out after ${AUTHENTICATION_TIMEOUT}ms waiting for host response`,
105+
statusCode: HttpStatus.UNAUTHORIZED,
106+
})
107+
),
108+
AUTHENTICATION_TIMEOUT
109+
);
110+
111+
try {
112+
// Register listener before sending — avoids any race between send and response
113+
const responsePromise = waitForMessage(
114+
event => event.origin === pinnedOrigin && event.data?.eventType === responseEventType,
115+
controller.signal
116+
);
117+
118+
sendRequest();
119+
120+
const event = await responsePromise;
121+
122+
const token = extractToken(event.data);
123+
if (!token) {
124+
throw new AuthenticationError({
125+
message: 'Host responded but did not include a valid access token',
126+
statusCode: HttpStatus.UNAUTHORIZED,
127+
});
128+
}
129+
130+
onTokenRefreshed({ token: token.accessToken, type: 'secret', expiresAt: token.expiresAt });
131+
return token.accessToken;
132+
} finally {
133+
clearTimeout(timer);
134+
}
135+
})();
136+
137+
return { promise, cancel };
138+
}

0 commit comments

Comments
 (0)