feat(auth): generalised postMessage auth protocol for coded app embedding#458
feat(auth): generalised postMessage auth protocol for coded app embedding#458NishankSiddharth wants to merge 1 commit into
Conversation
Review findingsFive issues found across the new auth files: Bugs
Convention violations
|
Review findingsAll 5 issues from the previous review have been fixed (destroyed listener leak,
|
74beea5 to
d3d2b22
Compare
|
✅ No issues found. Checked for bugs and CLAUDE.md compliance. |
1e9d9fb to
00cb1f0
Compare
Review findingsTwo new issues in the test file:
|
00cb1f0 to
e81f2d9
Compare
| clientId: hasOAuthAuth ? config.clientId : undefined, | ||
| redirectUri: hasOAuthAuth ? config.redirectUri : undefined, | ||
| scope: hasOAuthAuth ? config.scope : undefined, | ||
| platformHosted: config.platformHosted, |
There was a problem hiding this comment.
TokenManager.destroy() (which delegates to EmbeddedTokenManager.destroy()) was added in this PR, but the call chain is broken: nothing calls it. AuthService has no destroy(), and neither does UiPath. The EmbeddedTokenManager window listener created when platformHosted === true is therefore never removed, and the EmbeddedTokenManager instance is pinned in memory for the lifetime of the page (the listener holds a reference through the bound handleInit callback).
The previous review thread at token-manager.ts:38 explicitly noted: "Callers that own the TokenManager lifetime should call destroy() when done." The only caller that owns that lifetime is UiPath.
Suggested fix — add destroy() to UiPath (and wire up the chain through AuthService):
/**
* Releases resources held by this instance (e.g. window message listeners).
* Call this when the UiPath instance is no longer needed.
*/
public destroy(): void {
this.#authService?.getTokenManager().destroy();
}IUiPath should also gain this method so any code that depends on the interface can call it.
Review findingsOne new issue this pass:
|
|
✅ No issues found. Checked for bugs and CLAUDE.md compliance. |
e81f2d9 to
bf76735
Compare
Review findingsOne thread unresolved this pass:
|
bf76735 to
0318dc3
Compare
|
✅ No issues found. Checked for bugs and CLAUDE.md compliance. |
0318dc3 to
733dd14
Compare
| clientId: string, | ||
| scope: string, | ||
| }, | ||
| }; |
There was a problem hiding this comment.
UipEmbeddedRefreshTokenPayload is exported but never imported anywhere in the SDK — embedded-token-manager.ts constructs the outbound message inline as a plain object literal instead of using this type.
This is the same issue that was previously flagged and resolved in tasks.internal-types.ts. Convention says: "NEVER leave unused code."
The cleanest fix is to actually use the type in embedded-token-manager.ts, which also adds type safety for the outbound message:
| }; | |
| }; | |
| export type UipEmbeddedRefreshTokenPayload = { | |
| eventType: UipEmbeddedEventNames.REFRESH_TOKEN, | |
| content: { | |
| clientId: string, | |
| scope: string, | |
| }, | |
| }; |
Then in embedded-token-manager.ts, replace the inline object literal:
const message: UipEmbeddedRefreshTokenPayload = {
eventType: UipEmbeddedEventNames.REFRESH_TOKEN,
content: { clientId: this.config.clientId, scope: this.config.scope },
};
window.parent.postMessage(message, pinnedOrigin);If the intent is instead to export it purely for host-application consumers (so they can type-check the messages they receive), document that explicitly with a comment — e.g. // Exported for host applications to type-check incoming refresh requests — and add a @see JSDoc link. Without that signal, this reads as dead code.
Review findingsOne new issue this pass:
|
|
| } else if (isBrowser && config.platformHosted === true) { | ||
| this.embeddedTokenManager = new EmbeddedTokenManager(config, tokenInfo => this.setToken(tokenInfo)); |
There was a problem hiding this comment.
When platformHosted=true, EmbeddedTokenManager is created but isOAuth is not set to false.
The AC branch does this.isOAuth = false. Without this, the OAuth refresh path in getValidToken could race with the embedded path if both are reachable. can you check once?
| } | ||
|
|
||
| export function isTokenExpired(tokenInfo: TokenInfo): boolean { | ||
| if (!tokenInfo.expiresAt) return true; |
There was a problem hiding this comment.
* if (!tokenInfo?.expiresAt)
| if (event.data?.eventType !== UipEmbeddedEventNames.INIT) return; | ||
| if (this.pinnedOrigin !== null) return; // already activated — ignore subsequent inits | ||
| if (!isValidHostOrigin(event.origin)) return; | ||
|
|
||
| const tokenData = event.data?.content?.token; | ||
| if (!tokenData?.accessToken) return; | ||
|
|
||
| this.pinnedOrigin = event.origin; |
There was a problem hiding this comment.
Once origin is pinned, this listener only exits early on the second line. Could remove it here to avoid running on every postMessage for the app's lifetime.
| * 1. Constructed passively — registers a window message listener. | ||
| * 2. Activated — when the host sends UIP.init with a valid UiPath origin. | ||
| * 3. On token expiry — sends UIP.refreshToken to the host and awaits UIP.tokenRefreshed. | ||
| * 4. Destroyed — removes all listeners and cancels any in-flight refresh. |
There was a problem hiding this comment.
What if UIP.init arrives before the listener is registered? like host sends the event before apps js loaded?
There was a problem hiding this comment.
The current flow is: host sends UIP.init with a token → app receives it. But for non-AC
hosts (Governance, Insights), the host doesn't need to send data upfront like AC does (AC sends task data in its init event). we just need a way for app to know and send the event to host with client id and scope and fps will do auth and sends back the token.
Instead, for platform hosted apps, the app(sdk) can detect if it's embedded via a query param in the iframe URL (e.g. ?host=governance). The SDK reads this, and sends the first event to the host with clientId and scope. The host receives this, performs silent SSO using that clientId, and responds with the scoped token. this way first unnecessary handshake doesnt happen
| this.onTokenRefreshed({ | ||
| token: tokenData.accessToken, | ||
| type: 'secret', | ||
| expiresAt: parseExpiresAt(tokenData.expiresAt), |
There was a problem hiding this comment.
why type secret?
SDK treats 'secret' as "don't run OAuth refresh internally"
| } catch { | ||
| return false; |
| redirectUri: z.string().url().optional(), | ||
| scope: z.string().optional() | ||
| scope: z.string().optional(), | ||
| platformHosted: z.boolean().optional(), |
There was a problem hiding this comment.
why new field is needed in sdk config?
There was a problem hiding this comment.
- The SDK already has clientId and scope from existing meta tags
- UIP.init from a valid origin is the signal that the app is embedded?
- isActive() already gates the behavior ,if no UIP.init arrives, the embedded path is never taken
There was a problem hiding this comment.
we shouldnt need meta tag also uipath:platform-hosted
| 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; |
| } | ||
|
|
||
| /** | ||
| * Handles token delegation for coded apps embedded inside a first-party |
There was a problem hiding this comment.
first-party is internal naming. Just mention embedded inside a other UiPath hosts eg Governance
…ding
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>
733dd14 to
2c004f1
Compare
| * Must be called when the TokenManager is no longer needed to prevent listener leaks. | ||
| */ | ||
| destroy(): void { | ||
| this.embeddedTokenManager?.destroy(); |
There was a problem hiding this comment.
EmbeddedTokenManager has no destroy() method, so this line is a TypeScript compilation error: Property 'destroy' does not exist on type 'EmbeddedTokenManager'. TypeScript's ?. optional chaining still type-checks the accessed member before the null-guard.
The new EmbeddedTokenManager registers a listener per refreshAccessToken() call (via requestHostToken()) and removes it on completion — there is no persistent constructor listener. However destroy() is still needed to cancel any in-flight refresh when the app unmounts mid-cycle.
requestHostToken() already returns { promise, cancel } but EmbeddedTokenManager.refreshAccessToken() only destructures promise and discards cancel. The fix is:
- Store the cancel handle in a field:
private cancelRefresh: (() => void) | null = null;- Use both return values and clear in
finally:
const { promise, cancel } = requestHostToken({ ... });
this.cancelRefresh = cancel;
this.refreshPromise = promise;
try {
return await this.refreshPromise;
} finally {
this.refreshPromise = null;
this.cancelRefresh = null;
}- Add
destroy()toEmbeddedTokenManager:
destroy(): void {
this.cancelRefresh?.();
}Without destroy() on EmbeddedTokenManager, TokenManager.destroy() will not compile under strict TypeScript.
|
|
||
| /** | ||
| * Releases resources held by this SDK instance. | ||
| * Removes the window message listener registered by EmbeddedTokenManager when |
There was a problem hiding this comment.
This JSDoc sentence is truncated — it ends mid-clause with "when" and no predicate.
| * Removes the window message listener registered by EmbeddedTokenManager when | |
| * Releases resources held by this SDK instance. | |
| * Removes the window message listener registered by EmbeddedTokenManager | |
| * when the app is running embedded inside a UiPath host. | |
| * Call this when the coded app is unmounted. |
Review findingsTwo new issues this pass:
|



Summary
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.Problem:
ActionCenterTokenManagerwas hardwired to Action Center via a URL string check (source=ActionCenter), hardcodedAC.*event names, and a?basedomain=query param. New first-party hosts were forced to impersonate Action Center to embed a coded app.Solution: A new
EmbeddedTokenManageractivated by auipath:platform-hostedmeta tag injected by the Apps service at deployment time. The host sendsUIP.initwith the user's token after the iframe loads; the SDK handles all subsequent refresh cycles transparently.Protocol
UIP.initUIP.refreshTokenUIP.tokenRefreshedOrigin validation uses an explicit allowlist (
alpha,staging,cloud.uipath.com+ localhost) — identical to the existing Action Center policy.Files changed
New files
src/core/auth/uip-embedded-protocol.tsUipEmbeddedEventNamesenum and payload types (auth domain, not AC models)src/core/auth/embedded-token-manager.tsUIP.init; handles token refresh lifecyclesrc/core/auth/host-token-request.tsrequestHostToken()+isValidHostOrigin()+isTokenExpired()used by both managersModified files
src/core/auth/action-center-token-manager.tsrequestHostToken()andisValidHostOrigin(); removes duplicateisValidOriginprivate methodsrc/core/auth/token-manager.tsEmbeddedTokenManagerwhenisBrowser && config.platformHosted === true; addsdestroy()to release listenersrc/core/config/sdk-config.tsplatformHosted?: booleanadded toBaseConfigsrc/core/config/config.tsplatformHostedadded toConfigSchemaandUiPathConfigsrc/core/config/runtime.tsuipath:platform-hostedmeta tag vialoadFromMetaTags()src/utils/runtime/constants.tsPLATFORM_HOSTED = 'uipath:platform-hosted'added toUiPathMetaTagsTests (31 new)
embedded-token-manager.test.ts— origin validation against explicit allowlist, unknown subdomain rejection, origin pinning, token delivery on init, refresh flow, 8s timeout, concurrent deduplication, destroy cancellation,global.windowisolationtoken-manager-embedded.test.ts—EmbeddedTokenManagerinstantiation conditions,getValidTokenrouting,destroy()delegationruntime.test.ts—platformHostedmeta tag parsing ('true'activates,'false'/absent/garbage do not),global.documentisolationSecurity
alpha,staging,cloud.uipath.com,localhost) — same policy as Action Center, shared viaisValidHostOrigin()UIP.init; all subsequent messages accepted only from that originUIP.refreshTokenalways uses explicittargetOrigin, never'*'EmbeddedTokenManagerandActionCenterTokenManagerare mutually exclusive — AC behaviour unchangedAction Center
No behaviour change.
ActionCenterTokenManagerstill usesAC.*events and?basedomain=query param. It now delegates shared logic tohost-token-request.tsbut its external contract is identical.Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com