@microsoft/rayfin-auth-provider-fabric 1.36.0-alpha.1601 → 1.36.0-alpha.1663

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,22 @@ npm create @microsoft/rayfin@latest
8
8
 
9
9
  For more details, [visit our docs](https://aka.ms/rayfin/docs).
10
10
 
11
+ ## Direct Entra token sign-in
12
+
13
+ Already have a delegated Entra access token?
14
+ Use `signInWithEntraToken` to establish a Rayfin session on an existing `Auth` instance without a popup or iframe.
15
+ The helper supports browsers and Node.js.
16
+
17
+ ```typescript
18
+ import { signInWithEntraToken } from '@microsoft/rayfin-auth-provider-fabric';
19
+
20
+ const session = await signInWithEntraToken(client.auth, { entraToken });
21
+ ```
22
+
23
+ Configure the client with the target AppBackend's workload API base URL.
24
+ The helper derives the direct token-exchange endpoint and makes the resulting Rayfin session available to attached SDK clients.
25
+ See [direct sign-in setup and usage](./assets/docs/index.md#direct-entra-token-sign-in) for token requirements, Node.js usage, and session handling.
26
+
11
27
  ## Security
12
28
 
13
29
  Microsoft takes the security of our software products and services seriously, which
@@ -1,10 +1,11 @@
1
1
  # Fabric auth provider
2
2
 
3
- Fabric brokered authentication helpers for Rayfin browser applications.
3
+ Fabric brokered authentication helpers for Rayfin applications.
4
4
 
5
- Use `@microsoft/rayfin-auth-provider-fabric` when your application needs to authenticate through a Fabric-hosted broker experience instead of calling the standard Rayfin auth flows directly.
5
+ Use `@microsoft/rayfin-auth-provider-fabric` to sign in through a Fabric-hosted browser experience or exchange an existing delegated Entra token directly for a Rayfin session.
6
6
 
7
- The package is designed for browser applications that already use `@microsoft/rayfin-auth` and need to reuse an existing session, refresh an expired session, or open the Fabric broker when no session can be restored silently.
7
+ Popup and iframe helpers require a browser.
8
+ `signInWithEntraToken()` supports browsers and Node.js and installs the exchanged session on your existing `Auth` instance.
8
9
 
9
10
  ## Installation
10
11
 
@@ -12,9 +13,71 @@ The package is designed for browser applications that already use `@microsoft/ra
12
13
  npm install @microsoft/rayfin-auth-provider-fabric @microsoft/rayfin-auth @microsoft/rayfin-lib
13
14
  ```
14
15
 
15
- ## Quick start
16
+ ## Direct Entra token sign-in
16
17
 
17
- The recommended entry point is `ensureSignedInWithFabric()`.
18
+ Use this flow when your app already obtains a delegated Entra token through MSAL, Azure CLI, or another identity library.
19
+ Token acquisition remains your responsibility.
20
+ The token must target the Fabric/Power BI resource accepted by your deployment, contain delegated `Item.Execute.All`, and belong to the target item's owning tenant.
21
+ The user must also have Execute permission on that item; app-only tokens are not supported.
22
+ The app must have external Entra exchange enabled.
23
+
24
+ ```typescript
25
+ import { Auth } from '@microsoft/rayfin-auth';
26
+ import { ApiClient } from '@microsoft/rayfin-lib';
27
+ import { signInWithEntraToken } from '@microsoft/rayfin-auth-provider-fabric';
28
+
29
+ const apiClient = new ApiClient({
30
+ baseUrl: rayfinEndpoint,
31
+ publishableKey,
32
+ });
33
+ const auth = new Auth(apiClient, { storage: false });
34
+ auth.attachToClient(apiClient);
35
+
36
+ try {
37
+ const session = await signInWithEntraToken(auth, { entraToken });
38
+ console.log('Authenticated', session.isAuthenticated);
39
+ // Use SDK clients attached to apiClient here.
40
+ } finally {
41
+ auth.destroy();
42
+ }
43
+ ```
44
+
45
+ This example works in Node.js without browser globals.
46
+ `rayfinEndpoint` is the trusted HTTPS AppBackend workload API base URL, including its existing path, not the app's static-hosting URL or a full token endpoint.
47
+ Use an absolute HTTPS URL, including for localhost, with no credentials, query string, or fragment; relative proxy bases are not supported.
48
+ `entraToken` is the raw token string, without the `Bearer` scheme prefix.
49
+ The SDK resolves `/api/auth/v1/brokered/token` against the configured base.
50
+ No `FabricAuthOptions`, popup, iframe, PKCE, or return origin is needed.
51
+
52
+ ```typescript
53
+ function signInWithEntraToken(
54
+ auth: Auth,
55
+ options: EntraTokenSignInOptions
56
+ ): Promise<OpaqueSession>;
57
+
58
+ interface EntraTokenSignInOptions {
59
+ entraToken: string;
60
+ }
61
+ ```
62
+
63
+ For a `RayfinClient`, pass `client.auth`; it is already attached to the client's HTTP transport.
64
+ The returned `OpaqueSession` exposes session metadata, not tokens.
65
+ Subsequent authenticated requests and refresh use the Rayfin session, not the Entra token.
66
+ The supplied `Auth` instance controls persistence; `storage: false` keeps the session in memory.
67
+ Use a separate instance per user on a server and call `auth.destroy()` when it is no longer needed.
68
+ `RayfinServerClient` does not expose `auth`; this helper expects a standalone `Auth` or `RayfinClient.auth`, not an access-token callback.
69
+
70
+ Each explicit call attempts an exchange rather than silently returning a previous session.
71
+ Concurrent direct calls on the same `Auth` instance run in invocation order and coordinate with its refresh operations.
72
+ Success persists and replaces the session before emitting the login event; failure preserves an existing unexpired in-memory session without treating it as a successful new sign-in.
73
+ The helper does not revoke the previous server session.
74
+ Failures reject with `AuthError`; do not treat a failed sign-in as authentication of the requested identity.
75
+ Browser callers need the service's CORS policy to permit the calling origin and authorization header.
76
+ The helper does not acquire Entra tokens, follow exchange redirects, or automatically retry a rejected exchange.
77
+
78
+ ## Browser quick start
79
+
80
+ For the browser broker experience, the recommended entry point is `ensureSignedInWithFabric()`.
18
81
 
19
82
  It performs a three-step waterfall:
20
83
 
@@ -73,6 +136,8 @@ This supports production and development portal URLs such as `https://app.fabric
73
136
 
74
137
  ## Supported flows
75
138
 
139
+ Use `signInWithEntraToken()` when your app already has a delegated Entra token and wants to sign in without a browser broker.
140
+
76
141
  Use `ensureSignedInWithFabric()` when you want silent-session and refresh-token fallback behavior before opening the broker UI.
77
142
 
78
143
  Use `initiateFabricLogin()` when you only want the broker step and do not need the session and refresh pre-checks.
@@ -114,7 +179,7 @@ The bridge returns `true` when it handled a Fabric handoff and `false` when the
114
179
 
115
180
  ## Behavior notes
116
181
 
117
- - The broker URL is built with PKCE using the `S256` challenge method.
182
+ - The browser broker URL is built with PKCE using the `S256` challenge method; direct token sign-in does not use PKCE.
118
183
  - Existing query parameters on `fabricPortalUrl` are preserved.
119
184
  - `callbackUrl` defaults to `${returnOrigin}/auth/callback` when omitted.
120
185
  - The broker handoff waits up to five minutes before timing out.
@@ -126,6 +191,10 @@ The package throws `AuthError` values from `@microsoft/rayfin-lib` for validatio
126
191
 
127
192
  Common cases include missing required options, blocked popups, explicit broker errors, and handoff timeout.
128
193
 
194
+ Direct token sign-in reports `INVALID_REQUEST` for invalid local inputs, `EXCHANGE_NOT_ENABLED` for a disabled exchange, `AUTH_FAILED` for token rejection, `INSUFFICIENT_PERMISSIONS` for missing Execute access, and `NOT_AVAILABLE` for an unavailable endpoint.
195
+ Transport or other HTTP failures use `TOKEN_EXCHANGE_FAILED`; malformed successful responses use `INVALID_TOKEN_RESPONSE`.
196
+ Errors do not include the supplied token or raw server response.
197
+
129
198
  ```typescript
130
199
  import { AuthError } from '@microsoft/rayfin-lib';
131
200
 
@@ -138,8 +207,10 @@ try {
138
207
  }
139
208
  ```
140
209
 
141
- ## Browser requirements
210
+ ## Runtime requirements
142
211
 
143
- This package is intended for browser environments.
212
+ The public package entry point and `signInWithEntraToken()` can be imported and used in Node.js without browser globals.
213
+ Use memory-only or appropriate custom `Auth` storage outside a browser.
144
214
 
145
- It depends on browser APIs such as `window.open()`, `postMessage`, `BroadcastChannel`, and `window.location`.
215
+ The popup, iframe, and legacy callback helpers remain browser-only.
216
+ They depend on browser APIs such as `window.open()`, `postMessage`, `BroadcastChannel`, and `window.location`.
@@ -35,5 +35,14 @@ export declare function requestHandoff(params: {
35
35
  codeChallengeMethod: string;
36
36
  state: string;
37
37
  timeoutMs?: number;
38
+ /** externalEmbed only: the app's brokered-authorize endpoint URL. */
39
+ brokeredAuthorizeUrl?: string;
40
+ /** externalEmbed only: the app's Fabric artifact identifier. */
41
+ artifactId?: string;
42
+ /**
43
+ * externalEmbed only: the pinned parent origin. When set, the request is
44
+ * delivered to (and the response accepted only from) this origin.
45
+ */
46
+ targetOrigin?: string;
38
47
  }): Promise<PostMessageHandoffResult>;
39
48
  //# sourceMappingURL=PostMessageAuthTransport.d.ts.map
@@ -21,21 +21,31 @@ import { AuthError } from '@microsoft/rayfin-lib';
21
21
  * supported public API.
22
22
  */
23
23
  export function requestHandoff(params) {
24
- const { callbackUrl, codeChallenge, codeChallengeMethod, state, timeoutMs } = params;
24
+ const { callbackUrl, codeChallenge, codeChallengeMethod, state, timeoutMs, brokeredAuthorizeUrl, artifactId, targetOrigin, } = params;
25
25
  if (!window.parent || window.parent === window) {
26
26
  return Promise.reject(new AuthError('No parent window — embedded auth requires an iframe host.', 'NO_PARENT_WINDOW'));
27
27
  }
28
+ // Normal Fabric payload is left byte-for-byte unchanged; the externalEmbed
29
+ // fields are added only when supplied.
30
+ const payload = {
31
+ callbackUrl,
32
+ codeChallenge,
33
+ codeChallengeMethod,
34
+ state,
35
+ };
36
+ if (brokeredAuthorizeUrl !== undefined) {
37
+ payload.brokeredAuthorizeUrl = brokeredAuthorizeUrl;
38
+ }
39
+ if (artifactId !== undefined) {
40
+ payload.artifactId = artifactId;
41
+ }
28
42
  return sendBridgeRequest({
29
43
  target: window.parent,
30
44
  channel: 'fabric-auth',
31
45
  kind: 'auth.requestHandoff',
32
- payload: {
33
- callbackUrl,
34
- codeChallenge,
35
- codeChallengeMethod,
36
- state,
37
- },
46
+ payload,
38
47
  timeoutMs,
48
+ ...(targetOrigin !== undefined ? { targetOrigin } : {}),
39
49
  }).catch((err) => {
40
50
  // Re-wrap BridgeError as AuthError for backward compatibility
41
51
  if (err instanceof BridgeError) {
@@ -2,6 +2,7 @@ import { generateCodeVerifier, generateCodeChallenge, generateState, } from '@mi
2
2
  import { createSessionFromTokenResponse } from '@microsoft/rayfin-auth/_internal';
3
3
  import { AuthError, assertBrowser } from '@microsoft/rayfin-lib';
4
4
  import { requestHandoff } from './PostMessageAuthTransport.js';
5
+ import { getPinnedParentOrigin, isExternalEmbedScenario, } from './externalEmbedClassification.js';
5
6
  import { markEmbeddedHandoffCompleted } from './fabricAuthHelpers.js';
6
7
  import { hasFabricUserHint } from './fabricUserHint.js';
7
8
  /**
@@ -33,10 +34,15 @@ export async function embeddedFabricLogin(auth, options) {
33
34
  if (!options.returnOrigin) {
34
35
  throw new AuthError('returnOrigin is required for embedded Fabric authentication.', 'MISSING_RETURN_ORIGIN');
35
36
  }
36
- if (!hasFabricUserHint()) {
37
- // Legacy host: nothing server-side is comparing identities, so fall back to discarding any prior
38
- // session before the handoff. Errors are swallowed — the stale token may already be invalid
39
- // server-side, and the local clear that signOut performs in its own catch is what matters.
37
+ // In the acked externalEmbed scenario there is no server-side serve-cookie
38
+ // identity gate, so a `?_fu=` hint carries no comparison and is ignored: the
39
+ // SDK always signs out before the handoff. On the normal Fabric path the
40
+ // hint-gated sign-out below is unchanged.
41
+ const externalEmbed = isExternalEmbedScenario();
42
+ if (externalEmbed || !hasFabricUserHint()) {
43
+ // Discard any prior session before the handoff. Errors are swallowed — the
44
+ // stale token may already be invalid server-side, and the local clear that
45
+ // signOut performs in its own catch is what matters.
40
46
  try {
41
47
  await auth.signOut();
42
48
  }
@@ -54,11 +60,26 @@ export async function embeddedFabricLogin(auth, options) {
54
60
  // not the portal itself. The extension runs at a different origin than
55
61
  // fabricPortalUrl, and the child iframe cannot read window.parent.origin.
56
62
  // Security relies on UUID v4 requestId correlation + PKCE.
63
+ // In the externalEmbed scenario the parent host has no per-app config, so it
64
+ // is told which endpoint to broker against (`brokeredAuthorizeUrl`, derived
65
+ // from the SDK's own Fabric capacity URL) and which artifact the token is for
66
+ // (`artifactId`). The request is also pinned to the parent origin
67
+ // acknowledged during classification. On the normal Fabric path all three are
68
+ // omitted and the payload/target are unchanged.
57
69
  const result = await requestHandoff({
58
70
  callbackUrl: options.returnOrigin,
59
71
  codeChallenge,
60
72
  codeChallengeMethod: 'S256',
61
73
  state,
74
+ ...(externalEmbed
75
+ ? {
76
+ brokeredAuthorizeUrl: auth
77
+ .getAuthApi()
78
+ .getBrokeredAuthorizeExternalUrl(),
79
+ artifactId: options.projectId,
80
+ targetOrigin: getPinnedParentOrigin(),
81
+ }
82
+ : {}),
62
83
  });
63
84
  // Validate state to prevent replay attacks.
64
85
  if (result.state !== state) {
@@ -12,8 +12,11 @@ import type { FabricAuthOptions } from './types.js';
12
12
  * 2. **Refresh token** — if a refresh token is available, attempt `auth.refreshSession()`.
13
13
  * Return the refreshed session on success; continue on failure.
14
14
  * Subject to the same skip.
15
- * 3. **Embedded mode** — if running inside a Fabric iframe (`fabricEmbedded=true`),
16
- * use `embeddedFabricLogin()` to acquire a session via `postMessage` handoff.
15
+ * 3. **Embedded mode** — if running inside a Fabric iframe (`fabricEmbedded=true`)
16
+ * or a third-party portal that acknowledges the externalEmbed classification
17
+ * handshake, use `embeddedFabricLogin()` to acquire a session via `postMessage`
18
+ * handoff. The externalEmbed scenario is detected from the handshake alone and
19
+ * does not require the Fabric `fabricEmbedded` flag.
17
20
  * 4. **Open Fabric broker** — no existing auth path available. Open the Fabric Portal
18
21
  * in a new tab via `initiateFabricLogin()` and wait for the Fabric extension to post
19
22
  * the handoff code via `postMessage`. The function exchanges the code internally
@@ -1,5 +1,6 @@
1
1
  import { AuthError, assertBrowser } from '@microsoft/rayfin-lib';
2
2
  import { embeddedFabricLogin } from './embeddedFabricLogin.js';
3
+ import { classifyExternalEmbed, isExternalEmbedScenario, } from './externalEmbedClassification.js';
3
4
  import { hasEmbeddedHandoffCompleted, isEmbeddedMode, tryResumeSession, } from './fabricAuthHelpers.js';
4
5
  import { hasFabricUserHint } from './fabricUserHint.js';
5
6
  import { initiateFabricLogin } from './initiateFabricLogin.js';
@@ -15,8 +16,11 @@ import { initiateFabricLogin } from './initiateFabricLogin.js';
15
16
  * 2. **Refresh token** — if a refresh token is available, attempt `auth.refreshSession()`.
16
17
  * Return the refreshed session on success; continue on failure.
17
18
  * Subject to the same skip.
18
- * 3. **Embedded mode** — if running inside a Fabric iframe (`fabricEmbedded=true`),
19
- * use `embeddedFabricLogin()` to acquire a session via `postMessage` handoff.
19
+ * 3. **Embedded mode** — if running inside a Fabric iframe (`fabricEmbedded=true`)
20
+ * or a third-party portal that acknowledges the externalEmbed classification
21
+ * handshake, use `embeddedFabricLogin()` to acquire a session via `postMessage`
22
+ * handoff. The externalEmbed scenario is detected from the handshake alone and
23
+ * does not require the Fabric `fabricEmbedded` flag.
20
24
  * 4. **Open Fabric broker** — no existing auth path available. Open the Fabric Portal
21
25
  * in a new tab via `initiateFabricLogin()` and wait for the Fabric extension to post
22
26
  * the handoff code via `postMessage`. The function exchanges the code internally
@@ -66,6 +70,19 @@ import { initiateFabricLogin } from './initiateFabricLogin.js';
66
70
  export async function ensureSignedInWithFabric(auth, options) {
67
71
  assertBrowser('ensureSignedInWithFabric');
68
72
  const inEmbeddedMode = isEmbeddedMode(options);
73
+ // Run the one-shot externalEmbed classification handshake before any resume
74
+ // decision — and independently of Fabric's `fabricEmbedded` flag. The
75
+ // handshake self-gates on the presence of a parent frame (no parent → instant
76
+ // no-op), so a standalone tab pays nothing. A third-party parent that brokers
77
+ // external Entra acknowledges here; a normal Fabric host does not (the short
78
+ // timeout then classifies as normal). A successful acknowledgement — not the
79
+ // Fabric-UX `fabricEmbedded` hint — is what defines the externalEmbed
80
+ // scenario, so a portal embedding the app need stamp no Fabric-specific flag.
81
+ await classifyExternalEmbed();
82
+ const externalEmbed = isExternalEmbedScenario();
83
+ // The postMessage handoff path applies whenever the app is embedded — either
84
+ // Fabric embedded mode (`fabricEmbedded`) or a classified externalEmbed parent.
85
+ const embedded = inEmbeddedMode || externalEmbed;
69
86
  // Steps 1-2: existing session or refresh.
70
87
  //
71
88
  // A host that stamps `?_fu=` has already had its cookie identity-checked by the workload gate
@@ -75,9 +92,13 @@ export async function ensureSignedInWithFabric(auth, options) {
75
92
  // A host that stamps no hint is indistinguishable from the pre-feature world: nothing compared the
76
93
  // identities, so the persisted session may belong to a previously signed-in Fabric user. There we
77
94
  // keep the original behaviour and skip resume until a handoff has run this page load.
78
- const skipResume = inEmbeddedMode && !hasFabricUserHint() && !hasEmbeddedHandoffCompleted();
95
+ //
96
+ // The externalEmbed scenario always skips resume: there is no serve-cookie identity gate and the
97
+ // parent brokers a fresh delegated handoff, so any persisted session must be discarded.
98
+ const skipResume = externalEmbed ||
99
+ (inEmbeddedMode && !hasFabricUserHint() && !hasEmbeddedHandoffCompleted());
79
100
  if (skipResume) {
80
- console.debug('[FabricAuth] Embedded host stamped no user hint — skipping session resume to avoid a stale cross-user session');
101
+ console.debug('[FabricAuth] Skipping session resume (externalEmbed scenario or embedded host stamped no user hint) to avoid reusing a stale session');
81
102
  }
82
103
  else {
83
104
  const resumed = await tryResumeSession(auth);
@@ -86,7 +107,7 @@ export async function ensureSignedInWithFabric(auth, options) {
86
107
  }
87
108
  }
88
109
  // Step 3: Embedded mode — postMessage auth via parent iframe host
89
- if (inEmbeddedMode) {
110
+ if (embedded) {
90
111
  console.debug('[FabricAuth] Embedded mode detected, using postMessage auth');
91
112
  try {
92
113
  await embeddedFabricLogin(auth, options);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * externalEmbed scenario classification.
3
+ *
4
+ * In embedded mode a third-party parent page may broker external-Entra
5
+ * authentication for this app. The app cannot tell such a host apart from the
6
+ * normal Fabric extension host by inspection, so on load it announces readiness
7
+ * once and treats a scenario acknowledgement as the classifier.
8
+ *
9
+ * The result is held in module state — like the Fabric user hint — so that
10
+ * every entry point (`ensureSignedInWithFabric`, `initEmbeddedAuth`) triggers
11
+ * the same one-shot classification and `embeddedFabricLogin` can read it to
12
+ * decide sign-out and payload shape without re-running the handshake.
13
+ *
14
+ * @internal Not exported from the package barrel.
15
+ */
16
+ /**
17
+ * Short classification timeout (ms), deliberately distinct from the handoff
18
+ * request timeout. A normal Fabric host never acknowledges, so this bound is
19
+ * added to a normal embedded login in full; it is kept small so that path
20
+ * incurs no material delay. An externalEmbed parent acknowledges synchronously,
21
+ * well within the bound.
22
+ */
23
+ export declare const EXTERNAL_EMBED_CLASSIFY_TIMEOUT_MS = 300;
24
+ /**
25
+ * Runs the one-shot externalEmbed handshake if it has not run this page load.
26
+ *
27
+ * Posts a single readiness message to the parent and waits up to
28
+ * {@link EXTERNAL_EMBED_CLASSIFY_TIMEOUT_MS} for an acknowledgement declaring
29
+ * the externalEmbed scenario. Idempotent: subsequent calls are no-ops.
30
+ */
31
+ export declare function classifyExternalEmbed(timeoutMs?: number): Promise<void>;
32
+ /**
33
+ * Whether the current load was classified as the externalEmbed scenario.
34
+ * Returns `false` until {@link classifyExternalEmbed} has resolved.
35
+ */
36
+ export declare function isExternalEmbedScenario(): boolean;
37
+ /**
38
+ * The parent origin pinned during the handshake, or `undefined` when not in the
39
+ * externalEmbed scenario. Used as the `postMessage` target for the handoff so
40
+ * the request goes only to the acknowledged parent.
41
+ */
42
+ export declare function getPinnedParentOrigin(): string | undefined;
43
+ /**
44
+ * Resets classification state.
45
+ *
46
+ * @internal Test helper.
47
+ */
48
+ export declare function resetExternalEmbedClassificationForTests(): void;
49
+ //# sourceMappingURL=externalEmbedClassification.d.ts.map
@@ -0,0 +1,107 @@
1
+ import { assertBrowser } from '@microsoft/rayfin-lib';
2
+ import { EXTERNAL_EMBED_ACK_KIND, EXTERNAL_EMBED_AUTH_CHANNEL, EXTERNAL_EMBED_READY_KIND, EXTERNAL_EMBED_SCENARIO, } from './externalEmbedProtocol.js';
3
+ /**
4
+ * externalEmbed scenario classification.
5
+ *
6
+ * In embedded mode a third-party parent page may broker external-Entra
7
+ * authentication for this app. The app cannot tell such a host apart from the
8
+ * normal Fabric extension host by inspection, so on load it announces readiness
9
+ * once and treats a scenario acknowledgement as the classifier.
10
+ *
11
+ * The result is held in module state — like the Fabric user hint — so that
12
+ * every entry point (`ensureSignedInWithFabric`, `initEmbeddedAuth`) triggers
13
+ * the same one-shot classification and `embeddedFabricLogin` can read it to
14
+ * decide sign-out and payload shape without re-running the handshake.
15
+ *
16
+ * @internal Not exported from the package barrel.
17
+ */
18
+ /**
19
+ * Short classification timeout (ms), deliberately distinct from the handoff
20
+ * request timeout. A normal Fabric host never acknowledges, so this bound is
21
+ * added to a normal embedded login in full; it is kept small so that path
22
+ * incurs no material delay. An externalEmbed parent acknowledges synchronously,
23
+ * well within the bound.
24
+ */
25
+ export const EXTERNAL_EMBED_CLASSIFY_TIMEOUT_MS = 300;
26
+ // `undefined` = not classified yet; `null` = normal Fabric; object = externalEmbed (pinned parent).
27
+ let classification;
28
+ /**
29
+ * Runs the one-shot externalEmbed handshake if it has not run this page load.
30
+ *
31
+ * Posts a single readiness message to the parent and waits up to
32
+ * {@link EXTERNAL_EMBED_CLASSIFY_TIMEOUT_MS} for an acknowledgement declaring
33
+ * the externalEmbed scenario. Idempotent: subsequent calls are no-ops.
34
+ */
35
+ export async function classifyExternalEmbed(timeoutMs = EXTERNAL_EMBED_CLASSIFY_TIMEOUT_MS) {
36
+ assertBrowser('classifyExternalEmbed');
37
+ if (classification !== undefined) {
38
+ return;
39
+ }
40
+ const parent = window.parent;
41
+ if (!parent || parent === window) {
42
+ classification = null;
43
+ return;
44
+ }
45
+ classification = await new Promise((resolve) => {
46
+ let settled = false;
47
+ const requestId = crypto.randomUUID();
48
+ function finish(result) {
49
+ if (settled)
50
+ return;
51
+ settled = true;
52
+ window.removeEventListener('message', onMessage);
53
+ clearTimeout(timer);
54
+ resolve(result);
55
+ }
56
+ function onMessage(event) {
57
+ const data = event.data;
58
+ if (!data || typeof data !== 'object')
59
+ return;
60
+ if (event.source !== parent)
61
+ return;
62
+ if (data.channel !== EXTERNAL_EMBED_AUTH_CHANNEL)
63
+ return;
64
+ if (data.kind !== EXTERNAL_EMBED_ACK_KIND)
65
+ return;
66
+ if (data.scenario !== EXTERNAL_EMBED_SCENARIO)
67
+ return;
68
+ // Pin the parent's origin from the acknowledgement for the later handoff.
69
+ finish({ source: parent, origin: event.origin });
70
+ }
71
+ window.addEventListener('message', onMessage);
72
+ const timer = setTimeout(() => finish(null), timeoutMs);
73
+ // The iframe cannot know the parent's origin a priori and the readiness
74
+ // signal carries no secret, so it is posted with a wildcard target; the
75
+ // acknowledgement pins the origin for everything that follows.
76
+ parent.postMessage({
77
+ channel: EXTERNAL_EMBED_AUTH_CHANNEL,
78
+ version: 1,
79
+ kind: EXTERNAL_EMBED_READY_KIND,
80
+ requestId,
81
+ }, '*');
82
+ });
83
+ }
84
+ /**
85
+ * Whether the current load was classified as the externalEmbed scenario.
86
+ * Returns `false` until {@link classifyExternalEmbed} has resolved.
87
+ */
88
+ export function isExternalEmbedScenario() {
89
+ return Boolean(classification);
90
+ }
91
+ /**
92
+ * The parent origin pinned during the handshake, or `undefined` when not in the
93
+ * externalEmbed scenario. Used as the `postMessage` target for the handoff so
94
+ * the request goes only to the acknowledged parent.
95
+ */
96
+ export function getPinnedParentOrigin() {
97
+ return classification ? classification.origin : undefined;
98
+ }
99
+ /**
100
+ * Resets classification state.
101
+ *
102
+ * @internal Test helper.
103
+ */
104
+ export function resetExternalEmbedClassificationForTests() {
105
+ classification = undefined;
106
+ }
107
+ //# sourceMappingURL=externalEmbedClassification.js.map
@@ -0,0 +1,9 @@
1
+ /** Bridge channel carrying Fabric brokered-auth messages. */
2
+ export declare const EXTERNAL_EMBED_AUTH_CHANNEL = "fabric-auth";
3
+ /** `kind` of the iframe's one-shot readiness announcement. */
4
+ export declare const EXTERNAL_EMBED_READY_KIND = "externalEmbed.ready";
5
+ /** `kind` of the parent's scenario acknowledgement. */
6
+ export declare const EXTERNAL_EMBED_ACK_KIND = "externalEmbed.ack";
7
+ /** Scenario tag declared by the acknowledgement. */
8
+ export declare const EXTERNAL_EMBED_SCENARIO = "externalEmbed";
9
+ //# sourceMappingURL=externalEmbedProtocol.d.ts.map
@@ -0,0 +1,27 @@
1
+ /*
2
+ * externalEmbed wire protocol — iframe (embedded app) side.
3
+ *
4
+ * These `channel`/`kind`/scenario literals are the wire contract shared with
5
+ * the parent embed host (`@microsoft/rayfin-embed-host`). They are owned in the
6
+ * auth layer — the same layer that already owns the `fabric-auth` channel and
7
+ * the `auth.requestHandoff` kind — rather than in the transport package, which
8
+ * stays plugin-agnostic (it defines only the generic envelope; callers supply
9
+ * `channel` and `kind`). The peer package declares the same literals; a
10
+ * contract test in each package pins them to these canonical values so the two
11
+ * ends cannot drift.
12
+ *
13
+ * Canonical values (see docs/rfc/external-entra-embed-host-sdk.md):
14
+ * channel = "fabric-auth", ready = "externalEmbed.ready",
15
+ * ack = "externalEmbed.ack", scenario = "externalEmbed".
16
+ *
17
+ * @internal Not exported from the package barrel.
18
+ */
19
+ /** Bridge channel carrying Fabric brokered-auth messages. */
20
+ export const EXTERNAL_EMBED_AUTH_CHANNEL = 'fabric-auth';
21
+ /** `kind` of the iframe's one-shot readiness announcement. */
22
+ export const EXTERNAL_EMBED_READY_KIND = 'externalEmbed.ready';
23
+ /** `kind` of the parent's scenario acknowledgement. */
24
+ export const EXTERNAL_EMBED_ACK_KIND = 'externalEmbed.ack';
25
+ /** Scenario tag declared by the acknowledgement. */
26
+ export const EXTERNAL_EMBED_SCENARIO = 'externalEmbed';
27
+ //# sourceMappingURL=externalEmbedProtocol.js.map
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export type { FabricAuthOptions } from './types.js';
2
+ export type { EntraTokenSignInOptions } from './signInWithEntraToken.js';
3
+ export { signInWithEntraToken } from './signInWithEntraToken.js';
2
4
  export { bridgeFabricCallback } from './bridgeFabricCallback.js';
3
5
  export { embeddedFabricLogin } from './embeddedFabricLogin.js';
4
6
  export { ensureSignedInWithFabric } from './ensureSignedInWithFabric.js';
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { signInWithEntraToken } from './signInWithEntraToken.js';
1
2
  // Functions
2
3
  export { bridgeFabricCallback } from './bridgeFabricCallback.js';
3
4
  export { embeddedFabricLogin } from './embeddedFabricLogin.js';
@@ -9,13 +9,17 @@ import type { FabricAuthOptions } from './types.js';
9
9
  * new tab.
10
10
  *
11
11
  * **Behaviour:**
12
- * - If `fabricEmbedded=true` is **not** in the URL and `options.fabricEmbedded`
13
- * is not `true`, returns `null` immediately (no-op).
12
+ * - Runs the one-shot externalEmbed classification handshake first. If a
13
+ * third-party parent acknowledges it, the externalEmbed flow proceeds even
14
+ * when the Fabric `fabricEmbedded` flag is absent.
15
+ * - If neither the handshake is acknowledged **nor** `fabricEmbedded` is set
16
+ * (option or `?fabricEmbedded=true`), returns `null` (no-op). A standalone
17
+ * page with no parent frame reaches this immediately.
14
18
  * - When the host stamps a `?_fu=` hint, runs the standard resume waterfall
15
19
  * (existing session → refresh token → handoff).
16
- * - When it does not, skips resume on the first call per page load and goes
17
- * straight to the handoff, preserving the pre-hint protection against reusing
18
- * a previously signed-in Fabric user's session.
20
+ * - When it does not (or in the externalEmbed scenario), skips resume on the
21
+ * first call per page load and goes straight to the handoff, preserving the
22
+ * protection against reusing a previously signed-in user's session.
19
23
  *
20
24
  * Apps that also support the popup flow should continue to call
21
25
  * {@link ensureSignedInWithFabric} from a user-gesture handler for the
@@ -1,4 +1,5 @@
1
1
  import { embeddedFabricLogin } from './embeddedFabricLogin.js';
2
+ import { classifyExternalEmbed, isExternalEmbedScenario, } from './externalEmbedClassification.js';
2
3
  import { hasEmbeddedHandoffCompleted, isEmbeddedMode, tryResumeSession, } from './fabricAuthHelpers.js';
3
4
  import { hasFabricUserHint } from './fabricUserHint.js';
4
5
  /**
@@ -10,13 +11,17 @@ import { hasFabricUserHint } from './fabricUserHint.js';
10
11
  * new tab.
11
12
  *
12
13
  * **Behaviour:**
13
- * - If `fabricEmbedded=true` is **not** in the URL and `options.fabricEmbedded`
14
- * is not `true`, returns `null` immediately (no-op).
14
+ * - Runs the one-shot externalEmbed classification handshake first. If a
15
+ * third-party parent acknowledges it, the externalEmbed flow proceeds even
16
+ * when the Fabric `fabricEmbedded` flag is absent.
17
+ * - If neither the handshake is acknowledged **nor** `fabricEmbedded` is set
18
+ * (option or `?fabricEmbedded=true`), returns `null` (no-op). A standalone
19
+ * page with no parent frame reaches this immediately.
15
20
  * - When the host stamps a `?_fu=` hint, runs the standard resume waterfall
16
21
  * (existing session → refresh token → handoff).
17
- * - When it does not, skips resume on the first call per page load and goes
18
- * straight to the handoff, preserving the pre-hint protection against reusing
19
- * a previously signed-in Fabric user's session.
22
+ * - When it does not (or in the externalEmbed scenario), skips resume on the
23
+ * first call per page load and goes straight to the handoff, preserving the
24
+ * protection against reusing a previously signed-in user's session.
20
25
  *
21
26
  * Apps that also support the popup flow should continue to call
22
27
  * {@link ensureSignedInWithFabric} from a user-gesture handler for the
@@ -27,20 +32,32 @@ import { hasFabricUserHint } from './fabricUserHint.js';
27
32
  * @returns The authenticated session, or `null` if not in embedded mode.
28
33
  */
29
34
  export async function initEmbeddedAuth(auth, options) {
30
- if (!isEmbeddedMode(options)) {
35
+ const inEmbeddedMode = isEmbeddedMode(options);
36
+ // One-shot externalEmbed classification handshake before any resume or
37
+ // early-return decision. A successful acknowledgement means a third-party
38
+ // parent is brokering external Entra — detected from the handshake alone,
39
+ // independently of Fabric's `fabricEmbedded` flag. The handshake self-gates on
40
+ // a parent frame, so a standalone page is an instant no-op and still returns
41
+ // `null` below without opening anything.
42
+ await classifyExternalEmbed();
43
+ const externalEmbed = isExternalEmbedScenario();
44
+ // Not embedded by either signal → nothing to do on page load.
45
+ if (!externalEmbed && !inEmbeddedMode) {
31
46
  return null;
32
47
  }
33
48
  // Steps 1-2: existing session or refresh. Skipped on a legacy host (no `?_fu=` hint) until a
34
49
  // handoff has run this page load, because nothing server-side compared the session's identity
35
- // against the current Fabric user. See ensureSignedInWithFabric for the full reasoning.
36
- if (hasFabricUserHint() || hasEmbeddedHandoffCompleted()) {
50
+ // against the current Fabric user. The externalEmbed scenario always skips resume — the parent
51
+ // brokers a fresh delegated handoff. See ensureSignedInWithFabric for the full reasoning.
52
+ if (!externalEmbed &&
53
+ (hasFabricUserHint() || hasEmbeddedHandoffCompleted())) {
37
54
  const resumed = await tryResumeSession(auth);
38
55
  if (resumed) {
39
56
  return resumed;
40
57
  }
41
58
  }
42
59
  else {
43
- console.debug('[FabricAuth:initEmbedded] Embedded host stamped no user hint — skipping session resume to avoid a stale cross-user session');
60
+ console.debug('[FabricAuth:initEmbedded] Skipping session resume (externalEmbed scenario or no user hint) to avoid a stale cross-user session');
44
61
  }
45
62
  // Step 3: postMessage handoff (no popup, no window.open)
46
63
  console.debug('[FabricAuth:initEmbedded] Starting postMessage handoff flow');
@@ -0,0 +1,28 @@
1
+ import type { Auth, OpaqueSession } from '@microsoft/rayfin-auth';
2
+ /** Options for signing in to a Fabric AppBackend with an existing Entra token. */
3
+ export interface EntraTokenSignInOptions {
4
+ /** Already-acquired delegated Entra access token, without a Bearer prefix. */
5
+ entraToken: string;
6
+ }
7
+ /**
8
+ * Signs in to the AppBackend configured on `auth` using a delegated Entra token.
9
+ * Works in browsers and Node.js; no popup or iframe is required.
10
+ *
11
+ * The configured backend must be an absolute HTTPS workload URL, including its
12
+ * capacity/workspace/artifact path, not a static-hosting URL. The token is used
13
+ * only for this request; subsequent refresh uses the Rayfin refresh token.
14
+ * Success replaces the session on this Auth instance. Failures reject without
15
+ * returning a prior session. Concurrent calls on the same instance run in call
16
+ * order, after any active refresh; refresh waits until these calls settle.
17
+ *
18
+ * @param auth - The Auth instance used by your SDK clients.
19
+ * @param options - The already-acquired Entra credential.
20
+ * @returns The installed session metadata, without access or refresh tokens.
21
+ * @throws `AuthError` with a stable code and a credential-free message.
22
+ * @example
23
+ * ```typescript
24
+ * const session = await signInWithEntraToken(client.auth, { entraToken });
25
+ * ```
26
+ */
27
+ export declare function signInWithEntraToken(auth: Auth, options: EntraTokenSignInOptions): Promise<OpaqueSession>;
28
+ //# sourceMappingURL=signInWithEntraToken.d.ts.map
@@ -0,0 +1,165 @@
1
+ import { createSessionFromTokenResponse, getAuthProviderClient, runProviderSignIn, } from '@microsoft/rayfin-auth/_internal';
2
+ import { AuthError } from '@microsoft/rayfin-lib';
3
+ const TOKEN_PATH = '/api/auth/v1/brokered/token';
4
+ function invalidResponse() {
5
+ return new AuthError('The token exchange returned an invalid token response.', 'INVALID_TOKEN_RESPONSE');
6
+ }
7
+ async function readTokenResponse(response) {
8
+ try {
9
+ return await response.json();
10
+ }
11
+ catch (error) {
12
+ if (error instanceof SyntaxError) {
13
+ return undefined;
14
+ }
15
+ throw error;
16
+ }
17
+ }
18
+ function validateTokenResponse(value) {
19
+ if (!value || typeof value !== 'object') {
20
+ throw invalidResponse();
21
+ }
22
+ if (!('accessToken' in value) ||
23
+ typeof value.accessToken !== 'string' ||
24
+ !value.accessToken.trim() ||
25
+ /\s/.test(value.accessToken) ||
26
+ !('tokenType' in value) ||
27
+ typeof value.tokenType !== 'string' ||
28
+ value.tokenType.toLowerCase() !== 'bearer' ||
29
+ !('expiresIn' in value) ||
30
+ typeof value.expiresIn !== 'number' ||
31
+ !Number.isFinite(value.expiresIn) ||
32
+ value.expiresIn <= 0) {
33
+ throw invalidResponse();
34
+ }
35
+ const now = Date.now();
36
+ const expiry = new Date(now + value.expiresIn * 1000).getTime();
37
+ if (!Number.isFinite(expiry) || expiry <= now) {
38
+ throw invalidResponse();
39
+ }
40
+ const refreshToken = 'refreshToken' in value ? value.refreshToken : undefined;
41
+ const scope = 'scope' in value ? value.scope : undefined;
42
+ if ((refreshToken != null && typeof refreshToken !== 'string') ||
43
+ (scope != null && typeof scope !== 'string')) {
44
+ throw invalidResponse();
45
+ }
46
+ return {
47
+ accessToken: value.accessToken,
48
+ tokenType: value.tokenType,
49
+ expiresIn: value.expiresIn,
50
+ refreshToken,
51
+ scope,
52
+ };
53
+ }
54
+ function exchangeError(status) {
55
+ switch (status) {
56
+ case 400:
57
+ return new AuthError('External Entra exchange is not enabled.', 'EXCHANGE_NOT_ENABLED');
58
+ case 401:
59
+ return new AuthError('Entra authentication failed.', 'AUTH_FAILED');
60
+ case 403:
61
+ return new AuthError('Item Execute permission is required.', 'INSUFFICIENT_PERMISSIONS');
62
+ case 404:
63
+ return new AuthError('External Entra exchange is not available.', 'NOT_AVAILABLE');
64
+ default:
65
+ return new AuthError('Entra token exchange failed.', 'TOKEN_EXCHANGE_FAILED');
66
+ }
67
+ }
68
+ function sanitizeSignInFailure(error) {
69
+ if (error instanceof AuthError) {
70
+ switch (error.code) {
71
+ case 'INVALID_TOKEN_RESPONSE':
72
+ throw invalidResponse();
73
+ case 'EXCHANGE_NOT_ENABLED':
74
+ throw exchangeError(400);
75
+ case 'AUTH_FAILED':
76
+ throw exchangeError(401);
77
+ case 'INSUFFICIENT_PERMISSIONS':
78
+ throw exchangeError(403);
79
+ case 'NOT_AVAILABLE':
80
+ throw exchangeError(404);
81
+ }
82
+ }
83
+ // Initialization and custom storage can also fail, outside the HTTP path.
84
+ throw exchangeError();
85
+ }
86
+ /**
87
+ * Signs in to the AppBackend configured on `auth` using a delegated Entra token.
88
+ * Works in browsers and Node.js; no popup or iframe is required.
89
+ *
90
+ * The configured backend must be an absolute HTTPS workload URL, including its
91
+ * capacity/workspace/artifact path, not a static-hosting URL. The token is used
92
+ * only for this request; subsequent refresh uses the Rayfin refresh token.
93
+ * Success replaces the session on this Auth instance. Failures reject without
94
+ * returning a prior session. Concurrent calls on the same instance run in call
95
+ * order, after any active refresh; refresh waits until these calls settle.
96
+ *
97
+ * @param auth - The Auth instance used by your SDK clients.
98
+ * @param options - The already-acquired Entra credential.
99
+ * @returns The installed session metadata, without access or refresh tokens.
100
+ * @throws `AuthError` with a stable code and a credential-free message.
101
+ * @example
102
+ * ```typescript
103
+ * const session = await signInWithEntraToken(client.auth, { entraToken });
104
+ * ```
105
+ */
106
+ export async function signInWithEntraToken(auth, options) {
107
+ const token = options?.entraToken;
108
+ if (typeof token !== 'string' ||
109
+ !token ||
110
+ /\s/.test(token) ||
111
+ /^bearer$/i.test(token)) {
112
+ throw new AuthError('A raw Entra access token is required.', 'INVALID_REQUEST');
113
+ }
114
+ const client = getAuthProviderClient(auth);
115
+ const endpoint = client.resolveUrl(TOKEN_PATH);
116
+ try {
117
+ const url = new URL(endpoint);
118
+ if (url.protocol !== 'https:' ||
119
+ url.username ||
120
+ url.password ||
121
+ url.search ||
122
+ url.hash ||
123
+ /[\s\\]/.test(endpoint) ||
124
+ !url.pathname.endsWith(TOKEN_PATH)) {
125
+ throw new Error();
126
+ }
127
+ }
128
+ catch {
129
+ throw new AuthError('An absolute HTTPS backend URL is required.', 'INVALID_REQUEST');
130
+ }
131
+ return runProviderSignIn(auth, async () => {
132
+ let result;
133
+ try {
134
+ result = await client.requestIsolated(TOKEN_PATH, { Authorization: `Bearer ${token}` }, async (response) => {
135
+ const rejected = !response.ok || response.redirected;
136
+ if (rejected) {
137
+ // Release unread streams without inspecting credential-bearing
138
+ // error bodies. Cancellation failures follow the sanitized
139
+ // transport-failure path, while the request timeout is still active.
140
+ await response.body?.cancel();
141
+ }
142
+ return {
143
+ status: response.status,
144
+ ok: response.ok,
145
+ redirected: response.redirected,
146
+ value: rejected ? undefined : await readTokenResponse(response),
147
+ };
148
+ });
149
+ }
150
+ catch {
151
+ throw exchangeError();
152
+ }
153
+ if (result.redirected) {
154
+ throw exchangeError();
155
+ }
156
+ if (!result.ok) {
157
+ throw exchangeError(result.status);
158
+ }
159
+ const response = validateTokenResponse(result.value);
160
+ return createSessionFromTokenResponse(auth, response, {
161
+ persistBeforeCommit: true,
162
+ });
163
+ }).catch(sanitizeSignInFailure);
164
+ }
165
+ //# sourceMappingURL=signInWithEntraToken.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@microsoft/rayfin-auth-provider-fabric",
3
- "version": "1.36.0-alpha.1601",
3
+ "version": "1.36.0-alpha.1663",
4
4
  "description": "Fabric brokered authentication provider for Rayfin SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -13,9 +13,9 @@
13
13
  ],
14
14
  "type": "module",
15
15
  "dependencies": {
16
- "@microsoft/rayfin-auth": "1.36.0-alpha.1601",
17
- "@microsoft/fabric-embedded-host": "1.36.0-alpha.1601",
18
- "@microsoft/rayfin-lib": "1.36.0-alpha.1601"
16
+ "@microsoft/rayfin-auth": "1.36.0-alpha.1663",
17
+ "@microsoft/fabric-embedded-host": "1.36.0-alpha.1663",
18
+ "@microsoft/rayfin-lib": "1.36.0-alpha.1663"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^5.8.3",