Skip to content

feat(auth): generalised postMessage auth protocol for coded app embedding#458

Open
NishankSiddharth wants to merge 1 commit into
mainfrom
feat/generic-coded-app-auth-embedding
Open

feat(auth): generalised postMessage auth protocol for coded app embedding#458
NishankSiddharth wants to merge 1 commit into
mainfrom
feat/generic-coded-app-auth-embedding

Conversation

@NishankSiddharth
Copy link
Copy Markdown
Contributor

@NishankSiddharth NishankSiddharth commented May 21, 2026

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: ActionCenterTokenManager was hardwired to Action Center via a URL string check (source=ActionCenter), hardcoded AC.* 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 EmbeddedTokenManager activated by a uipath:platform-hosted meta tag injected by the Apps service at deployment time. The host sends UIP.init with the user's token after the iframe loads; the SDK handles all subsequent refresh cycles transparently.


Protocol

Direction Event Payload
Host → App (on iframe load) UIP.init Initial access token + expiry
App → Host (on token expiry) UIP.refreshToken (none)
Host → App UIP.tokenRefreshed Refreshed access token + expiry

Origin validation uses an explicit allowlist (alpha, staging, cloud.uipath.com + localhost) — identical to the existing Action Center policy.


Files changed

New files

File Purpose
src/core/auth/uip-embedded-protocol.ts UipEmbeddedEventNames enum and payload types (auth domain, not AC models)
src/core/auth/embedded-token-manager.ts Passive listener activated by UIP.init; handles token refresh lifecycle
src/core/auth/host-token-request.ts Shared requestHostToken() + isValidHostOrigin() + isTokenExpired() used by both managers

Modified files

File Change
src/core/auth/action-center-token-manager.ts Delegates to requestHostToken() and isValidHostOrigin(); removes duplicate isValidOrigin private method
src/core/auth/token-manager.ts Instantiates EmbeddedTokenManager when isBrowser && config.platformHosted === true; adds destroy() to release listener
src/core/config/sdk-config.ts platformHosted?: boolean added to BaseConfig
src/core/config/config.ts platformHosted added to ConfigSchema and UiPathConfig
src/core/config/runtime.ts Reads uipath:platform-hosted meta tag via loadFromMetaTags()
src/utils/runtime/constants.ts PLATFORM_HOSTED = 'uipath:platform-hosted' added to UiPathMetaTags

Tests (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.window isolation
  • token-manager-embedded.test.tsEmbeddedTokenManager instantiation conditions, getValidToken routing, destroy() delegation
  • runtime.test.tsplatformHosted meta tag parsing ('true' activates, 'false'/absent/garbage do not), global.document isolation

Security

  • Origin validated against explicit allowlist before pinning (alpha, staging, cloud.uipath.com, localhost) — same policy as Action Center, shared via isValidHostOrigin()
  • Origin pinned on first valid UIP.init; all subsequent messages accepted only from that origin
  • Outbound UIP.refreshToken always uses explicit targetOrigin, never '*'
  • EmbeddedTokenManager and ActionCenterTokenManager are mutually exclusive — AC behaviour unchanged

Action Center

No behaviour change. ActionCenterTokenManager still uses AC.* events and ?basedomain= query param. It now delegates shared logic to host-token-request.ts but its external contract is identical.

Co-authored-by: Claude Opus 4.7 (1M context) noreply@anthropic.com

@NishankSiddharth NishankSiddharth requested a review from a team May 21, 2026 12:06
Comment thread src/core/auth/token-manager.ts Outdated
Comment thread tests/unit/core/config/runtime.test.ts
Comment thread src/models/action-center/tasks.internal-types.ts Outdated
Comment thread src/models/action-center/tasks.internal-types.ts Outdated
Comment thread src/core/auth/embedded-token-manager.ts Outdated
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 21, 2026

Review findings

Five issues found across the new auth files:

Bugs

  1. Listener leak — token-manager.ts:38EmbeddedTokenManager adds a window.message listener in its constructor and has a destroy() to clean it up, but TokenManager never calls destroy(). The listener (and both manager instances) will be held alive indefinitely. TokenManager needs a destroy() that delegates to embeddedTokenManager?.destroy().

  2. Test isolation leak — runtime.test.ts:27global.document is replaced in beforeEach but never restored. Missing afterEach(() => { delete (global as any).document; }).

Convention violations

  1. Wrong domain for auth types — tasks.internal-types.ts:34UipEmbeddedEventNames and payload types are auth-protocol types placed in the Action Center models folder. They should live in src/core/auth/.

  2. Unused type — tasks.internal-types.ts:60UipEmbeddedRefreshTokenPayload is defined but never imported anywhere. Remove it or document intent with a TODO + tracking issue.

  3. Missing constructor JSDoc — embedded-token-manager.ts:52 — Constructor takes a dependency parameter (onTokenRefreshed) but has no JSDoc. Convention requires @param documentation when a constructor takes dependencies.

Comment thread tests/unit/core/auth/embedded-token-manager.test.ts
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 22, 2026

Review findings

All 5 issues from the previous review have been fixed (destroyed listener leak, global.document cleanup, auth types moved to src/core/auth/uip-embedded-protocol.ts, unused type removed, constructor JSDoc added). One new issue found this pass:

  • Test isolation leak — embedded-token-manager.test.ts:83global.window is replaced in beforeEach but not removed in afterEach, leaking the mock to subsequent test files in the same Vitest worker. Identical pattern to the global.document issue that was already fixed in runtime.test.ts.

@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch from 74beea5 to d3d2b22 Compare May 22, 2026 09:16
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 22, 2026

✅ No issues found. Checked for bugs and CLAUDE.md compliance.

@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch 2 times, most recently from 1e9d9fb to 00cb1f0 Compare May 22, 2026 09:58
Comment thread tests/unit/core/auth/embedded-token-manager.test.ts Outdated
Comment thread tests/unit/core/auth/embedded-token-manager.test.ts
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 22, 2026

Review findings

Two new issues in the test file:

  1. Hardcoded timeout magic number — embedded-token-manager.test.ts:230vi.advanceTimersByTime(8001) hardcodes the value instead of using the exported AUTHENTICATION_TIMEOUT + 1. If the constant changes, the test silently tests the wrong duration.

  2. Missing error-scenario test — embedded-token-manager.test.ts:221 — The branch in host-token-request.ts:122 where extractToken returns undefined (host replies with correct origin and event type, but with a missing/empty accessToken) is not covered. This is an error path in the auth critical-path and requires 100% coverage per conventions.

@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch from 00cb1f0 to e81f2d9 Compare May 22, 2026 10:03
Comment thread src/core/uipath.ts Outdated
clientId: hasOAuthAuth ? config.clientId : undefined,
redirectUri: hasOAuthAuth ? config.redirectUri : undefined,
scope: hasOAuthAuth ? config.scope : undefined,
platformHosted: config.platformHosted,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 22, 2026

Review findings

One new issue this pass:

  • UiPath does not expose destroy() — uipath.ts:90 — TokenManager.destroy() (delegating to EmbeddedTokenManager.destroy()) was added this PR but the call chain is incomplete: AuthService has no destroy(), and neither does UiPath. The window listener registered by EmbeddedTokenManager when platformHosted === true is never removed, leaking the listener and pinning the instance for the page lifetime. The prior review thread on token-manager.ts explicitly said 'callers that own the TokenManager lifetime should call destroy() when done' — UiPath is that caller. See inline comment: feat(auth): generalised postMessage auth protocol for coded app embedding #458 (comment)

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 22, 2026

✅ No issues found. Checked for bugs and CLAUDE.md compliance.

@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch from e81f2d9 to bf76735 Compare May 26, 2026 14:47
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 26, 2026

Review findings

One thread unresolved this pass:

  • destroy() not wired to UiPath — uipath.ts:90TokenManager.destroy() was added and delegates correctly to EmbeddedTokenManager.destroy(), but the chain is never exposed to callers. UiPath has no destroy() method, and IUiPath has no matching declaration. The EmbeddedTokenManager window listener (registered when platformHosted === true) is therefore leaked for the lifetime of the page. The prior thread explicitly noted "callers that own the TokenManager lifetime should call destroy() when done"UiPath is that caller.

@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch from bf76735 to 0318dc3 Compare May 26, 2026 15:02
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 26, 2026

✅ No issues found. Checked for bugs and CLAUDE.md compliance.

@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch from 0318dc3 to 733dd14 Compare May 26, 2026 16:37
clientId: string,
scope: string,
},
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

Suggested change
};
};
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.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 26, 2026

Review findings

One new issue this pass:

  • Unused exported type — uip-embedded-protocol.ts:44UipEmbeddedRefreshTokenPayload is exported but never imported anywhere in the SDK. The same issue was previously flagged and resolved in tasks.internal-types.ts. The type should either be used as a typed annotation for the outbound window.parent.postMessage call in embedded-token-manager.ts (which also adds type safety), or explicitly documented as a host-application contract with a comment explaining the intent.

@sonarqubecloud
Copy link
Copy Markdown

Comment thread src/core/auth/token-manager.ts Outdated
Comment on lines +37 to +38
} else if (isBrowser && config.platformHosted === true) {
this.embeddedTokenManager = new EmbeddedTokenManager(config, tokenInfo => this.setToken(tokenInfo));
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.

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;
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)

Comment thread src/core/auth/embedded-token-manager.ts Outdated
Comment on lines +118 to +125
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;
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.

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.

Comment thread src/core/auth/embedded-token-manager.ts Outdated
Comment on lines +24 to +27
* 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.
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.

What if UIP.init arrives before the listener is registered? like host sends the event before apps js loaded?

Copy link
Copy Markdown
Collaborator

@Raina451 Raina451 May 27, 2026

Choose a reason for hiding this comment

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

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

cc: @vnaren23 @Sandeepan-Ghosh-0312

Comment thread src/core/auth/embedded-token-manager.ts Outdated
Comment on lines +127 to +130
this.onTokenRefreshed({
token: tokenData.accessToken,
type: 'secret',
expiresAt: parseExpiresAt(tokenData.expiresAt),
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.

why type secret?
SDK treats 'secret' as "don't run OAuth refresh internally"

Comment on lines +21 to +22
} catch {
return false;
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?

Comment thread src/core/config/config.ts Outdated
redirectUri: z.string().url().optional(),
scope: z.string().optional()
scope: z.string().optional(),
platformHosted: z.boolean().optional(),
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.

why new field is needed in sdk config?

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.

  • 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

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.

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;
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

Comment thread src/core/auth/embedded-token-manager.ts Outdated
}

/**
* Handles token delegation for coded apps embedded inside a first-party
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.

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>
@NishankSiddharth NishankSiddharth force-pushed the feat/generic-coded-app-auth-embedding branch from 733dd14 to 2c004f1 Compare May 27, 2026 12:21
* Must be called when the TokenManager is no longer needed to prevent listener leaks.
*/
destroy(): void {
this.embeddedTokenManager?.destroy();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. Store the cancel handle in a field:
private cancelRefresh: (() => void) | null = null;
  1. 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;
}
  1. Add destroy() to EmbeddedTokenManager:
destroy(): void {
  this.cancelRefresh?.();
}

Without destroy() on EmbeddedTokenManager, TokenManager.destroy() will not compile under strict TypeScript.

Comment thread src/core/uipath.ts

/**
* Releases resources held by this SDK instance.
* Removes the window message listener registered by EmbeddedTokenManager when
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This JSDoc sentence is truncated — it ends mid-clause with "when" and no predicate.

Suggested change
* 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.

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 27, 2026

Review findings

Two new issues this pass:

  1. TypeScript error — token-manager.ts:247TokenManager.destroy() calls this.embeddedTokenManager?.destroy() but EmbeddedTokenManager has no destroy() method. TypeScript strict mode catches ?. accesses on non-existent members, so this fails compilation. The fix: store the cancel handle from requestHostToken() (currently discarded) and add destroy(): void { this.cancelRefresh?.(); } to EmbeddedTokenManager.

  2. Truncated JSDoc — uipath.ts:266 — The second sentence of destroy() JSDoc ends mid-clause: "Removes the window message listener registered by EmbeddedTokenManager when" — the predicate is missing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants