-
Notifications
You must be signed in to change notification settings - Fork 10
feat(auth): generalised postMessage auth protocol for coded app embedding #458
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import { UipEmbeddedEventNames, UipEmbeddedRefreshTokenPayload, UipEmbeddedTokenRefreshedPayload } from './uip-embedded-protocol'; | ||
| import { TokenInfo } from './types'; | ||
| import { Config } from '../config/config'; | ||
| import { HostTokenResponse, isTokenExpired, requestHostToken } from './host-token-request'; | ||
|
|
||
| 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; | ||
| } | ||
|
|
||
| function extractToken(data: unknown): HostTokenResponse | undefined { | ||
| const token = (data as UipEmbeddedTokenRefreshedPayload)?.content?.token; | ||
| if (!token?.accessToken) return undefined; | ||
| return { accessToken: token.accessToken, expiresAt: parseExpiresAt(token.expiresAt) }; | ||
| } | ||
|
|
||
| /** | ||
| * Handles token delegation for coded apps embedded inside a UiPath host | ||
| * (e.g. Governance Portal, Insights UI). | ||
| * | ||
| * Detection: the parent frame's origin is identified from `document.referrer` | ||
| * at startup and validated against the known UiPath host allowlist. This | ||
| * mirrors exactly how Action Center embedding works — app-initiated, no | ||
| * upfront token push from the host. | ||
| * | ||
| * On every token expiry the SDK sends `UIP.refreshToken` with `clientId` and | ||
| * `scope`; the host performs silent SSO and responds with `UIP.tokenRefreshed`. | ||
| */ | ||
| export class EmbeddedTokenManager { | ||
| private refreshPromise: Promise<string> | null = null; | ||
|
|
||
| /** | ||
| * @param parentOrigin Validated UiPath host origin derived from `document.referrer`. | ||
| * @param config SDK configuration — `clientId` and `scope` are forwarded in | ||
| * every `UIP.refreshToken` request so the host knows which OAuth client to use. | ||
| * @param onTokenRefreshed Called with the refreshed TokenInfo so the caller | ||
| * can persist it in the execution context. | ||
| */ | ||
| constructor( | ||
| private readonly parentOrigin: string, | ||
| private readonly config: Config, | ||
| private readonly onTokenRefreshed: (tokenInfo: TokenInfo) => void | ||
| ) {} | ||
|
|
||
| async refreshAccessToken(tokenInfo: TokenInfo): Promise<string> { | ||
| if (!isTokenExpired(tokenInfo)) { | ||
| return tokenInfo.token; | ||
| } | ||
|
|
||
| if (this.refreshPromise) { | ||
| return this.refreshPromise; | ||
| } | ||
|
|
||
| const { promise } = requestHostToken({ | ||
| pinnedOrigin: this.parentOrigin, | ||
| sendRequest: () => { | ||
| try { | ||
| const message: UipEmbeddedRefreshTokenPayload = { | ||
| eventType: UipEmbeddedEventNames.REFRESH_TOKEN, | ||
| content: { clientId: this.config.clientId, scope: this.config.scope }, | ||
|
Check failure on line 61 in src/core/auth/embedded-token-manager.ts
|
||
| }; | ||
| window.parent.postMessage(message, this.parentOrigin); | ||
| } catch (error) { | ||
| console.warn('EmbeddedTokenManager: postMessage to host failed', error); | ||
| } | ||
| }, | ||
| responseEventType: UipEmbeddedEventNames.TOKEN_REFRESHED, | ||
| extractToken, | ||
| onTokenRefreshed: this.onTokenRefreshed, | ||
| }); | ||
|
|
||
| this.refreshPromise = promise; | ||
| try { | ||
| return await this.refreshPromise; | ||
| } finally { | ||
| this.refreshPromise = null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import { TokenInfo } from './types'; | ||
| import { AuthenticationError, HttpStatus } from '../errors'; | ||
|
|
||
| export const AUTHENTICATION_TIMEOUT = 8000; | ||
|
|
||
| const ALLOWED_HOST_ORIGINS = new Set([ | ||
| 'https://alpha.uipath.com', | ||
| 'https://staging.uipath.com', | ||
| 'https://cloud.uipath.com', | ||
| ]); | ||
|
|
||
| /** | ||
| * Returns true if the origin is a trusted UiPath host that may initiate | ||
| * token delegation. Mirrors the same allowlist used by ActionCenterTokenManager. | ||
| */ | ||
| export function isValidHostOrigin(origin: string | null): boolean { | ||
| if (!origin) return false; | ||
| if (ALLOWED_HOST_ORIGINS.has(origin)) return true; | ||
| try { | ||
| return new URL(origin).hostname === 'localhost'; | ||
| } catch { | ||
| return false; | ||
|
Comment on lines
+21
to
+22
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can we have error here? |
||
| } | ||
| } | ||
|
|
||
| export function isTokenExpired(tokenInfo: TokenInfo): boolean { | ||
| if (!tokenInfo.expiresAt) return true; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| return new Date() >= tokenInfo.expiresAt; | ||
| } | ||
|
|
||
| export interface HostTokenResponse { | ||
| accessToken: string; | ||
| expiresAt: Date; | ||
| } | ||
|
|
||
| export interface HostTokenRequestOptions { | ||
| /** Origin the request is sent to and responses accepted from. */ | ||
| pinnedOrigin: string; | ||
| /** Sends the refresh request to the parent frame. */ | ||
| sendRequest: () => void; | ||
| /** Event type string the host sends back with the refreshed token. */ | ||
| responseEventType: string; | ||
| /** Extracts the token from the host response message. Returns undefined if the payload is malformed. */ | ||
| extractToken: (data: unknown) => HostTokenResponse | undefined; | ||
| /** Called with the refreshed TokenInfo before the promise resolves. */ | ||
| onTokenRefreshed: (tokenInfo: TokenInfo) => void; | ||
| } | ||
|
|
||
| export interface HostTokenRequest { | ||
| readonly promise: Promise<string>; | ||
| /** Immediately rejects the promise and removes the response listener. */ | ||
| readonly cancel: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Waits for the next window message that satisfies `filter`. | ||
| * Rejects if the AbortSignal fires before a matching message arrives. | ||
| */ | ||
| function waitForMessage( | ||
| filter: (event: MessageEvent) => boolean, | ||
| signal: AbortSignal | ||
| ): Promise<MessageEvent> { | ||
| return new Promise((resolve, reject) => { | ||
| const handler = (event: MessageEvent): void => { | ||
| if (!filter(event)) return; | ||
| window.removeEventListener('message', handler); | ||
| resolve(event); | ||
| }; | ||
|
|
||
| signal.addEventListener('abort', () => { | ||
| window.removeEventListener('message', handler); | ||
| reject(signal.reason); | ||
| }, { once: true }); | ||
|
|
||
| window.addEventListener('message', handler); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Sends a token-refresh request to a parent host frame and waits for the | ||
| * response. Handles timeout, origin filtering, and listener cleanup. | ||
| * | ||
| * Both ActionCenterTokenManager and EmbeddedTokenManager delegate to this | ||
| * function; they differ only in the event names and message shape they use. | ||
| */ | ||
| export function requestHostToken(options: HostTokenRequestOptions): HostTokenRequest { | ||
| const { pinnedOrigin, sendRequest, responseEventType, extractToken, onTokenRefreshed } = options; | ||
|
|
||
| const controller = new AbortController(); | ||
|
|
||
| const cancel = (): void => | ||
| controller.abort( | ||
| new AuthenticationError({ | ||
| message: 'Token refresh cancelled', | ||
| statusCode: HttpStatus.UNAUTHORIZED, | ||
| }) | ||
| ); | ||
|
|
||
| const promise = (async (): Promise<string> => { | ||
| const timer = setTimeout( | ||
| () => | ||
| controller.abort( | ||
| new AuthenticationError({ | ||
| message: `Token refresh timed out after ${AUTHENTICATION_TIMEOUT}ms waiting for host response`, | ||
| statusCode: HttpStatus.UNAUTHORIZED, | ||
| }) | ||
| ), | ||
| AUTHENTICATION_TIMEOUT | ||
| ); | ||
|
|
||
| try { | ||
| // Register listener before sending — avoids any race between send and response | ||
| const responsePromise = waitForMessage( | ||
| event => event.origin === pinnedOrigin && event.data?.eventType === responseEventType, | ||
| controller.signal | ||
| ); | ||
|
|
||
| sendRequest(); | ||
|
|
||
| const event = await responsePromise; | ||
|
|
||
| const token = extractToken(event.data); | ||
| if (!token) { | ||
| throw new AuthenticationError({ | ||
| message: 'Host responded but did not include a valid access token', | ||
| statusCode: HttpStatus.UNAUTHORIZED, | ||
| }); | ||
| } | ||
|
|
||
| onTokenRefreshed({ token: token.accessToken, type: 'secret', expiresAt: token.expiresAt }); | ||
| return token.accessToken; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| })(); | ||
|
|
||
| return { promise, cancel }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please address this