Skip to content

Commit 00cb1f0

Browse files
feat(auth): generalised postMessage auth protocol for coded app embedding
Adds a generic UIP.* postMessage token-delegation protocol so any first-party UiPath host (Insights UI, Governance Portal, etc.) can embed a coded app and pass the user's active session without the existing requirement to fake ?source=ActionCenter. Changes: - Add UipEmbeddedEventNames enum and payload types in src/core/auth/uip-embedded-protocol.ts (auth domain, not AC models) - Add uipath:platform-hosted meta tag constant to UiPathMetaTags - Thread platformHosted flag through SDK config chain: BaseConfig → ConfigSchema → UiPathConfig → loadFromMetaTags (only 'true' activates) - Add EmbeddedTokenManager: passive window.message listener activated by UIP.init from a validated *.uipath.com/localhost origin; handles token refresh via UIP.refreshToken / UIP.tokenRefreshed - Add host-token-request.ts: shared requestHostToken() helper used by both EmbeddedTokenManager and ActionCenterTokenManager, built on AbortController for clean timeout and cancellation handling - Wire EmbeddedTokenManager into TokenManager via isBrowser && config.platformHosted === true — mutually exclusive with ActionCenterTokenManager; AC behaviour fully unchanged - Add TokenManager.destroy() to release the EmbeddedTokenManager window listener; fix global.window and global.document test isolation - 29 unit tests: origin validation, pinning, refresh flow, 8s timeout, concurrent deduplication, destroy cancellation, meta tag parsing, TokenManager routing and destroy delegation Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2d7fbc4 commit 00cb1f0

13 files changed

Lines changed: 856 additions & 74 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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { UipEmbeddedEventNames, UipEmbeddedInitPayload, UipEmbeddedTokenRefreshedPayload } from './uip-embedded-protocol';
2+
import { TokenInfo } from './types';
3+
import { AuthenticationError, HttpStatus } from '../errors';
4+
import { HostTokenResponse, isTokenExpired, isValidHostOrigin, 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 first-party
20+
* UiPath host (e.g. Governance Portal, Insights UI).
21+
*
22+
* Lifecycle:
23+
* 1. Constructed passively — registers a window message listener.
24+
* 2. Activated — when the host sends UIP.init with a valid UiPath origin.
25+
* 3. On token expiry — sends UIP.refreshToken to the host and awaits UIP.tokenRefreshed.
26+
* 4. Destroyed — removes all listeners and cancels any in-flight refresh.
27+
*/
28+
export class EmbeddedTokenManager {
29+
/**
30+
* Null until a valid UIP.init is received.
31+
* Once set it is the host origin we accept all subsequent messages from.
32+
*/
33+
private pinnedOrigin: string | null = null;
34+
35+
private refreshPromise: Promise<string> | null = null;
36+
private cancelRefresh: (() => void) | null = null;
37+
private readonly initListener: (event: MessageEvent) => void;
38+
39+
/**
40+
* @param onTokenRefreshed Called whenever a token is received or refreshed,
41+
* allowing the caller to persist the new token in the execution context.
42+
*/
43+
constructor(private readonly onTokenRefreshed: (tokenInfo: TokenInfo) => void) {
44+
this.initListener = this.handleInit.bind(this);
45+
window.addEventListener('message', this.initListener);
46+
}
47+
48+
isActive(): boolean {
49+
return this.pinnedOrigin !== null;
50+
}
51+
52+
async refreshAccessToken(tokenInfo: TokenInfo): Promise<string> {
53+
if (!isTokenExpired(tokenInfo)) {
54+
return tokenInfo.token;
55+
}
56+
57+
if (this.refreshPromise) {
58+
return this.refreshPromise;
59+
}
60+
61+
this.refreshPromise = this.requestRefresh();
62+
try {
63+
return await this.refreshPromise;
64+
} finally {
65+
this.refreshPromise = null;
66+
this.cancelRefresh = null;
67+
}
68+
}
69+
70+
destroy(): void {
71+
window.removeEventListener('message', this.initListener);
72+
this.cancelRefresh?.();
73+
}
74+
75+
private requestRefresh(): Promise<string> {
76+
const pinnedOrigin = this.pinnedOrigin;
77+
if (!pinnedOrigin) {
78+
return Promise.reject(
79+
new AuthenticationError({
80+
message: 'Cannot refresh token: embedded manager has not received a UIP.init from the host',
81+
statusCode: HttpStatus.UNAUTHORIZED,
82+
})
83+
);
84+
}
85+
86+
const { promise, cancel } = requestHostToken({
87+
pinnedOrigin,
88+
sendRequest: () => {
89+
try {
90+
window.parent.postMessage(
91+
{ eventType: UipEmbeddedEventNames.REFRESH_TOKEN },
92+
pinnedOrigin
93+
);
94+
} catch (error) {
95+
console.warn('EmbeddedTokenManager: postMessage to host failed', error);
96+
}
97+
},
98+
responseEventType: UipEmbeddedEventNames.TOKEN_REFRESHED,
99+
extractToken,
100+
onTokenRefreshed: this.onTokenRefreshed,
101+
});
102+
103+
this.cancelRefresh = cancel;
104+
return promise;
105+
}
106+
107+
private handleInit(event: MessageEvent<UipEmbeddedInitPayload>): void {
108+
if (event.data?.eventType !== UipEmbeddedEventNames.INIT) return;
109+
if (this.pinnedOrigin !== null) return; // already activated — ignore subsequent inits
110+
if (!isValidHostOrigin(event.origin)) return;
111+
112+
const tokenData = event.data?.content?.token;
113+
if (!tokenData?.accessToken) return;
114+
115+
this.pinnedOrigin = event.origin;
116+
117+
this.onTokenRefreshed({
118+
token: tokenData.accessToken,
119+
type: 'secret',
120+
expiresAt: parseExpiresAt(tokenData.expiresAt),
121+
});
122+
}
123+
}
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)