Skip to content

Commit 0318dc3

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 0318dc3

15 files changed

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

0 commit comments

Comments
 (0)