---
name: tableau-next-embedding-sdk-onboarding
description: >-
  End-to-end onboarding for embedding Salesforce Tableau Next / Tableau Unified
  Analytics components into any web app with the @salesforce/analytics-embedding-sdk.
  Covers the BACKEND Salesforce setup (create an External Client App / ECA, set the
  OAuth callback URL, add Trusted Domains for Inline Frames, mint an access token with
  Authorization Code + PKCE, and build the FrontDoor URL used as authCredential) and
  FRONTEND integration of a dashboard, metric, visualization, or AI agent across
  vanilla JavaScript, TypeScript, React, Vue, and Angular via Lightning Out 2.0. Use
  when embedding Salesforce analytics / Tableau Next in a web page, wiring
  initializeAnalyticsSdk, AnalyticsDashboard / AnalyticsMetric / AnalyticsVisualization
  / AnalyticsAgent, orgUrl + frontdoor auth, a server-side token broker, applyFilters or
  export, or debugging "refused to connect", CORS, or OAuth token errors.
license: Apache-2.0
compatibility: >-
  Requires a Salesforce org with Tableau Next enabled and an org admin to create the
  External Client App. SDK published on public npm as @salesforce/analytics-embedding-sdk
  (v2.0.0, zero runtime deps). Frontend needs a bundler (ESM) or a plain <script> tag
  (UMD). Node 18+ for the token broker.
metadata:
  domain: salesforce-analytics-embedding
  sdk: "@salesforce/analytics-embedding-sdk@2.0.0"
  components: [dashboard, metric, visualization, ai-agent]
  frameworks: [vanilla-js, typescript, react, vue, angular]
---

# Embedding Tableau Next with the Analytics Embedding SDK

This skill takes you from an empty org to a Tableau Next component rendered in your
web app. Embedding has **two phases that must happen in order** — the backend org
setup produces the credential the frontend needs, so do Phase 1 first.

```
Phase 1 (backend, once)          Phase 2 (frontend, per app)
Salesforce org  ─────────────▶   Your web app
  External Client App (ECA)        token broker (server) ── mints ──┐
  Trusted Domains (framing)                                         │
  OAuth token (Auth Code+PKCE)     initializeAnalyticsSdk({ ────────┘
  FrontDoor URL  ── authCredential ──▶  orgUrl, authCredential })
                                     new AnalyticsDashboard(props).render()
```

## Start here — interactive runbook (read this first)

**If you are an AI agent and this skill was just invoked, follow this before anything else.**
Your job is to take the user from nothing to a rendered Tableau Next embed. You need seven
inputs. **Do not assume them and do not dump this whole document at the user** — work either
way depending on what they already gave you:

- **If the user front-loaded details** (e.g. "embed a *dashboard* `abc123` from
  `myorg.lightning.force.com`, React frontend, Python broker"), parse what's present, then
  ask **only for what's still missing**. Never re-ask something already provided.
- **If the user just invoked the skill** with no details, ask for the inputs below. Prefer a
  single grouped set of questions over a slow one-at-a-time interrogation.

**The seven inputs:**

1. **Component type** — `dashboard`, `metric`, `visualization`, or `agent` (AI agent).
2. **Component id or API name** — the `idOrApiName` you'll embed.
3. **Org URLs — you need both hosts** (they're on the same org, usually the same subdomain):
   - **Lightning host** → becomes the SDK `orgUrl` / broker `ORG_URL`, e.g.
     `https://yourorg.lightning.force.com`.
   - **Login / My-Domain host** → becomes the broker `LOGIN_URL` (where OAuth happens), e.g.
     `https://yourorg.my.salesforce.com`.
   > These are **different hosts** and both are required — the broker fails fast without
   > `LOGIN_URL`. If the user gives only the Lightning host, derive the login host by swapping
   > `.lightning.force.com` → `.my.salesforce.com` and **confirm it** (or fall back to
   > `https://login.salesforce.com`); don't silently assume.
   >
   > **Tunneled / internal dev workspaces:** some orgs present the host in a hyphenated,
   > proxied form with an explicit port, e.g.
   > `https://yourorg.lightning.force-com.<workspace-suffix>.wa.crm.dev:6101` (note
   > `force-com` with a **hyphen**, plus a workspace suffix and a `:port`). Here a literal
   > `.lightning.force.com` → `.my.salesforce.com` replace **matches nothing and silently
   > no-ops**, leaving a wrong `LOGIN_URL`. Swap only the host-type segment
   > (`lightning.force-com` → `my.salesforce-com`) and **preserve the suffix and port** — the
   > login host is `https://yourorg.my.salesforce-com.<workspace-suffix>.wa.crm.dev:6101`. And
   > **do not** fall back to `https://login.salesforce.com` for these orgs — OAuth must hit the
   > org's own my-domain host on its port.
4. **Frontend framework** — vanilla JS, TypeScript, React, Vue, or Angular. These have
   ready-to-copy samples; if the user wants a different framework, ask which one they prefer
   and support it too — the list is not a limit.
5. **Auth flow — ask the user which one; don't assume.** Two supported flows:
   - **Authorization Code + PKCE** _(default — pick this unless the user says otherwise)_ —
     interactive: the user logs in through Salesforce in the browser, the broker exchanges the
     code for tokens. Use for user-facing apps where each viewer signs in as themselves. This is
     what [The runnable token broker](#the-runnable-token-broker) scaffolds.
   - **JWT Bearer** — server-to-server, **no interactive login**: the broker signs a JWT with a
     private key and impersonates a fixed Salesforce user. Use for headless/back-office/kiosk
     apps, or demos where nobody should see a login screen. Requires a certificate + extra ECA
     config; see [Auth alternative: the JWT Bearer flow](#auth-alternative-the-jwt-bearer-flow)
     and [Certificate & ECA setup for JWT Bearer](#certificate--eca-setup-for-jwt-bearer-one-time).
   > If the user is unsure, choose **Authorization Code + PKCE** — it needs no certificate and is
   > the SDK-documented path. Only go JWT Bearer when they explicitly want no login screen.
6. **Broker language** — Node.js, Java, Python, Go, C#, Ruby, or PHP. Both auth flows (Auth Code +
   PKCE and JWT Bearer) have a runnable broker in every one of these languages; if the user prefers
   a different language, ask which one and support it too — the list is not a limit.
7. **How much of the backend is already set up?** — this is **not** a yes/no. Ask which of the
   discrete steps below are done, so you walk the user through **only the ones that remain** (a
   partially-set-up org is the common case — e.g. the ECA exists but Trusted Domains was never
   added). Offer three states and, if they say **partial**, confirm each item:
   - **Nothing set up yet** → you'll walk the full Phase 1 backend below.
   - **Partially set up** → check off each item and resume at the first undone one:
     - ☐ **ECA created** and its **Consumer Key** in hand (Backend Step 1–3).
     - ☐ **Callback URL** set on the ECA, matching the broker's `redirect_uri`.
     - ☐ App origin added to **Trusted Domains for Inline Frames** (Backend Step 4).
     - _For **JWT Bearer** additionally:_ ☐ **public cert uploaded** + **JWT Bearer Flow enabled**,
       and ☐ the **sub user pre-authorized** (Permitted Users) — see the cert/ECA setup link above.
   - **Fully set up** → skip Phase 1 and go straight to scaffolding the broker + frontend.
   > Don't infer this from other answers, and don't assume "they mentioned a Consumer Key, so
   > everything's done" — a Consumer Key proves the ECA exists, not that Trusted Domains or (for
   > JWT) the cert/pre-authorization are in place. Ask.

**Then, before writing app code, set up the workspace (do this explicitly so the user can
pre-install):** State the exact prerequisites and dependencies for the choices the user
made (broker language, frontend framework, and auth flow), create the config file, and let the
user fill in secrets.

1. **Announce the toolchain + dependencies up front**, using the table below for the chosen
   **broker language** plus the chosen **frontend framework** (all JS/TS frameworks need
   Node.js 18+ and a `npm install` in the generated frontend). Tell the user the one or two
   install commands to run so they can do it before the demo.

   | Broker language | Prerequisite toolchain | Install its deps |
   |---|---|---|
   | Node.js | Node 18+ | `npm install` (in the broker dir) |
   | Java | JDK **21** (LTS), Maven 3.9+ | `mvn -q compile` (or first `mvn spring-boot:run`) |
   | Python | Python 3.9+ | `python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt` |
   | Go | Go 1.22+ | none (stdlib-only; `go run .` builds it) |
   | C# | .NET 8 SDK | `dotnet restore` |
   | Ruby | Ruby 3.x, Bundler | `bundle install` |
   | PHP | PHP 8.x (with cURL ext) | none (uses the built-in server) |

2. **Create the config file, don't ask for secrets in chat.** Write a `.env.example` (and, if
   the user wants, copy it to `.env`) next to the broker, and tell the user to paste the secret
   values in themselves — **never ask them to type a client secret or private key into the
   conversation, and never echo one back.** Use the key set for the **auth flow chosen in
   input #5:**

   **For Authorization Code + PKCE** (the default):
   ```properties
   PORT=8500
   LOGIN_URL=            # the org's login/My-Domain host (…my.salesforce.com); if unsure use https://login.salesforce.com
   ORG_URL=              # the Lightning host from input #3 (…lightning.force.com) — the SDK orgUrl
   CLIENT_ID=            # ECA Consumer Key            (paste yourself)
   CLIENT_SECRET=        # ECA Consumer Secret         (paste yourself; required by the reference ECA)
   REDIRECT_URI=http://localhost:8500/getAccessToken   # must match the ECA callback URL exactly
   APP_ORIGIN=http://localhost:8700                    # where the frontend runs / the CORS-allowed origin
   SESSION_SECRET=change-me-to-something-random
   ```

   **For JWT Bearer** (no `CLIENT_SECRET`, no `REDIRECT_URI`; adds the signing key + impersonated
   user — see [JWT Bearer broker (Node.js)](#jwt-bearer-broker-nodejs) for the full server):
   ```properties
   PORT=8500
   LOGIN_URL=            # the org's login/My-Domain host; ALSO used as the JWT `aud`
   ORG_URL=              # the Lightning host from input #3 (…lightning.force.com) — the SDK orgUrl
   CLIENT_ID=            # ECA Consumer Key — becomes the JWT `iss`   (paste yourself)
   SF_USERNAME=          # the Salesforce user to impersonate — the JWT `sub` (must be pre-authorized on the ECA)
   PRIVATE_KEY_PATH=./private.key   # path to the RSA private key; NEVER commit this file
   APP_ORIGIN=http://localhost:8700                    # where the frontend runs / the CORS-allowed origin
   SESSION_SECRET=change-me-to-something-random
   ```
   Note the two host vars are different in both cases: `ORG_URL` is the **Lightning** host
   (input #3); `LOGIN_URL` is the **login/My-Domain** host (usually the same subdomain with
   `my.salesforce.com`, or `https://login.salesforce.com` if the user doesn't know it).
   Add `.env` to `.gitignore` if a repo is present — and for JWT Bearer, add `private.key`
   (and `*.pem`/`*.key`) too; only the **public** cert ever leaves the machine (uploaded to the ECA).

**Then scaffold:**

- **If NOTHING is set up** → walk the user through
  [Phase 1 — Backend](#phase-1--backend-stand-up-authentication) first (ECA → callback URL →
  Trusted Domains → the token broker). The broker mints the FrontDoor URL the frontend needs,
  so this must come first. **If the user chose JWT Bearer in input #5**, the ECA setup also
  includes the certificate upload and pre-authorizing the sub user — see
  [Certificate & ECA setup for JWT Bearer](#certificate--eca-setup-for-jwt-bearer-one-time).
- **If PARTIALLY set up** → don't restart from the top and don't skip to the frontend. Using the
  checklist from input #7, **resume at the first undone step** and do only what's left, then
  scaffold. Each item maps to one section — jump straight to it:
  - ECA not created → [Backend Step 1–3](#backend-step-13-create-the-external-client-app-eca).
  - Callback URL not set → set it on the existing ECA (Backend Step 1–3, "Callback URL").
  - Trusted Domains not added → [Backend Step 4](#backend-step-4-open-iframe-framing-trusted-domains-for-inline-frames).
  - _JWT Bearer only:_ cert not uploaded / flow not enabled / sub user not pre-authorized →
    [Certificate & ECA setup for JWT Bearer](#certificate--eca-setup-for-jwt-bearer-one-time).

  Skip every item the user already confirmed done, then continue to scaffolding below.
- **If FULLY set up** → scaffold the token broker for the chosen **auth flow** and the
  chosen-framework component (from [Framework guides](#framework-guides)), wired to call
  `GET /getFrontdoorURL` and pass the result to `initializeAnalyticsSdk({ orgUrl, authCredential })`:
  - **Authorization Code + PKCE** → generate the chosen-language broker from
    [The runnable token broker](#the-runnable-token-broker).
  - **JWT Bearer** → generate the chosen-language JWT Bearer broker. The Node.js reference is
    [JWT Bearer broker (Node.js)](#jwt-bearer-broker-nodejs); ports for
    [Java](#java-spring-boot--jwt-bearer), [Python](#python-fastapi--jwt-bearer),
    [Go](#go-nethttp--jwt-bearer), [C#](#c-aspnet-core--jwt-bearer),
    [Ruby](#ruby-sinatra--jwt-bearer), and [PHP](#php-built-in-server--jwt-bearer) follow it. For
    any other language, port the same three steps — sign JWT, exchange for token, mint FrontDoor.
- **Always** confirm the security invariants hold in what you produce: the client secret / private
  key and the raw access token stay **server-side**, and the component is instantiated with
  **`idOrApiName`**.

Everything you need to do all of the above is inlined below — you do not need to open any
other file or fetch anything.

## How to use this file

This is a **single, self-contained file** — every doc and every piece of runnable code
is inlined below, so an agent (or a person) can complete the whole task without opening
anything else or having network access. It is written in the [Agent Skills](https://agentskills.io)
open format, so a compatible AI coding agent can load it as a skill; it also reads
perfectly well as a plain document.

> **Agents: generate every file from the code blocks in THIS document.** This file may be
> the only thing present — do not assume a `token-broker/` or `samples/` folder exists next
> to it, and do not read sibling files to reconstruct a project. Each broker and each
> framework sample is inlined in full (every file it needs, labelled with its path); create
> those files from the fenced blocks here. The paths shown (e.g. `samples/react/src/App.tsx`)
> are just the layout to create, not a dependency to read.

- **All runnable code is inlined below**, in [The runnable token broker](#the-runnable-token-broker)
  and [Runnable samples](#runnable-samples) — every file of every broker (7 languages) and
  every framework sample (vanilla JS, TypeScript, React, Vue, Angular), labelled with the
  path to write it to. Copy them out to build a complete, runnable project; you never need
  another file.
- **Authoritative upstream docs:** the official Salesforce **Tableau Next Embedding SDK**
  guide is at <https://developer.salesforce.com/docs/analytics/sdk/guide/sdk-overview.html>.
  It's the canonical reference to fall back to for anything this file doesn't cover; useful
  child pages are **Set Up Salesforce for Embedding**, **Browser Requirements** (e.g. the
  third-party-cookie / CSP requirements for the embed iframe), **Troubleshooting**, and the
  **Release Notes**. Note the upstream version notice: **from July '26 you must use v2.0**
  (v1.0/v1.1 are unsupported). Everything in this file is verified against the SDK source
  (often *stronger* than those docs — see [Auth details verified against
  source](#auth-details-verified-against-source)), so the skill does **not** require the
  guide to complete a setup — treat it as the authoritative supplement to consult when you
  need the newest upstream detail or a topic this file leaves out.

## Contents

- [Prerequisites](#prerequisites)
- **Phase 1 — Backend**
  - [Step 1–3: Create the External Client App (ECA)](#backend-step-13-create-the-external-client-app-eca)
  - [Step 4: Open iframe framing (Trusted Domains for Inline Frames)](#backend-step-4-open-iframe-framing-trusted-domains-for-inline-frames)
  - [Step 5: Mint an access token, then build the FrontDoor URL](#backend-step-5-mint-an-access-token-then-build-the-frontdoor-url)
  - [Auth alternative: the JWT Bearer flow](#auth-alternative-the-jwt-bearer-flow)
  - [Verify the happy path (30-second smoke test)](#verify-the-happy-path-30-second-smoke-test)
  - [Backend & auth troubleshooting](#backend--auth-troubleshooting)
- **Phase 2 — Frontend**
  - [The universal 5-step sequence](#phase-2--frontend-the-universal-5-step-sequence)
  - [Pick a component](#pick-a-component)
  - [SDK API reference](#sdk-api-reference)
  - [Component lifecycle & events](#component-lifecycle--events)
  - [Framework guides](#framework-guides): [Vanilla JS](#vanilla-javascript) · [TypeScript](#typescript) · [React](#react) · [Vue 3](#vue-3) · [Angular](#angular)
- [Multi-org](#multi-org): [the shape](#the-shape) · [one credential per org](#the-one-rule-that-shapes-everything-one-credential-per-org) · [`PARTIAL_SUCCESS` & `orgStates`](#reading-the-result-partial_success-and-orgstates) · [add/retry/drop orgs](#adding-retrying-and-dropping-orgs-after-init) · [broker variant](#multi-org-broker-variant)
- [Auth details verified against source](#auth-details-verified-against-source)
- **The runnable token broker**
  - [The broker contract (language-neutral)](#the-broker-contract-language-neutral)
  - Implementations (Auth Code + PKCE): [Node.js](#nodejs-express) · [Java](#java-spring-boot) · [Python](#python-fastapi) · [Go](#go-nethttp) · [C#](#c-aspnet-core) · [Ruby](#ruby-sinatra) · [PHP](#php-built-in-server)
  - Alternative flow (JWT Bearer): [Node.js](#jwt-bearer-broker-nodejs) · [Java](#java-spring-boot--jwt-bearer) · [Python](#python-fastapi--jwt-bearer) · [Go](#go-nethttp--jwt-bearer) · [C#](#c-aspnet-core--jwt-bearer) · [Ruby](#ruby-sinatra--jwt-bearer) · [PHP](#php-built-in-server--jwt-bearer)
- [Integrating into an existing app](#integrating-into-an-existing-app)
- [Runnable samples](#runnable-samples)
- [Security notes](#security-notes)

## Prerequisites

- A Salesforce org with **Tableau Next / Tableau Unified Analytics** enabled, and the
  component IDs (or API names) you want to embed.
- **Admin access** to the org (creating an External Client App and editing Session
  Settings requires it).
- Your org's **Lightning (My Domain) URL** — e.g. `https://yourorg.lightning.force.com`.
  This is the `orgUrl` you pass the SDK. It is **not** the `my.salesforce.com` host.
- Node 18+ (for the token broker) and, for framework samples, a bundler (Vite, etc.).

> **Official guide (authoritative fallback):** the Salesforce **Tableau Next Embedding SDK**
> docs at <https://developer.salesforce.com/docs/analytics/sdk/guide/sdk-overview.html> cover
> the same setup from Salesforce's side — including a **Browser Requirements** page (the embed
> runs in a cross-site iframe, so it documents the third-party-cookie / CSP requirements) and
> **Troubleshooting**. This skill is self-contained and doesn't need it, but consult it for the
> newest upstream detail or anything not covered here. (Version note: from **July '26** the SDK
> requires **v2.0**.)

---

# Phase 1 — Backend: stand up authentication

Do these once per org. The short version:

1. **Create an External Client App (ECA)** and grab its Consumer Key (client_id).
2. **Set the OAuth callback URL** to your token broker's redirect (e.g.
   `http://localhost:8500/getAccessToken`).
3. **Select OAuth scopes**: `api`, `web`, `refresh_token`, `offline_access`, `lightning`.
4. **Open iframe framing**: Setup → Session Settings → **Trusted Domains for Inline
   Frames** → add your app origin with **IFrame Type = Lightning Out**. This clears the
   "refused to connect" error.
5. **Mint an access token** via Authorization Code + PKCE, then **exchange it for a
   FrontDoor URL**. This is what the frontend passes as `authCredential`.

The token exchange and FrontDoor call must happen **server-side** (they can require a
secret and shouldn't expose raw tokens to the browser). Use the ready-made broker in
[The runnable token broker](#the-runnable-token-broker).

## Backend Step 1–3: Create the External Client App (ECA)

The External Client App (ECA) is the OAuth client your app authenticates as. You create
it once per org. It produces a **Consumer Key** (`client_id`) that the token broker uses.

> ECAs are the modern replacement for Connected Apps. On a Tableau Next org you create
> them under **Setup → Apps → External Client Apps → External Client App Manager**.

There are two ways to create it: **manually in Setup** (recommended for a first setup,
and the most portable — any admin can follow it) or **scripted** (for repeatable
dev/scratch-org provisioning). Both produce the same result.

### Option A — Manual, in Setup (recommended)

1. In Setup, search for **External Client App Manager** (under Apps → External Client Apps).
2. Click **New External Client App**. Give it a name (e.g. "Analytics Embedding") and a
   contact email.
3. Enable OAuth, then open **OAuth Settings** and configure the three things below.

**Callback URL** — set it to the redirect your token broker listens on. You can list more
than one (newline-separated) — e.g. a local and a deployed origin:

```
http://localhost:8500/getAccessToken
https://your-app.example.com/getAccessToken
```

The callback URL must **exactly** match the `redirect_uri` your broker sends during the
OAuth flow. The bundled broker uses `<origin>/getAccessToken`.

**OAuth scopes** — select exactly these (verified against a live Tableau Next org). The
picklist labels on the left, the underlying scope on the right:

| Setup picklist label | Scope |
|----------------------|-------|
| Manage user data via APIs | `api` |
| Manage user data via Web browsers | `web` |
| Perform requests at any time | `refresh_token`, `offline_access` |
| Access Lightning applications | `lightning` |

> The token broker's authorize request sends `scope=api refresh_token lightning web`
> (space-delimited). Make sure every scope it requests is *granted* by the ECA above, or
> the authorize step fails. (`offline_access` is granted alongside `refresh_token`.)

**Flow enablement / client secret:**

- The bundled broker uses **Authorization Code + PKCE**. It appends a `client_secret` only
  when one is set in its config (`CLIENT_SECRET`), so it can drive either a public
  (PKCE-only) or a confidential (secret-bearing) client. Store any secret **only** on the
  server, never in frontend code.

> **Bottom line: plan on setting `CLIENT_SECRET`.** The reference ECA in this repo requires
> the Consumer Secret at token exchange — if you get `invalid_client`, that's why. (The full
> flag analysis, and how to run genuinely PKCE-only instead, is in
> [Auth details verified against source](#auth-details-verified-against-source).)

4. Save. Open **Consumer Key and Secret** to copy the **Consumer Key** — this is the
   `client_id` for the token broker. Copy the secret too if your org requires one.

### Option B — Scripted (dev/scratch orgs only)

This repo's `packages/metadata-utils` can create the ECA and its supporting metadata in
one transaction:

Requires these env vars set first: `USERNAME`, `PASSWORD`, `ORG_URL`, `SALESFORCE_API_VERSION`.
`eca:create` prints the Consumer Key on success and rolls back on failure.

```bash
cd packages/metadata-utils
npm run eca:create
```

> **Caution:** this path uses **SOAP username/password** auth via env vars. Only use it
> against a dev/scratch org you control, and never commit credentials. For anything
> shared or production-like, prefer Option A. (Source:
> `packages/metadata-utils/README.md`, `src/index.ts`, `src/auth.ts`.)

The scripted payload grants `RefreshToken,OfflineAccess,Lightning,Api`. Note this omits
`web`, which the broker's authorize request includes — so if you use Option B, **add the
`web` scope** in Setup afterward (see the scope table above) to match a real embedding
setup.

### What you have after this step

- **Consumer Key** (`client_id`) — into the token broker config.
- **Consumer Secret** (only if your org requires one) — into the token broker config, server-side.
- Callback URL registered, matching your broker's redirect.
- Scopes granted: `api web refresh_token offline_access lightning`.

## Backend Step 4: Open iframe framing (Trusted Domains for Inline Frames)

Lightning Out 2.0 renders the Tableau Next component **inside an iframe** on your page.
By default Salesforce refuses to be framed by other origins (you'll see **"refused to
connect"** / a `frame-ancestors` or `X-Frame-Options` block in the console). You fix this
by allow-listing your app's origin for Lightning Out framing.

**Exact location (verified): Setup → Session Settings → Trusted Domains for Inline Frames.**

> This is the **Session Settings** page (the section sits just above "Cross-Site Request
> Forgery (CSRF) Protection"). It is **not** the "Trusted URLs" / CSP Trusted Sites page —
> a common wrong turn. Add the domain here, under *Trusted Domains for Inline Frames*.

Click **Add Domain** and add each origin that will host the embed, choosing
**IFrame Type = `Lightning Out`**:

| Domain | IFrame Type |
|--------|-------------|
| `http://localhost:8700` | Lightning Out |
| `https://your-app.example.com` | Lightning Out |

Add the origin of the **frontend that hosts the embed** — the SPA, which runs on
`http://localhost:8700` (this matches `APP_ORIGIN`). Do **not** add the broker's origin
(`http://localhost:8500`): the broker is a server-side API, it never frames a component.
The rule of thumb: allow-list the port your **browser's address bar** shows, never the one
in your **broker's terminal**.

> **You may see *two* `frame-ancestors` violations, for two different Salesforce hosts —
> that's expected, not a double bug.** Lightning Out frames your page twice: first a
> **hidden** auth iframe against the login / `my.salesforce.com` host (it exchanges the
> FrontDoor URL for a session), then the **visible** component iframe against the
> `lightning.force.com` host. Both are framed by *your* app origin, and `frame-ancestors` is
> keyed by the **framing** origin — so the single Trusted Domains entry you add here (your
> app's origin) clears **both**. On tunneled dev orgs you may even see three hosts
> (`file.force-com`, `my.salesforce-com`, `lightning.force-com`); the one app-origin entry
> still governs all of them.

Notes:
- Use the **exact origin** (scheme + host + port). Match your dev server exactly —
  `http://localhost:8700` (the samples' default), not `https://`, unless you serve over TLS.
- For production, add your real app origin. Remove the localhost entry when you no longer
  need it.

The page also lists other IFrame Types (Visualforce Pages, Surveys, UI Embedding, etc.).
For the Analytics Embedding SDK you want **Lightning Out**.

**Also check: CORS.** If your frontend makes cross-origin calls to the org (the token
broker runs server-side, so this is usually only needed if you call org APIs from the
browser), add your origin under **Setup → CORS → Allowed Origins List**. If you only use
the server-side token broker, you typically don't need a CORS entry, but add it if you see
CORS errors in the browser console.

**Verify it worked.** After adding the domain, reload your app. The "refused to connect"
error should be gone and the component iframe should render. If it persists: double-check
the origin matches exactly (scheme/host/port), confirm you chose **Lightning Out** as the
IFrame Type, and see the [troubleshooting](#backend--auth-troubleshooting) hosted-org /
My Domain nuances.

> **Fastest diagnostic — read the allow-list the browser prints.** A `frame-ancestors`
> console error dumps the org's *current* allow-list verbatim, e.g.
> `Refused to frame … 'frame-ancestors' 'self' … http://localhost:8500`. That is the
> authoritative list of what's trusted right now — faster than re-opening Setup. If your
> frontend's origin (`http://localhost:8700`) isn't in it, it isn't allow-listed yet; if you
> see your **broker's** port (`:8500`) in it, you added the wrong origin (see the rule of
> thumb above). Entries you didn't add (some orgs carry baseline platform entries) are normal
> — you only need *your* app origin present.

## Backend Step 5: Mint an access token, then build the FrontDoor URL

The frontend never mints credentials itself. A small **server-side token broker** runs
the OAuth flow and produces a **FrontDoor URL**, which the SDK accepts as `authCredential`.
This section explains the flow; the runnable server is in [The runnable token
broker](#the-runnable-token-broker).

### Why server-side

- The token exchange requires a **client secret** for the reference ECA
  (`isSecretRequiredForTokenExchange: true`) — that must never be in browser code.
- The FrontDoor call uses the raw access token as a `Bearer` credential — also server-only.
- The browser only ever receives the short-lived **FrontDoor URL**. (And it must: the SDK
  rejects a non-URL `authCredential` before init — see [Auth details verified against
  source](#auth-details-verified-against-source) — so a bare token can't be handed to the
  browser as a shortcut anyway.)

### The flow (three broker endpoints)

```
Browser                         Token Broker (server)                 Salesforce org
   │  GET /login ───────────────────▶│                                      │
   │                                 │  redirect to /services/oauth2/authorize ─▶ (user logs in)
   │◀──────────────── redirect back with ?code ───────────────────────────┤
   │  GET /getAccessToken?code=… ───▶│                                      │
   │                                 │  POST /services/oauth2/token ───────▶│ (code + PKCE verifier)
   │                                 │◀────────── access_token, instance_url┤
   │  GET /getFrontdoorURL ─────────▶│                                      │
   │                                 │  POST /services/oauth2/singleaccess ─▶│ (Bearer access_token)
   │                                 │◀───────────────── frontdoor_uri ─────┤
   │◀─────────── { frontdoorUrl } ───┤                                      │
   │  initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl })      │
```

**1. Authorize (PKCE).** The broker generates a PKCE `code_verifier`/`code_challenge`,
then redirects the browser to:

```
<login_url>/services/oauth2/authorize
  ?response_type=code
  &client_id=<consumer key>
  &redirect_uri=<origin>/getAccessToken
  &scope=api%20refresh_token%20lightning%20web
  &code_challenge=<challenge>
  &code_challenge_method=S256
```

The `scope` must be granted by the ECA. `<login_url>` is your org's login host.

**2. Token exchange.** Salesforce redirects back to `/getAccessToken?code=…`. The broker
exchanges the code:

```
POST <login_url>/services/oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&client_id=<consumer key>
&redirect_uri=<origin>/getAccessToken
&code=<code>
&code_verifier=<pkce verifier>
# client_secret appended ONLY if configured (confidential client)
```

The response contains `access_token` and `instance_url`. The broker keeps these
server-side (in the session).

**3. FrontDoor URL.** The broker exchanges the access token for a FrontDoor URL:

```
POST <instance_url>/services/oauth2/singleaccess
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded
Accept: application/json
```

The response is `{ "frontdoor_uri": "https://…" }`. The broker returns
`{ frontdoorUrl }` to the browser. (Source: `getFrontdoorUrl` in
`packages/playground/sdk_test/routers/authRouter.ts`.)

### Using it in the frontend

```js
const { frontdoorUrl } = await (
  await fetch('http://localhost:8500/getFrontdoorURL', { credentials: 'include' })
).json();
// init resolves with a status (it does NOT throw on failure) — always check it.
const res = await initializeAnalyticsSdk({
  orgUrl: 'https://yourorg.lightning.force.com',
  authCredential: frontdoorUrl,
});
if (res.status !== Status.SUCCESS) {
  throw new Error(`SDK init failed (${res.status}).`); // see "init fails by status, not by throwing"
}
```

### Token lifetime

Access tokens are short-lived (session-dependent; commonly ~2 hours). The ECA grants a
long-lived **refresh token** (`refresh_token`/`offline_access` scopes) that re-mints an
access token without a full interactive login. The bundled broker **uses it**: it stores
the refresh token at callback time, and when `singleaccess` returns 401/403 (expired
access token) `/getFrontdoorURL` refreshes once and retries — so the SPA keeps working
across the TTL boundary without bouncing the user through `/login`. Only if the refresh
also fails (org withheld the scope, or the refresh token was revoked) does the SPA need a
fresh interactive login.

> **All seven ports** (Node, Java, Python, Go, C#, Ruby, PHP) implement this refresh-and-retry
> identically: store `refresh_token` at callback; on 401/403 from `singleaccess`, POST
> `grant_type=refresh_token` and retry once. It's a no-op when the org didn't grant a refresh
> token — the flow degrades gracefully to requiring a fresh `/login`.

### Two FrontDoor endpoints

The bundled broker uses **`/services/oauth2/singleaccess`** (against the `instance_url`) —
the simplest path and the default. There is a second endpoint,
**`/services/oauth2/lightningoutsingleaccess?lightning_out_app_id=<id>`**, used when a
Lightning Out App is configured; that path converts the Lightning host to the
`my.salesforce.com` host before calling. (Source: `generateFrontdoorURL` in
`packages/playground/sdk_test/public/js/assets.js`.)

> **VERIFY (live org):** confirm which endpoint your integration should use.
> `singleaccess` is the verified default here. Use `lightningoutsingleaccess` only if you
> deliberately configure a Lightning Out App and pass its id.

## Auth alternative: the JWT Bearer flow

Everything above uses **Authorization Code + PKCE** — the interactive flow where each user
logs in through Salesforce in the browser. That is the **default and recommended** flow for
embedding, and it's what all seven bundled brokers implement. This section describes an
**alternative** for a different scenario.

> **Status of this section.** This flow has been **verified end-to-end on a real org** — the
> broker below mints a token via JWT Bearer, builds the FrontDoor URL, and the SDK renders a
> live dashboard from it. Two honest caveats remain: it is **not part of the official SDK
> guide** (the guide documents Authorization Code + PKCE), and the SDK source itself never
> touches the token step — it only consumes the FrontDoor URL, so any OAuth flow that produces
> a valid one works. Your org's ECA config still has to be correct (cert uploaded, `sub` user
> pre-authorized); if the token exchange fails, see the troubleshooting notes by the broker.

### When to use it (and when not to)

The JWT Bearer flow is a **server-to-server** flow: no interactive login, no browser
redirect. Your server signs a JWT with a private key and exchanges it for an access token,
**impersonating** a named Salesforce user (the `sub` claim). Reach for it when:

- The embed runs in a **headless / backend-driven** context where you can't send the user
  through an interactive Salesforce login (e.g. a service rendering a dashboard on a
  schedule, or a trusted middle tier that has already authenticated the end user its own way).
- You want to map **your** application's users/roles onto specific Salesforce users
  server-side, rather than each viewer authenticating to Salesforce directly.

**Prefer Authorization Code + PKCE (the default) when** each viewer is a real Salesforce user
who can log in — it's the flow the SDK guide documents, it needs no private-key management,
and each user authenticates as themselves.

> **Security & licensing caveat — read before choosing this.** JWT Bearer **impersonates the
> `sub` user with no password and no interactive consent**. That means:
> - The signing **private key is a high-value secret** — anyone holding it can mint tokens for
>   any pre-authorized user. Keep it server-side only (env var or a secrets manager), never in
>   the repo, never in the browser. (`*.key`/`*.pem` belong in `.gitignore`.)
> - The `sub` user must be **pre-authorized** for the External Client App (admin-approved /
>   assigned), and each impersonated user still consumes the **Tableau+ / Tableau Next license**
>   and must have the right permissions. Impersonation doesn't bypass licensing or object/row
>   security — it authenticates *as* that user, with exactly that user's access.
> - Because there's no interactive login, MFA and per-user consent screens are **not** part of
>   this flow. Confirm that's acceptable for your security posture.

### Certificate & ECA setup for JWT Bearer (one-time)

JWT Bearer authenticates with an **RS256 signature** instead of a client secret: you upload a
**public certificate** to the External Client App and sign the assertion with the matching
**private key**. This is the same ECA from
[Backend Step 1–3](#backend-step-13-create-the-external-client-app-eca) — you're adding a
certificate and one policy to it, not creating a new app. Do all five steps; skipping the
last one (Permitted Users) is the most common cause of `invalid_grant`.

**1. Generate the key pair.** In a working directory (this creates two files there):

```bash
openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 \
  -keyout private.key -out public.crt \
  -subj "/CN=tableau-next-embedding"
```

You get **`public.crt`** (upload this to the ECA) and **`private.key`** (stays on the
server, next to the broker). openssl prints a stream of dots while generating the key —
that's normal; the files land in your current directory. Confirm with `ls -la private.key
public.crt`.

> **Only `public.crt` goes to Salesforce. Never upload or commit `private.key`** — it's the
> secret that lets the broker sign as the ECA. Add `*.key` / `*.pem` to `.gitignore`.
>
> `-days 365` means this cert **expires in a year** — fine for a demo, but for production track
> the expiry and plan a rotation (generate a new pair, upload the new `public.crt`, swap the key)
> before it lapses, or the token exchange starts failing. `-nodes` produces an **unencrypted**
> key, which is what the broker expects; a passphrase-encrypted key makes `jwt.sign` fail unless
> you pass the passphrase.

**2. Set OAuth scopes.** On the ECA's OAuth settings, select at least: **Manage user data via
APIs (`api`)**, **Manage user data via Web browsers (`web`)**, **Perform requests at any time
(`refresh_token`, `offline_access`)**, and **Access Lightning applications (`lightning`)** —
the same scopes the interactive flow uses.

**3. Set a Callback URL.** The Callback URL field is **required** even though JWT Bearer never
uses a browser redirect. Use the broker's standard one, `http://localhost:8500/getAccessToken`
— it's only actually exercised if you also run the Auth Code + PKCE flow against this same ECA.

**4. Enable the flow and upload the cert.** Under **Flow Enablement**, check **Enable JWT
Bearer Flow**, then under **Certificate Upload** upload **`public.crt`**.

> **Don't confuse this with the "Issue JSON Web Token (JWT)-based access tokens" checkbox** in
> the **Security** section — that's a *different* setting that only controls the *format* of the
> returned access token (JWT vs opaque). The JWT Bearer **flow** works either way, so **leave
> that Security checkbox off**; the one you need is **Enable JWT Bearer Flow** under Flow
> Enablement. (The greyed Security items — PKCE required, refresh-token rotation, 30-day TTL —
> are org-locked defaults and don't affect the JWT Bearer exchange.)

**5. Pre-authorize the impersonated user (the step people miss).** JWT Bearer signs in *as* the
`sub` user with no interactive consent, so that user must be **pre-authorized** for the app.
On the ECA (or its linked connected-app policies): set **Permitted Users = "Admin approved
users are pre-authorized,"** then under **Select Profiles** (or Permission Sets) add the
**Profile of the `sub` user** — or a permission set they hold. Every user has exactly one
profile, so adding their profile is the reliable path; adding a profile pre-authorizes *every*
user with that profile, so add only what you need. (Reference: Salesforce **OAuth 2.0 JWT
Bearer Flow** docs.)

> **The `sub` user must also hold a Tableau+ / Tableau Next license and be able to see the
> component.** Pre-authorization only clears `invalid_grant`; licensing and object/row security
> are what make the FrontDoor URL actually *render*. For a first verification pass, impersonating
> a System Administrator user (already pre-authorized in most test orgs, broadest access)
> removes the most variables — then narrow to a least-privilege user once it works.

### The flow

The **only** difference from the default flow is how the access token is obtained; the
FrontDoor mint (and everything the frontend does) is **identical**.

1. **Sign a JWT** (RS256) with these claims:
   - `iss` — the ECA **Consumer Key** (client_id).
   - `sub` — the **username to impersonate**, e.g. `user@yourorg.com`.
   - `aud` — the **login host**: `https://login.salesforce.com` (prod), `https://test.salesforce.com`
     (sandbox), or your **My Domain login host** — it must match the host you POST to.
   - `exp` — a short expiry, **≤ 5 minutes** from now (Unix seconds).
2. **Exchange it for an access token** — POST to `<LOGIN_URL>/services/oauth2/token` with just
   two params and **no client_secret**:
   ```
   grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
   assertion=<signed JWT>
   ```
   The response carries `access_token` and `instance_url`.
3. **Mint the FrontDoor URL exactly as before** — POST the access token to
   `<instance_url>/services/oauth2/singleaccess` and return `{ frontdoorUrl }` to the browser.
   From here on, the frontend is unchanged: it passes the FrontDoor URL to
   `initializeAnalyticsSdk`.

A runnable implementation of this is in [JWT Bearer broker (Node.js)](#jwt-bearer-broker-nodejs) —
the Node.js reference, verified end-to-end on a real org — with faithful ports for
[Java](#java-spring-boot--jwt-bearer), [Python](#python-fastapi--jwt-bearer),
[Go](#go-nethttp--jwt-bearer), [C#](#c-aspnet-core--jwt-bearer),
[Ruby](#ruby-sinatra--jwt-bearer), and [PHP](#php-built-in-server--jwt-bearer). The signing
library changes per language; the two Salesforce calls don't.

## Verify the happy path (30-second smoke test)

Before wiring up any frontend, confirm the backend actually works. Walk **up** this ladder —
each rung depends on the one below it, so the first one that fails tells you exactly where
the problem is. Run it against the broker you started (default **http://localhost:8500**).

**Rung 1 — the broker is up.** `curl` its health endpoint:

```bash
curl -sS http://localhost:8500/health
```

Expect `{"ok":true}`. If the connection is refused, the broker isn't running (or is on a
different port) — recheck the run command and that `.env` loaded (`echo "ORG_URL=$ORG_URL"`
should be non-blank).

**Rung 2 — OAuth completes.** In a **browser** (not curl — this step is interactive), open:

```
http://localhost:8500/login
```

You should be redirected to Salesforce, log in, and land back at your `APP_ORIGIN`
(`http://localhost:8700` by default). Landing on a Salesforce error page instead points at
the ECA — a bad callback URL (`redirect_uri` mismatch), a missing scope, or `invalid_client`
(the ECA requires a `CLIENT_SECRET` you haven't set — see the troubleshooting table).

> **JWT Bearer flow:** there is **no Salesforce login screen** — `/login` runs the exchange
> server-side and redirects straight to `APP_ORIGIN`, so a silent bounce to your SPA *is*
> success. If instead you get a `400` (`invalid_grant` — `sub` not pre-authorized, `aud`
> mismatch, clock skew, or cert/key mismatch) or a `502` (token host unreachable), the exchange
> failed — see the troubleshooting notes under [JWT Bearer broker](#jwt-bearer-broker-nodejs).

**Rung 3 — a FrontDoor URL mints.** In the **same browser** (so it sends the session cookie),
open:

```
http://localhost:8500/getFrontdoorURL
```

Expect JSON like `{"frontdoorUrl":"https://…","orgUrl":"https://…"}`. A `401 Not
authenticated` means Rung 2 didn't stick (session/cookie issue); a `403`/other error means
the access token can't mint FrontDoor (re-run `/login`, or the org withheld access).

**Rung 4 — the component renders.** Now start a frontend sample, point it at your component
id, and load it. Success = the dashboard/metric/viz appears **and** you see a
`COMPONENT_LOADED` event (the samples log it) with no `ERROR` event and no `frame-ancestors`
/ "refused to connect" message in the browser console. A framing error here is the
Trusted-Domains step ([Step 4](#backend-step-4-open-iframe-framing-trusted-domains-for-inline-frames)),
**not** an auth problem — Rungs 1–3 already proved auth works.

> If any rung fails, stop there and fix it before climbing higher — a failure two rungs down
> masquerades as a frontend bug if you skip straight to Rung 4.

## Backend & auth troubleshooting

Symptom-first table, then details.

| Symptom | Most likely cause | Fix |
|---------|-------------------|-----|
| **"refused to connect"** in the iframe; `frame-ancestors`/`X-Frame-Options` in console | App origin not allow-listed for framing | Add your origin under **Session Settings → Trusted Domains for Inline Frames**, IFrame Type **Lightning Out** (see [Step 4](#backend-step-4-open-iframe-framing-trusted-domains-for-inline-frames)). |
| **Stuck on "Loading…" forever, no visible "refused to connect"**, but `frame-ancestors` errors in the console | App origin not allow-listed — but the block hit the **hidden** init-phase auth iframe, so no visible message appears. `initializeAnalyticsSdk` **resolves with a `FAILURE` status; it does not throw**, so a bare `try/catch` around `await init(...)` catches nothing and your loading UI never advances | Same fix as above (Trusted Domains). Then make the failure *visible*: check `(await initializeAnalyticsSdk(...)).status === Status.SUCCESS`, and/or wire the global `analyticsEventTarget` `ERROR` listener into your loading state. Don't rely on `try/catch` alone. See [init returns a status, it doesn't reject](#initializeanalyticssdk-fails-by-status-not-by-throwing). |
| **`Refused to execute script … MIME type ('text/html')`** + a failed **`/secur/contentDoor`** request + `LO2:LightningOutError … unable to load the iframe` | The iframe couldn't establish the org session: `contentDoor` (the session handoff across the `my.salesforce.com` → `lightning.force.com` hosts) failed, so script URLs came back as an HTML login/error page instead of JS | Log in fresh at the broker's `/login` for a new single-use FrontDoor URL and reload **once** (a spent/reused FrontDoor OTP is a common cause — see the StrictMode gotcha). If it still fails on a **tunneled `*.wa.crm.dev` dev org**, the multi-subdomain handoff often isn't served through the proxy — point the sample at a normal `*.my.salesforce.com` org to confirm. |
| **Blank component**, no error | Wrong `orgUrl` (used `my.salesforce.com` instead of the Lightning host) | Use the **Lightning My Domain** URL, e.g. `https://yourorg.lightning.force.com`, for `orgUrl`. |
| **401 / token expired** after a while | Access token TTL elapsed (~2h) | All seven broker ports auto-refresh and retry once on 401/403 (see [Token lifetime](#token-lifetime)), so this should recover on its own. If it *still* 401s after the refresh, the refresh token was revoked or the org withheld the `refresh_token` scope — re-run `/login`. |
| **`invalid_client`** at token exchange | Org requires the client secret, broker has none (or vice-versa) | Set `CLIENT_SECRET` in the broker config, or clear it if your org is PKCE-only. See below. |
| **CORS error** in the browser console | Browser calling the org cross-origin | Add your origin under **Setup → CORS**, or keep org calls server-side in the broker. |
| **Stuck on "Authenticating…", init logs `SUCCESS`, nothing in the console** (Angular) | init resolved from a cross-iframe `postMessage` callback **outside Angular's zone**, so change detection never ran and `*ngIf` never flipped — the auth actually succeeded, the *view* is frozen | Wrap every post-SDK state mutation in `this.zone.run(() => …)` (inject `NgZone`). See [Angular](#angular) (note #3 on the zone boundary). React/Vue don't need this. |

**`orgUrl`: Lightning host vs my.salesforce.com.** The SDK expects `orgUrl` to be the
**Lightning My Domain** host (`https://yourorg.lightning.force.com`). This is what the
component validates against. There's a subtlety: the alternate FrontDoor endpoint
(`lightningoutsingleaccess`) internally converts the Lightning host **to**
`…my.salesforce.com` before calling Salesforce (`convertLightningToMySalesforce` in
`assets.js`). So `my.salesforce.com` does get used — but only for that server-side call,
never as the SDK's `orgUrl`. Don't set `orgUrl` to a `my.salesforce.com` host.

**Scope mismatch.** The broker's authorize request sends `scope=api refresh_token
lightning web`. Every one of those must be **granted by the ECA**. If you created the ECA
with the scripted payload (`packages/metadata-utils`), it grants
`RefreshToken,OfflineAccess,Lightning,Api` and **omits `web`** — add `web` in Setup.

**`invalid_client` at token exchange → set `CLIENT_SECRET`.** The reference ECA in this repo
requires the Consumer Secret; the broker appends it only when configured. Set `CLIENT_SECRET`
in the broker and retry. (If your org is genuinely PKCE-only, clear it instead — the full
flag analysis is in [Auth details verified against source](#auth-details-verified-against-source).)

**Hosted-org / older-core framing note.** The "add your origin to Trusted Domains for
Inline Frames" fix is verified. On some hosted orgs running older core versions, a
separate FrontDoor / My Domain redirect issue has been observed independently of framing
config. If framing is correctly configured and you still can't embed on a hosted org,
check whether your org has the relevant My Domain / redirect fixes; ship a graceful
fallback message rather than assuming the embed always succeeds.

**JWT vs opaque tokens.** If your org has **"Issue JWT-based access tokens"** enabled,
tokens come back as JWTs (`eyJ…`) instead of opaque IDs (`00D…`). Embedding via the
FrontDoor URL works with the standard flow above; the JWT-vs-opaque distinction matters
mainly for other integrations (e.g. an MCP gateway) sharing the same org. If you run both,
keep their token settings straight — don't flip that toggle just for embedding.

**"Stuck on Authenticating… but nothing in the console" — a decision tree.** When even the
global error listener is silent, work in this order (this is the sequence that actually
isolates it):

1. **Reveal hidden console levels first.** Chrome DevTools hides `Info`/`Verbose` behind an
   "N hidden" badge in the console filter — and the SDK's `LO2:` Lightning Out handshake logs
   live there. Turn those levels on before concluding "no logs."
2. **Instrument whether `init` actually resolves, and with what status:**
   ```js
   const res = await initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl });
   console.info('init resolved:', res.status); // SUCCESS? FAILURE? or never printed at all?
   ```
3. **Read the branch:**
   - **Never prints** → init hung (framing/session never completed) — see the timeout note below.
   - **Prints `FAILURE`** → auth/framing failure; fix Trusted Domains / FrontDoor URL (rows above).
   - **Prints `SUCCESS` but the DOM stays frozen** → *not* an auth problem. It's a view-layer /
     reactivity bug — on **Angular**, the zone boundary (wrap state in `this.zone.run`, see
     [Angular](#angular)). That last distinction is the whole game.

**Defensive init timeout.** `initializeAnalyticsSdk` resolves `FAILURE` on a *detected* failure,
but it can also legitimately **never resolve** (e.g. the framing handshake stalls) — leaving your
UI on "Authenticating…" with no error to catch. Race it against a timeout so a silent hang
becomes a visible message:

```js
const withTimeout = (p, ms = 30_000) =>
  Promise.race([p, new Promise((_, r) => setTimeout(() => r(new Error(`init timed out after ${ms}ms`)), ms))]);
const res = await withTimeout(initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl }));
```

> **Use exactly `.env` (lowercase).** dotenv loaders conventionally look for a lowercase
> `.env`; an uppercase `.ENV` can be silently ignored on case-sensitive setups. (Caution, not a
> confirmed failure here — but there's no upside to the uppercase spelling.)

---

# Phase 2 — Frontend: the universal 5-step sequence

**Every** framework follows the same core sequence. Frameworks differ only in *where*
these steps live (a `useEffect`, an `onMounted`, an `ngAfterViewInit`). Learn this once:

```js
import { initializeAnalyticsSdk, AnalyticsDashboard, EventName, Status } from '@salesforce/analytics-embedding-sdk';

// 1. Get the credential from YOUR token broker (never mint it in the browser)
const { frontdoorUrl } = await (
  await fetch('http://localhost:8500/getFrontdoorURL', { credentials: 'include' })
).json();

// 2. Initialize the SDK for the org. init RESOLVES with a status — it does NOT throw on an
//    auth/framing failure — so you MUST check it. This is the fatal, nothing-rendered path.
//    (Details: "init fails by status, not by throwing", below.)
const res = await initializeAnalyticsSdk({ orgUrl: 'https://yourorg.lightning.force.com', authCredential: frontdoorUrl });
if (res.status !== Status.SUCCESS) {
  throw new Error(`SDK init failed (${res.status}). Check Trusted Domains for Inline Frames.`);
}

// 3. Construct a component, pointing it at a container element
const dashboard = new AnalyticsDashboard({
  parentIdOrElement: containerEl,          // an HTMLElement or its id
  idOrApiName: 'Your_Dashboard_API_Name',  // The component's API name (from its URL or details panel), not a record ID
  orgUrl: 'https://yourorg.lightning.force.com',
});

// 4. Listen for errors. A component ERROR is often PARTIAL (a tile fails while the dashboard
//    renders) — warn, don't treat it as fatal.
dashboard.addEventListener(EventName.ERROR, (e) => console.warn(e.detail));

// 5. Render (async). render() rejects if ANY asset fails, even when the dashboard renders —
//    so this rejection is non-fatal too. Clear the container on teardown.
await dashboard.render().catch((e) => console.warn('render() rejected (may be partial):', e));
```

## Pick a component

| Component | Class | Distinctive props/methods |
|-----------|-------|---------------------------|
| Dashboard | `AnalyticsDashboard` | `pageName`, `getPages()`, `setPage()`, filters, export |
| Metric | `AnalyticsMetric` | `range`/`applyTimeRange()`, `layout`, filters, export |
| Visualization | `AnalyticsVisualization` | `getSelections()`, filters, export |
| AI Agent | `AnalyticsAgent` | `contextConfig`, `startNewAgentSession()`; **no filters/export** |

## SDK API reference

Everything the frontend imports from `@salesforce/analytics-embedding-sdk` (v2.0.0). All
identifiers below are verified against the SDK's exported types.

### Install

```bash
npm install @salesforce/analytics-embedding-sdk
```

Published on public npm with **zero runtime dependencies** (Lightning Out 2.0 is bundled).
Ships both **ESM** (`import`) and **UMD** (`<script>`, global `window.embeddedSDK`).

### Exports

```ts
import {
  // functions
  initializeAnalyticsSdk, retryOrAddOrgs, logout,
  // component classes (native custom elements, extend HTMLElement)
  AnalyticsDashboard, AnalyticsMetric, AnalyticsVisualization, AnalyticsAgent, AnalyticsFilter,
  // enums
  EventName, FilterOperator, Status, ExportFileType, AgentContextType, OrgStates,
  // global event bus
  analyticsEventTarget,
} from '@salesforce/analytics-embedding-sdk';

// types are type-only exports, e.g.:
import type {
  AnalyticsSdkConfig, OrgConfig, BootstrapResponse,
  DashboardProps, MetricProps, VisualizationProps, AgentProps,
  UnifiedFilter, SimplifiedFilter, Field, DataSource,
} from '@salesforce/analytics-embedding-sdk';
```

### Initialization: `initializeAnalyticsSdk(config)`

Returns a `Promise<BootstrapResponse>`. Call once before constructing components.

```ts
type AnalyticsSdkConfig = {
  // Single-org: provide both
  orgUrl?: string;          // Lightning My Domain URL, e.g. https://yourorg.lightning.force.com
  authCredential?: string;  // the FrontDoor URL from your token broker

  // Multi-org: provide this instead of the pair above
  orgConfigs?: OrgConfig[];

  debug?: boolean;          // default false
};

type OrgConfig = {
  orgUrl: string;           // required, Lightning URL
  authCredential: string;   // required, must be URL-shaped — use the FrontDoor URL
};
```

- **Single-org:** `await initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl })`
- **Multi-org:** `await initializeAnalyticsSdk({ orgConfigs: [{ orgUrl, authCredential }, ...] })`
- `orgUrl` **must** be the Lightning host (`*.lightning.force.com`), not `my.salesforce.com`.
- There is **no** `accessToken` config key. Auth goes through `authCredential`.

> **Use a FrontDoor URL — a bare access token is rejected.** The SDK's type doc
> (`OrgConfig.authCredential`) says "a Frontdoor URL *or* a generated access token," but
> the runtime contradicts the second half: before init, `validateOrgsInPlace`
> (`packages/sdk/src/utils/utils.ts:701-728`) runs `isValidUrl(authCredential)` — literally
> `new URL(value)` — and any org whose credential doesn't parse as a URL is marked
> `INVALID_AUTH_CREDENTIAL_URL` ("The authCredential must be a valid URL.") and dropped, so
> `initializeAnalyticsSdk` returns `CONFIGURATION_ERROR` and never renders
> (`bootstrap.ts:290-296`). A raw Salesforce token like `00Dxx...!AQ...` is not URL-shaped,
> so **it fails this gate**. The value must be URL-shaped; the FrontDoor URL is the correct
> one. (The Lightning Out consumption layer would accept a token as a `Bearer` value —
> `createAuthHeader`, `utils.ts:217` — but execution never reaches it. *Verified against SDK
> source; not yet re-tested on a live render.*)

#### initializeAnalyticsSdk fails by status, not by throwing

**A `try/catch` around `await initializeAnalyticsSdk(...)` is not enough to detect an init
failure.** On auth, config, or framing failures the SDK does **not** reject the promise — it
**resolves** with a `BootstrapResponse` whose `status` is a failure value (e.g.
`CONFIGURATION_ERROR`, or `FAILURE` after the org's Lightning Out init errors out), and it
dispatches an `ERROR` event on the global `analyticsEventTarget`. (Verified against SDK
source: `bootstrap.ts` has no throw on these paths; it returns the response and fires
`INITIALIZATION_FAILED`.) So a `catch` block only ever catches your *own* code — like a
broker `fetch` that threw — never the SDK's failure.

Two consequences to design for:

- **Check the status** — don't infer success from the `await` merely completing:

  ```ts
  import { initializeAnalyticsSdk, Status } from '@salesforce/analytics-embedding-sdk';

  const res = await initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl });
  if (res.status !== Status.SUCCESS) {
    // surface an error to the user; do NOT clear the loading state as if it worked
  }
  ```

  For a **single org** (the samples), status is only ever `SUCCESS` or `FAILURE`, so
  `!== SUCCESS` is exactly right. If you adapt this to **multi-org** (`orgConfigs: [...]`),
  a `PARTIAL_SUCCESS` means *some* orgs came up — don't treat that as a total failure; branch
  on it and inspect `res.orgStates` for the per-org outcome. Full pattern in
  [Multi-org → reading the result](#reading-the-result-partial_success-and-orgstates).

- **Also wire the global error listener** (see [Events](#events) — the `analyticsEventTarget`
  global bus), because some failures surface only as an `ERROR` event, and a framing block against the
  **hidden** init-phase auth iframe produces *no* visible "refused to connect" — the UI would
  otherwise sit on your loading copy indefinitely. Drive your loading state off both signals,
  not off the bare `await`.

### Component base props (`ComponentProps`)

All four components share these:

| Prop | Type | Notes |
|------|------|-------|
| `idOrApiName` | `string` | **Required.** The component's developer **API name** (e.g. `Knowledge_Analytics_Dash`), not a record ID. Read it from the component's URL in the Tableau Next app (for a dashboard, `.../tableau/dashboard/<apiName>/view`, i.e. the path segment before `/view`) or from its details panel. A record ID also works if you have one. |
| `parentIdOrElement` | `string \| HTMLElement` | Container element (or its id) to render into. |
| `orgUrl` | `string` | Required in multi-org; specifies which org this component is from. |
| `height` / `width` | `string` | CSS size — `px`, `%`, `rem`, `em`, etc. (invalid values default to `100%`). Samples size via CSS on the container instead; either works. |
| `filters` | `UnifiedFilter[]` | Initial filters (not on Agent). |

### The four components

**`AnalyticsDashboard` — `DashboardProps`.** Adds: `pageName?`, `hideHeader?`,
`customViewId?`. Methods: `render()`, `reload()`, `getPages()`,
`setPage(pageLabel?)`, `getDataSources()`, `getFields(dataSources?)`, `applyFilters()`,
`getFilters()`, `clearFilters()`, `getMetadata()`, `getInteractionDetails()`, `export()`.
(There is **no** public `destroy()` method — it's not in the SDK's `index.d.ts`. To tear a
component down, drop your reference and empty its container with `replaceChildren()`; see
[Component lifecycle & events](#component-lifecycle--events).)

**`AnalyticsMetric` — `MetricProps`.** Adds: `layout?` (`MetricLayoutAttributes`),
`range?` (`MetricTimeRange`), `borderRadius?`. Metric-only methods: `applyLayout(layout)`,
`getLayout()`, `applyTimeRange(range)`, `getTimeRange()`, `clearTimeRange()`. Plus the
shared filter/export methods. (No `getMetadata()` — Metric implements `Exportable` and
`FilterSimplification` but not `MetadataProvider`; only Dashboard and Visualization have it.)

**`AnalyticsVisualization` — `VisualizationProps`.** Just the base props. Viz-only method:
`getSelections()`. Plus shared filter/export/metadata.

**`AnalyticsAgent` — `AgentProps`.** `AgentProps = Omit<ComponentProps, 'filters'>` —
**no `filters`, no `export()`.** Adds: `contextConfig?`, `showHeader?`,
`showHeaderActions?`, `agentName?`, `welcomeText?`, `styleTokens?`. Method:
`startNewAgentSession()`.
- **Single-context mode:** provide `contextConfig: { contextType, contextTypeIdOrApiName }`
  where `contextType` is an `AgentContextType` (`DASHBOARD` | `METRIC` | `SDM`).
- **Multi-component mode:** omit `contextConfig`; the agent auto-tracks all dashboards and
  metrics on the page.

### Enums

- **`EventName`** — `COMPONENT_LOADED = 'ComponentLoaded'`, `ERROR = 'Error'`, plus
  `UPDATED_FILTERS`, `UPDATED_TIME_RANGE`, `UPDATED_SELECTIONS`, `RECEIVED_*`.
- **`ExportFileType`** — `PNG` (the only supported export type). The SDK's shipped `index.d.ts`
  emits this enum with an empty body, so the exact string value isn't in the types; pass the
  `ExportFileType.PNG` member rather than a literal.
- **`AgentContextType`** — `DASHBOARD`, `METRIC`, `SDM`.
- **`OrgStates`** — `READY_FOR_INIT`, `INITIALIZATION_SUCCESS`, `INITIALIZATION_ERROR`,
  `AUTH_REDIRECT`, `LOGGED_OUT`, `LOGGED_OUT_FAILURE`.
- **`FilterOperator`**, **`Status`** — see the bundled `index.d.ts` for full member lists.

### Filters

Two shapes, both accepted by `applyFilters(filters)`:

```ts
// UnifiedFilter — fieldName is an "Object.Field" string
{ fieldName: 'Account.Account_Name', operator: FilterOperator.Equals,
  values: ['Acme Corp'], dataSource: 'SalesData' }

// SimplifiedFilter — pass a Field object from getFields(); fieldName is built for you
{ field: { apiName: 'Name', objectName: 'Account', dataType: 'text', fieldType: 'Dimension' },
  operator: FilterOperator.Equals, values: 'Acme Corp' /* single value auto-wrapped */,
  dataSource: { apiName: 'SalesData' } /* optional for single-source dashboards */ }
```

### Export

`await component.export()` (dashboard/metric/viz only). Only `ExportFileType.PNG` is
currently supported. Agent does not support export.

## Component lifecycle & events

How a component goes from construction to teardown, and how to listen for load/error.
This is the same across all frameworks — only the *place* you put each call differs.

```
new AnalyticsDashboard(props)   →   addEventListener   →   await render()   →   replaceChildren()
       (construct)                    (wire events)          (async mount)      (drop ref + empty container)
```

1. **Construct** — `const c = new AnalyticsDashboard({ parentIdOrElement, idOrApiName, orgUrl })`.
   The component is a native custom element; constructing it does not render it yet.
2. **Wire events** — attach `ERROR` (and optionally `COMPONENT_LOADED`) listeners *before*
   `render()` so you don't miss early events.
3. **Render** — `await c.render()`. This mounts the Lightning Out iframe and loads the
   component. It's async — await it (or attach `.then`/`.catch`). **`render()` rejects if
   *any* asset fails to load, even when the component itself renders fine** (the bundled
   `index.d.ts` says it "rejects with an error message if the loading fails"). A dashboard
   whose header and one tile load but whose second tile 404s will still reject — so a
   `render()` rejection (and likewise an `ERROR` event) is often a *partial* failure, not a
   dead embed. Log it; don't tear down the working iframe or paint the whole page as broken.
   Reserve the fatal path for **init** failure ([status ≠ `SUCCESS`](#initializeanalyticssdk-fails-by-status-not-by-throwing)),
   which means nothing rendered at all.
4. **Reload** — `await c.reload()` re-renders in place without reconstructing.
5. **Teardown** — on unmount, drop your reference and empty the container element with
   `container.replaceChildren()` (the cleanup pattern below). There is **no** `destroy()` /
   `disconnectedCallback()` to call — emptying the container removes the SDK-rendered iframe,
   and dropping the reference lets the instance be garbage-collected.

### Events

Two ways to listen — on the **instance** (scoped to one component) and on the **global
bus** (SDK-level and init failures):

```js
import { EventName, analyticsEventTarget } from '@salesforce/analytics-embedding-sdk';

// Per-component — often a PARTIAL error (one tile failed while the rest rendered), so warn;
// don't treat it as fatal or tear the embed down.
dashboard.addEventListener(EventName.ERROR, (ev) => {
  console.warn('Component error (may be partial):', ev.detail?.message);
});
dashboard.addEventListener(EventName.COMPONENT_LOADED, () => {
  console.log('Loaded and ready');
});

// Global (SDK init failures, cross-component errors) — this bus carries the fatal
// nothing-rendered init failures, so error-level logging is appropriate here.
analyticsEventTarget.addEventListener(EventName.ERROR, (ev) => {
  console.error('SDK error:', ev.detail);
});
```

Events are `CustomEvent`s — read `ev.detail`. Key names (from the `EventName` enum):
- `COMPONENT_LOADED` (`'ComponentLoaded'`) — component finished loading.
- `ERROR` (`'Error'`) — component or SDK error.
- Interaction events: `UPDATED_FILTERS`, `UPDATED_TIME_RANGE`, `UPDATED_SELECTIONS`, and the
  `RECEIVED_*` responses to `get*()` calls.

### The canonical mount+cleanup pattern (framework-neutral)

```js
let component;
const container = /* your container element */;

async function mount() {
  const { frontdoorUrl } = await (
    await fetch('http://localhost:8500/getFrontdoorURL', { credentials: 'include' })
  ).json();
  // init resolves with a status; it does NOT throw on auth/framing failure — check it (this
  // is the fatal, nothing-rendered path). See "init fails by status, not by throwing".
  const res = await initializeAnalyticsSdk({ orgUrl: ORG_URL, authCredential: frontdoorUrl });
  if (res.status !== Status.SUCCESS) {
    showError(`SDK init failed (${res.status}).`);
    return;
  }

  component = new AnalyticsDashboard({
    parentIdOrElement: container,
    idOrApiName: DASHBOARD_ID,
    orgUrl: ORG_URL,
  });
  // Component ERROR events are often PARTIAL — warn, don't tear the embed down (see notes).
  component.addEventListener(EventName.ERROR, (e) => console.warn(e.detail));
  await component.render().catch((e) => console.warn('render() rejected (may be partial):', e));
}

function unmount() {
  // Remove the SDK-rendered iframe. replaceChildren() is the safe, modern way to empty a
  // node (the repo's React wrapper uses `container.innerHTML = ''` for the same effect).
  container?.replaceChildren();
  component = undefined;
}
```

Each framework maps `mount()` → (`useEffect` / `onMounted` / `ngAfterViewInit`) and
`unmount()` → (cleanup return / `onBeforeUnmount` / `ngOnDestroy`).

**Notes from the reference implementation:**
- The repo's React wrapper waits ~100ms before init to let a previous cleanup finish, and
  guards against re-entrancy with an `isCancelled` flag — useful if your component's props
  can change and re-trigger mount (see [React](#react)).
- `render()` can reject; always handle the error path (surface it via your `ERROR` handler
  or a try/catch around `render()`). Treat that rejection as **non-fatal** — it fires when
  any single asset fails even though the rest rendered — so `console.warn` it and leave the
  embed up; don't confuse it with an **init** failure (which is the fatal, nothing-rendered
  case). The samples' `onError`/`.catch` deliberately warn rather than tear down.

---

# Framework guides

Read the one for your framework, then copy the matching runnable project from
[Runnable samples](#runnable-samples) (or from [`samples/`](samples/) on disk).

## Vanilla JavaScript

No build step. Two ways to load the SDK; pick one.

**Option A — ESM from a CDN (recommended):**

```html
<div id="dash" style="width:100%;height:600px"></div>
<script type="module">
  import {
    initializeAnalyticsSdk,
    AnalyticsDashboard,
    EventName,
    Status,
  } from 'https://esm.sh/@salesforce/analytics-embedding-sdk@2.0.0';

  // 1. Credential from YOUR broker. Never mint it here.
  const resp = await fetch('http://localhost:8500/getFrontdoorURL', { credentials: 'include' });
  const { frontdoorUrl, orgUrl } = await resp.json();

  // 2. Init. It resolves with a status; it does NOT throw on auth/framing failure — so check
  //    the status, or a framing block leaves you stuck with a blank iframe and no error.
  const res = await initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl });
  if (res.status !== Status.SUCCESS) {
    throw new Error(`SDK init failed (${res.status}). Check Trusted Domains for Inline Frames.`);
  }

  // 3. Construct + 4. wire events + 5. render
  const dash = new AnalyticsDashboard({
    parentIdOrElement: document.getElementById('dash'),
    idOrApiName: 'Your_Dashboard_API_Name', // The component's API name (from its URL or details panel), not a record ID
    orgUrl,
  });
  // A component ERROR is often PARTIAL (a tile fails while the dashboard renders) — log it.
  dash.addEventListener(EventName.ERROR, (e) => console.warn(e.detail));
  // render() rejects if ANY asset fails, even when the dashboard renders — non-fatal.
  await dash.render().catch((e) => console.warn('render() rejected (may be partial):', e));
</script>
```

**Option B — UMD via a plain `<script>` tag.** The published bundle exposes a global
`window.embeddedSDK` (the UMD library name is `embeddedSDK`). Use this when you can't use
ES modules.

```html
<script src="https://esm.sh/@salesforce/analytics-embedding-sdk@2.0.0/dist/sdk-bundle.cjs"></script>
<script>
  const { initializeAnalyticsSdk, AnalyticsDashboard, EventName } = window.embeddedSDK;
  // ...same 5 steps as above
</script>
```

> The UMD global is `embeddedSDK` (confirmed in the SDK's `webpack.config.js` `library`
> field). The exact CDN path to the `.cjs` file may vary by CDN — with esm.sh the ESM entry
> (Option A) is the simplest and least surprising.

**Lifecycle mapping:** mount → run the 5 steps on page load (or a button click); unmount →
if you remove the component, empty its container: `document.getElementById('dash').replaceChildren();`.
Nothing framework-specific to configure — the container just needs a width and height.

## TypeScript

The SDK ships its own type declarations (`index.d.ts`), so imports are fully typed with no
`@types` package. Same 5 steps as vanilla JS, but you get autocomplete and compile-time
checks on the config and component props.

In `tsconfig.json` use a modern module resolution so the SDK's `exports` map (ESM + types)
resolves:

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"]
  }
}
```

```ts
import {
  initializeAnalyticsSdk,
  AnalyticsDashboard,
  EventName,
  Status,
  type AnalyticsSdkConfig,
  type DashboardProps,
} from '@salesforce/analytics-embedding-sdk';

const ORG_URL = 'https://yourorg.lightning.force.com';

async function main(container: HTMLElement) {
  const { frontdoorUrl, orgUrl } = await (
    await fetch('http://localhost:8500/getFrontdoorURL', { credentials: 'include' })
  ).json();

  // init resolves with a status; it does NOT throw on auth/framing failure — so check the
  // status. Skipping this is the silent-hang footgun described above.
  const config: AnalyticsSdkConfig = { orgUrl, authCredential: frontdoorUrl };
  const res = await initializeAnalyticsSdk(config);
  if (res.status !== Status.SUCCESS) {
    throw new Error(`SDK init failed (${res.status}). Check Trusted Domains for Inline Frames.`);
  }

  const props: DashboardProps = {
    parentIdOrElement: container,
    idOrApiName: 'Your_Dashboard_API_Name', // The component's API name (from its URL or details panel), not a record ID
    orgUrl,
  };
  const dash = new AnalyticsDashboard(props);
  // A component ERROR is often PARTIAL (a tile fails while the dashboard renders) — log it,
  // don't tear the embed down.
  dash.addEventListener(EventName.ERROR, (e) => console.warn((e as CustomEvent).detail));
  // render() rejects if ANY asset fails, even when the dashboard itself renders — non-fatal.
  await dash.render().catch((e) => console.warn('render() rejected (may be partial):', e));
}
```

**Typing the events.** Instance/global events are `CustomEvent`s. The payload is on
`.detail`; cast when you read it:

```ts
dash.addEventListener(EventName.UPDATED_FILTERS, (e) => {
  const detail = (e as CustomEvent<{ filters: unknown[] }>).detail;
});
```

**Lifecycle mapping:** same as vanilla JS — call the 5 steps when you mount, and
`container.replaceChildren()` when you tear down.

## React

Wrap the imperative SDK in a small component. React owns the container `<div>`; the SDK
renders its iframe inside it. Do the SDK work in `useEffect` (after the DOM node exists)
and clean up on unmount.

```tsx
import { useEffect, useRef } from 'react';
import { AnalyticsDashboard, EventName } from '@salesforce/analytics-embedding-sdk';

export function DashboardEmbed({ id, orgUrl }: { id: string; orgUrl: string }) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    // Assumes initializeAnalyticsSdk() already ran once, higher up.
    let isCancelled = false;
    let instance: AnalyticsDashboard | undefined;

    const timer = setTimeout(async () => {
      if (isCancelled || !containerRef.current) return;
      instance = new AnalyticsDashboard({
        parentIdOrElement: containerRef.current,
        idOrApiName: id,
        orgUrl,
      });
      // Component ERROR is often PARTIAL (a tile fails while the dashboard renders) — warn,
      // don't tear the embed down. (The full sample forwards this to a parent onError.)
      instance.addEventListener(EventName.ERROR, (e) =>
        console.warn((e as CustomEvent).detail),
      );
      // render() rejects if ANY asset fails, even when the dashboard renders — non-fatal.
      await instance.render().catch((e) => console.warn('render() rejected (may be partial):', e));
    }, 100); // small delay lets a previous cleanup finish (see note)

    return () => {
      isCancelled = true;
      clearTimeout(timer);
      containerRef.current?.replaceChildren(); // remove the SDK iframe
    };
  }, [id, orgUrl]);

  return <div ref={containerRef} style={{ width: '100%', height: 600 }} />;
}
```

**Two React-specific gotchas:**

1. **StrictMode double-invokes effects in dev — guard *both* effects.** React 18 mounts →
   unmounts → remounts every effect once in development. This bites in two places, and each
   needs its own guard:
   - **The child iframe mount** (`AnalyticsEmbed`): if it isn't re-entrancy-safe you get two
     iframes or a torn-down one. The `isCancelled` flag + `setTimeout(…, 100)` + cleanup make
     it safe.
   - **The top-level init effect** (`App.tsx`): the double-invoke fires the broker `fetch` +
     `initializeAnalyticsSdk` **twice, back-to-back**. Because the FrontDoor URL is
     **single-use**, the second call can init with a spent credential and fail (this is the
     "two `/getFrontdoorURL` calls" you'd see in the dev Network tab). The `App.tsx` sample
     guards it with a **`useRef` sentinel set *before* the awaits** — the ref survives
     StrictMode's remount, so the second invocation returns immediately and the init runs once.

     > **Use the ref sentinel *alone* here — do NOT also add a `cancelled` flag.** The two
     > child-effect guard styles don't compose in the App effect. A `cancelled` flag whose
     > cleanup sets `cancelled = true` works in `AnalyticsEmbed` because *nothing blocks its
     > remount* — the second run re-does the mount. But once the `didInit` ref blocks the
     > App effect's remount, there is no second run; the flag then only ever fires on the one
     > legitimate run's cleanup and makes the code discard its own successful init — the app
     > hangs on "Authenticating…" forever. Apply the init result directly (no `if (!cancelled)`
     > gate). This is a bug that already shipped once — don't reintroduce it.

   With both effects guarded you can **keep StrictMode on** (the sample does). The SDK repo's
   own multiorg playground instead *disables* StrictMode — either approach works; being
   re-entrancy-safe is the more portable one. (Note: `initializeAnalyticsSdk` only no-ops a
   repeat call when the config is **unchanged** and the first init has already completed; a
   *different* FrontDoor URL triggers a full re-bootstrap — so "just call it twice, it's
   idempotent" is not safe to rely on. Guard the effect.)
2. **`initializeAnalyticsSdk` is global, not per-component.** Call it once (e.g. in a
   top-level effect or a context provider) after fetching the frontdoor URL, then render
   any number of component wrappers. The sample does init in `App.tsx` and passes `orgUrl`
   down. **Keep component callbacks (`onError`/`onReady`) out of the child effect's
   dependency array** (or memoize them) — fresh inline callbacks on every parent render
   would otherwise tear down and re-mount the iframe.

**Lifecycle mapping:** mount → `useEffect(() => { … }, [deps])`; unmount / deps change →
the effect's returned cleanup function.

## Vue 3

Composition API. Vue owns the container `<div>` via a template `ref`; the SDK renders its
iframe inside it. Do the SDK work in `onMounted` (the node exists by then) and clean up in
`onBeforeUnmount`.

```vue
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { AnalyticsDashboard, EventName } from '@salesforce/analytics-embedding-sdk';

const props = defineProps<{ idOrApiName: string; orgUrl: string }>();
const container = ref<HTMLDivElement | null>(null);
let instance: AnalyticsDashboard | undefined;

onMounted(async () => {
  // Assumes initializeAnalyticsSdk() already ran once (see App.vue).
  instance = new AnalyticsDashboard({
    parentIdOrElement: container.value as HTMLElement,
    idOrApiName: props.idOrApiName,
    orgUrl: props.orgUrl,
  });
  // Component ERROR is often PARTIAL (a tile fails while the dashboard renders) — warn,
  // don't tear the embed down. (The full sample forwards this to a parent via emit('error').)
  instance.addEventListener(EventName.ERROR, (e) =>
    console.warn((e as CustomEvent).detail),
  );
  // render() rejects if ANY asset fails, even when the dashboard renders — non-fatal.
  await instance.render().catch((e) => console.warn('render() rejected (may be partial):', e));
});

onBeforeUnmount(() => {
  container.value?.replaceChildren(); // remove the SDK iframe
  instance = undefined;
});
</script>

<template>
  <div ref="container" style="width: 100%; height: 600px" />
</template>
```

**If you use the SDK's custom-element tags directly.** This guide uses the **imperative**
API (`new AnalyticsDashboard(...)`), which needs no special config. The SDK also registers
native custom-element tags (`<analytics-dashboard>`, etc.). If you'd rather write those
tags in a template, tell Vue's compiler not to treat them as Vue components, or it will
warn about unknown elements:

```ts
// vite.config.ts
import vue from '@vitejs/plugin-vue';

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.startsWith('analytics-'),
        },
      },
    }),
  ],
};
```

**Lifecycle mapping:** mount → `onMounted`; unmount → `onBeforeUnmount`. Call
`initializeAnalyticsSdk` once (the sample does it in `App.vue`'s `onMounted` after fetching
the frontdoor URL), then render any number of wrappers. This wrapper is **mount-once** — if
your `idOrApiName`/`orgUrl` props can change at runtime, add a `watch` that re-mounts.

> **Vue has no StrictMode double-invoke, but Vite HMR still re-runs `onMounted`.** Vue
> doesn't intentionally double-mount effects the way React 18 dev does, so `App.vue` won't
> hang on a clean load. But saving a file during `npm run dev` re-fires `onMounted`, and the
> FrontDoor URL is **single-use** — a second `initializeAnalyticsSdk` with a spent credential
> fails. The `App.vue` sample guards its init with a module-level `didInit` flag so an
> edit-save doesn't burn a second FrontDoor URL. And like React, it **checks
> `res.status === Status.SUCCESS`** — init resolves-with-status rather than throwing, so a
> bare `try/catch` around `await init(...)` would leave you stuck on "Authenticating…"
> silently on a framing failure. (Angular's `ngOnInit` isn't double-invoked and does the
> same status check; the single-use-URL trap is milder there than in React/Vite-HMR.)
>
> **Caveat on the module-level `didInit`/guard:** a module-level flag is set once and never
> reset — it's scoped to a single-mount demo where `<App>` mounts one org's SDK for the page's
> lifetime. If you genuinely unmount and re-mount `<App>` (real SPA route changes, not an HMR
> save), init won't re-run. For that case, move the guard to instance state and re-run init on
> re-mount, or gate on the SDK's own state (`initializeAnalyticsSdk` no-ops an unchanged repeat
> once the first completed). The same applies to Angular's guard. (React's `App.tsx` uses a
> `useRef` sentinel, which is per-instance and *does* reset on a true remount.)

## Angular

Angular 18 standalone components (no NgModule). Angular owns the container `<div>` via
`@ViewChild`; the SDK renders its iframe inside it. Do the SDK work in `ngAfterViewInit`
(the view exists by then) and clean up in `ngOnDestroy`.

```ts
import {
  AfterViewInit,
  Component,
  ElementRef,
  Input,
  OnDestroy,
  ViewChild,
} from '@angular/core';
import { AnalyticsDashboard, EventName } from '@salesforce/analytics-embedding-sdk';

@Component({
  selector: 'dashboard-embed',
  standalone: true,
  template: '<div #container style="width:100%;height:600px"></div>',
})
export class DashboardEmbedComponent implements AfterViewInit, OnDestroy {
  @Input({ required: true }) id!: string;
  @Input({ required: true }) orgUrl!: string;
  @ViewChild('container', { static: true }) container!: ElementRef<HTMLDivElement>;

  private instance?: AnalyticsDashboard;

  async ngAfterViewInit(): Promise<void> {
    // Assumes initializeAnalyticsSdk() already ran once (see AppComponent).
    this.instance = new AnalyticsDashboard({
      parentIdOrElement: this.container.nativeElement,
      idOrApiName: this.id,
      orgUrl: this.orgUrl,
    });
    // Component ERROR is often PARTIAL (a tile fails while the dashboard renders) — warn,
    // don't tear the embed down. (The full sample forwards this to a parent via @Output.)
    this.instance.addEventListener(EventName.ERROR, (e) =>
      console.warn((e as CustomEvent).detail),
    );
    // render() rejects if ANY asset fails, even when the dashboard renders — non-fatal.
    await this.instance.render().catch((e) => console.warn('render() rejected (may be partial):', e));
  }

  ngOnDestroy(): void {
    this.container?.nativeElement.replaceChildren(); // remove the SDK iframe
    this.instance = undefined;
  }
}
```

**Three Angular-specific notes:**

1. **`CUSTOM_ELEMENTS_SCHEMA` — only if you use the SDK's tags in a template.** This guide
   uses the imperative API, so you don't need it. But if you put `<analytics-dashboard>`
   (or any SDK custom-element tag) directly in an Angular template, add the schema or
   Angular throws "not a known element":
   ```ts
   import { CUSTOM_ELEMENTS_SCHEMA, Component } from '@angular/core';
   @Component({ /* ... */, schemas: [CUSTOM_ELEMENTS_SCHEMA] })
   ```
2. **`@ViewChild(..., { static: true })`** resolves the container before
   `ngAfterViewInit`, which is what you want here because the container is *not* behind a
   structural directive (`*ngIf`) inside this component. If your container is conditionally
   rendered, use `{ static: false }` and read it in `ngAfterViewInit`.
3. **Re-enter Angular's zone after any SDK callback — this bug is silent and Angular-only.**
   The SDK resolves `initializeAnalyticsSdk` (and fires its component events) from a
   **cross-iframe `postMessage` handler that zone.js doesn't patch**, so those continuations
   run *outside* Angular's zone. Mutate component state there — `this.orgUrl = …` — and change
   detection never fires: init reports `Status.SUCCESS`, no error appears anywhere, but the
   view stays frozen on "Authenticating…" and the embed never mounts. Wrap every post-SDK
   state change in `this.zone.run(() => …)` (inject `NgZone`). This is why the sample's
   `AppComponent` and the wrapper's `@Output` emits are zoned — see the full sample below.
   React (`setState`) and Vue (`ref`) re-render independent of zone.js, so **only Angular**
   needs this, which is exactly why it's easy to miss when the other frameworks work.

**Lifecycle mapping:** mount → `ngAfterViewInit`; unmount → `ngOnDestroy`. Call
`initializeAnalyticsSdk` once (the sample does it in `AppComponent.ngOnInit` after fetching
the frontdoor URL), then render any number of embed components. This wrapper is
**mount-once** — if `@Input()`s can change at runtime, implement `ngOnChanges` to re-mount.

---

# Multi-org

Embed components from **more than one org** on the same page. This is a first-class SDK
feature — one `initializeAnalyticsSdk` call boots every org, and each component picks its org
via the `orgUrl` prop. Everything below is verified against the SDK's `index.d.ts` and the
repo's reference app (`packages/playground/sdk_multiorg_react`).

## The shape

`AnalyticsSdkConfig` is a union: single-org is `{ orgUrl, authCredential }`; multi-org is
`{ orgConfigs: OrgConfig[] }`, where each entry is the same `{ orgUrl, authCredential }` pair.
You pass the **array** instead of the single pair:

```ts
import { initializeAnalyticsSdk, Status } from '@salesforce/analytics-embedding-sdk';

const res = await initializeAnalyticsSdk({
  orgConfigs: [
    { orgUrl: 'https://orgA.lightning.force.com', authCredential: frontdoorA },
    { orgUrl: 'https://orgB.lightning.force.com', authCredential: frontdoorB },
  ],
  debug: true,
});
```

## The one rule that shapes everything: one credential per org

There is **no shared credential across orgs.** Each org needs its **own** login and its
**own** FrontDoor URL, minted from *that org's* access token. The reference app does exactly
this: N independent OAuth round-trips (one per org, token stored keyed by org name), then N
FrontDoor mints — run under `Promise.allSettled` so one org failing to mint doesn't sink the
rest (`packages/playground/sdk_multiorg_react/src/App.tsx:1276-1304`). Only after all mints
resolve does it build `orgConfigs` and call init once.

This is the single structural consequence for your token broker: the single-org broker holds
**one** `{accessToken, instanceUrl}` in the session; a multi-org broker holds a **map keyed
by org**, and `/getFrontdoorURL` takes an org identifier. See
[Multi-org broker variant](#multi-org-broker-variant) below — it's the same OAuth flow
repeated per org, not a rewrite.

## Which org a component renders from

Each component's **`orgUrl` prop** selects which initialized org it draws from — it's
required in multi-org (optional/ignored single-org). The prop flows straight into the
component constructor
(`packages/playground/sdk_multiorg_react/src/components/Dashboard.tsx:133`):

```ts
new AnalyticsDashboard({
  parentIdOrElement: containerA,
  idOrApiName: DASHBOARD_ID,
  orgUrl: 'https://orgA.lightning.force.com', // ← routes this card to org A
});
```

So one page can show org A's dashboard next to org B's metric — each component is pinned to
its org by `orgUrl`.

## Reading the result: `PARTIAL_SUCCESS` and `orgStates`

Multi-org is where the third `Status` value matters. Init can resolve as:

- **`SUCCESS`** — every org came up.
- **`PARTIAL_SUCCESS`** — *some* orgs came up, some didn't (**multi-org only**; a single org
  is only ever `SUCCESS`/`FAILURE`). Do **not** treat this as a total failure — render the
  orgs that succeeded and surface only the ones that didn't.
- **`FAILURE`** — none came up.

The per-org outcome is in `res.orgStates` — a `Map<string, OrgStateInfo>` **keyed by
Lightning URL**. Each `OrgStateInfo` is `{ state, reason?, redirectUrl?, redirectOrigin? }`
where `state` is an `OrgStates` enum value (`INITIALIZATION_SUCCESS`, `INITIALIZATION_ERROR`,
`AUTH_REDIRECT`, …). Branch on the overall status, then inspect per org:

```ts
if (res.status === Status.FAILURE) {
  // nothing initialized — show a fatal error
} else {
  // SUCCESS or PARTIAL_SUCCESS: render the orgs that are up, report the rest
  res.orgStates?.forEach((info, orgUrl) => {
    if (info.state !== 'INITIALIZATION_SUCCESS') {
      console.warn(`Org ${orgUrl} did not initialize: ${info.state} — ${info.reason ?? ''}`);
    }
  });
}
```

Note `orgStates` is a real `Map` — iterate with `.forEach`/`for…of`, and if you serialize it
for logging, convert with `Object.fromEntries(map)` (a plain `JSON.stringify` yields `{}`).

## Per-org auth redirects (MFA / password reset)

When an org needs an interactive step (MFA registration, password reset), its `state` is
`AUTH_REDIRECT` and `OrgStateInfo` carries a `redirectUrl` (relative) plus `redirectOrigin`.
Open `` `${redirectOrigin}${redirectUrl}` `` in a popup, let the user finish, then call
`retryOrAddOrgs()` for that org to resume. The other orgs stay up the whole time — this is
the payoff of `PARTIAL_SUCCESS`.

## Adding, retrying, and dropping orgs after init

- **`retryOrAddOrgs(orgConfigs: OrgConfig[]): Promise<BootstrapResponse>`** — add a brand-new
  org to a live session, or retry one that failed / needed an auth redirect. Same
  `{ orgUrl, authCredential }` entries, same response shape (`status` + `orgStates`) as init.
  Mint a fresh FrontDoor URL for the org first, exactly as you did at init.
  ```ts
  const r = await retryOrAddOrgs([{ orgUrl: 'https://orgC.lightning.force.com', authCredential: frontdoorC }]);
  ```
- **`logout(orgIdentifiers?: string[]): Promise<LogoutResponse>`** — pass an array of org URLs
  to drop specific orgs; **omit the argument to log out of all**. Invalid URLs are silently
  ignored. Returns the same `status` + `orgStates` shape.
  ```ts
  await logout(['https://orgA.lightning.force.com']); // drop just org A
  await logout();                                      // drop everything
  ```

Both are already in the [Exports](#exports) list.

## Multi-org broker variant

The [broker contract](#the-broker-contract-language-neutral) is single-org: one session holds
one `{accessToken, instanceUrl}`, and `/getFrontdoorURL` mints from it. Multi-org needs the
same flow **keyed by org**. The delta from the single-org contract:

- **Session shape.** Instead of one token set, store a map:
  `session.orgs[<orgId>] = { accessToken, instanceUrl }`. `<orgId>` is a stable key you
  choose per org (e.g. the org's name or Lightning host).
- **`GET /login?org=<orgId>`.** Same PKCE start, but stash `orgId` alongside the verifier so
  the callback knows which org it's completing. Run it once per org (the reference app opens
  the OAuth popup once per org the user picks).
- **`GET /getAccessToken?code=…`.** On success, write the token set into
  `session.orgs[<orgId>]` (from the stashed `orgId`) rather than a single slot.
- **`GET /getFrontdoorURL?org=<orgId>`.** Look up `session.orgs[<orgId>]`; `401` if that org
  isn't authenticated. Mint from *that org's* access token via
  `<instanceUrl>/services/oauth2/singleaccess` (unchanged). Return
  `{ "frontdoorUrl": …, "orgUrl": <that org's ORG_URL> }`.
- **`GET /orgs`** *(new, optional but handy)* → `200` `["orgA", "orgB", …]`: which orgs the
  session is currently authenticated for, so the SPA knows how many FrontDoor URLs to fetch.

The SDK side is unchanged — you still just hand `initializeAnalyticsSdk` the `orgConfigs`
array. Everything multi-org-specific lives in *sourcing N credentials*, not in the SDK call.

A minimal Node/Express delta over the [single-org reference broker](#nodejs-express):

```js
// Session now holds a map of orgs instead of a single token set.
// session.orgs = { [orgId]: { accessToken, instanceUrl, orgUrl } }

app.get('/login', (req, res) => {
  const orgId = String(req.query.org || '').trim();
  if (!orgId) return res.status(400).json({ error: 'Missing ?org=<orgId>' });
  const { verifier, challenge } = makePkce();          // same PKCE helper as single-org
  req.session.pending = { orgId, verifier };           // remember which org we're logging into
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'api refresh_token lightning web',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state: orgId,                                        // echoes back on the callback
  });
  res.redirect(`${LOGIN_URL}/services/oauth2/authorize?${params}`);
});

app.get('/getAccessToken', async (req, res) => {
  const { code, error } = req.query;
  if (error) return res.status(400).send(String(error));
  const pending = req.session.pending;
  if (!code || !pending?.verifier) return res.status(400).send('Missing code or verifier');
  const token = await exchangeCodeForToken(String(code), pending.verifier); // same as single-org
  req.session.orgs = req.session.orgs || {};
  req.session.orgs[pending.orgId] = {
    accessToken: token.access_token,
    instanceUrl: token.instance_url,
    orgUrl: ORG_URL_FOR[pending.orgId],                 // your per-org Lightning URL map
  };
  delete req.session.pending;
  res.redirect(APP_ORIGIN);
});

app.get('/getFrontdoorURL', async (req, res) => {
  const orgId = String(req.query.org || '').trim();
  const entry = req.session.orgs?.[orgId];
  if (!entry) return res.status(401).json({ error: `Org "${orgId}" not authenticated. Visit /login?org=${orgId}` });
  const frontdoorUrl = await mintFrontdoor(entry.instanceUrl, entry.accessToken); // same singleaccess POST
  res.json({ frontdoorUrl, orgUrl: entry.orgUrl });
});

app.get('/orgs', (req, res) => res.json(Object.keys(req.session.orgs || {})));
```

Frontend: fetch one FrontDoor URL per org, then init once:

```ts
const orgIds = await (await fetch(`${BROKER}/orgs`, { credentials: 'include' })).json();
const orgConfigs = await Promise.all(
  orgIds.map(async (org: string) => {
    const { frontdoorUrl, orgUrl } = await (
      await fetch(`${BROKER}/getFrontdoorURL?org=${encodeURIComponent(org)}`, { credentials: 'include' })
    ).json();
    return { orgUrl, authCredential: frontdoorUrl };
  }),
);
const res = await initializeAnalyticsSdk({ orgConfigs });
// then branch on res.status / res.orgStates exactly as above
```

# Auth details verified against source

These three were open questions; they've since been settled by reading the SDK source and
the reference ECA payload in this repo.

1. **`authCredential` must be a FrontDoor URL, not a bare access token.** The SDK's pre-init
   validation requires it to parse as a URL (`isValidUrl` → `new URL()` in
   `validateOrgsInPlace`, `packages/sdk/src/utils/utils.ts:701-728`); a raw token like
   `00Dxx...!AQ...` is rejected with `INVALID_AUTH_CREDENTIAL_URL` and never renders. The
   type doc's "or a generated access token" is contradicted by this gate. (Full detail in
   the callout under [`initializeAnalyticsSdk`](#initialization-initializeanalyticssdkconfig).)
2. **The reference ECA requires the Consumer Secret** at token exchange, so plan on setting
   `CLIENT_SECRET` — an `invalid_client` error without one confirms it. The provisioned
   payload (`packages/metadata-utils/src/payloads/ExternalClientApp.json:21-25`) sets
   `isSecretRequiredForTokenExchange: true` and `isPkceRequired: false`.
   `isSecretRequiredForTokenExchange` governs the Authorization Code flow's code→token step,
   so as provisioned this ECA expects the secret at the token endpoint. PKCE is a *separate*
   protection from client authentication: `isPkceRequired: false` means PKCE is permitted but
   not required, and neither adding PKCE nor that flag relaxes the secret requirement — **PKCE
   alone does not make this a no-secret client.** (The payload also sets
   `isConsumerSecretOptional: true`, a broader/legacy toggle; the more specific
   `isSecretRequiredForTokenExchange` is what controls the code-flow token step. The exact
   precedence between the two is Salesforce platform behavior, not provable from this repo.)
   To run genuinely PKCE-only with no secret, provision the ECA with
   `isSecretRequiredForTokenExchange: false` and clear `CLIENT_SECRET`; **verify on your org**
   by minting a token both ways.
3. **`/services/oauth2/singleaccess`** is the default and correct path (used by the broker);
   **`/services/oauth2/lightningoutsingleaccess?lightning_out_app_id=<id>`** is only for a
   configured Lightning Out App. Both confirmed present in the repo.

**Still needs a live browser render to be 100% settled** (source is conclusive, a render is
belt-and-suspenders): (a) that a URL-shaped-but-non-FrontDoor credential is actually
accepted end-to-end; (b) the exact precedence when an ECA sets both
`isSecretRequiredForTokenExchange: true` and `isConsumerSecretOptional: true`. Neither
blocks setup on the documented path.

---

# The runnable token broker

A small server that runs the Salesforce OAuth flow and mints a **FrontDoor URL** for the
SDK. Every framework sample calls it. The browser never sees the client secret or the raw
access token — only the short-lived FrontDoor URL.

It's provided in **seven languages**, each a full runnable project on disk under
[`token-broker/`](token-broker/) and inlined below. Pick the one that matches your stack —
they all implement the identical **broker contract** defined next, so the frontend samples
work unchanged regardless of which broker you run.

| Language | Framework | On disk | Run |
|---|---|---|---|
| Node.js | Express | [`token-broker/`](token-broker/) | `npm install && npm start` |
| Java | Spring Boot 3 | [`token-broker/java/`](token-broker/java/) | `mvn spring-boot:run` |
| Python | FastAPI | [`token-broker/python/`](token-broker/python/) | `uvicorn main:app --port 8500` |
| Go | net/http | [`token-broker/go/`](token-broker/go/) | `go run .` |
| C# | ASP.NET Core 8 | [`token-broker/dotnet/`](token-broker/dotnet/) | `dotnet run` |
| Ruby | Sinatra | [`token-broker/ruby/`](token-broker/ruby/) | `ruby app.rb` |
| PHP | built-in server | [`token-broker/php/`](token-broker/php/) | `php -S localhost:8500 broker.php` |

> **Before the `Run` command, load your config into the environment** — every broker reads
> `LOGIN_URL` / `ORG_URL` / `CLIENT_ID` (etc.) from the environment and fails fast without
> them. After filling `.env`, run `set -a; source .env; set +a` (or, for Node,
> `node --env-file=.env server.js`) *first*. The per-language "Run it" blocks below show this.

> **Verification status.** The **Node.js** broker is distilled from the repo's production
> `authRouter.ts` and is execution-verified. The other six are correct-by-construction ports
> of the same contract, checked as far as each toolchain allows offline: **Python**
> compiles clean, **Go** builds + `go vet` clean, **Ruby** and **PHP** pass their syntax
> linters; **Java** and **C#** are static-reviewed (no offline build toolchain here). None of
> the six has been run end-to-end against a live org. Treat them as *reviewed, not run*:
> use the [30-second smoke test](#verify-the-happy-path-30-second-smoke-test) before relying
> on one.

## The broker contract (language-neutral)

This is the single source of truth. Every implementation below satisfies exactly this, and
if you write your own broker in a language not listed, satisfy this and the SDK samples will
work unchanged. It's a tiny OAuth 2.0 **Authorization Code + PKCE** confidential-client
server with four GET endpoints.

### Configuration (environment variables)

The same names and defaults across every language. Startup **must fail fast** if any
*required* variable is missing.

| Variable | Required | Default | Purpose |
|---|---|---|---|
| `PORT` | no | `8500` | Port the broker listens on. |
| `LOGIN_URL` | **yes** | — | OAuth login host (My Domain login URL, or `https://login.salesforce.com`). |
| `ORG_URL` | **yes** | — | Lightning My Domain host; returned to the browser as `orgUrl` and used by the SDK. |
| `CLIENT_ID` | **yes** | — | ECA Consumer Key. |
| `CLIENT_SECRET` | no | — | ECA Consumer Secret. Appended to the token exchange **only if set** (confidential client). The reference ECA requires it. **Never sent to the browser.** |
| `REDIRECT_URI` | no | `http://localhost:<PORT>/getAccessToken` | Must exactly match a Callback URL on the ECA. |
| `APP_ORIGIN` | no | `http://localhost:8700` | The SPA's origin. Used for CORS **and** as the post-login redirect target. |
| `SESSION_SECRET` | no | `change-me-in-production` | Session-cookie signing secret. Set a strong value in production. |

### Endpoints

All four are `GET`. Paths are exact — the frontend samples hard-code `/getFrontdoorURL`.

**`GET /login`** — start the flow.
1. Generate a PKCE `code_verifier` (high-entropy, ~64 random bytes, base64url) and
   `code_challenge = base64url(SHA256(verifier))`.
2. Generate a **random per-session `state`** (~32 random bytes, base64url) for CSRF protection.
3. Store the verifier **and the `state`** in the **server-side session**.
4. `302` redirect to:
   ```
   <LOGIN_URL>/services/oauth2/authorize
     ?response_type=code
     &client_id=<CLIENT_ID>
     &redirect_uri=<REDIRECT_URI>
     &scope=api refresh_token lightning web      (URL-encoded)
     &code_challenge=<challenge>
     &code_challenge_method=S256
     &state=<random per-session value>
   ```
   > **`state` is a random per-session value, validated on callback.** All seven ports generate
   > a fresh `state` here, stash it in the session next to the PKCE verifier, and reject the
   > `/getAccessToken` callback if the returned `state` doesn't match (then clear it — it's
   > single-use). PKCE defends against code interception; `state` is the separate CSRF /
   > login-mix-up defense, so the brokers implement both.

**`GET /getAccessToken?code=…&state=…`** — OAuth callback (this URL must match the ECA's Callback URL).
1. If `?error` is present → `400` with the error description.
2. Validate `state` against the value stored at `/login`; if missing or mismatched → `400`
   `Invalid or missing OAuth state`. Clear the stored `state` (single-use).
3. Read the verifier from the session; if `code` or verifier is missing → `400`.
3. `POST` (form-encoded) to `<LOGIN_URL>/services/oauth2/token` with:
   `grant_type=authorization_code`, `client_id`, `redirect_uri`, `code`, `code_verifier`,
   and `client_secret` **only if `CLIENT_SECRET` is set**.
4. On success, store `access_token` + `instance_url` **and `refresh_token`** (if the response
   includes one) in the session; `302` redirect to `APP_ORIGIN`. On failure, propagate a
   non-2xx with a short message. The `refresh_token` is what powers the 401/403 auto-refresh in
   `/getFrontdoorURL` (see [Token lifetime](#token-lifetime)); omit it only if you don't
   implement refresh.

**`GET /getFrontdoorURL`** — the SPA calls this (credentialed fetch).
1. If there's no `access_token`/`instance_url` in the session → `401`
   `{"error": "Not authenticated. Visit /login first."}`.
2. `POST` to `<instance_url>/services/oauth2/singleaccess` with headers
   `Authorization: Bearer <access_token>`, `Accept: application/json`,
   `Content-Type: application/x-www-form-urlencoded`, and an **empty body**.
3. **If `singleaccess` returns 401/403** (access token expired) and a `refresh_token` is in the
   session, `POST grant_type=refresh_token` to `<LOGIN_URL>/services/oauth2/token`, store the
   new `access_token`, and retry step 2 **once**. (This is the auto-refresh; **all seven ports
   implement it** — see [Token lifetime](#token-lifetime).)
4. Return `200` JSON: `{"frontdoorUrl": <response.frontdoor_uri>, "orgUrl": <ORG_URL>}`.
   Propagate non-2xx.

**`GET /health`** → `200` `{"ok": true}`.

### CORS

The SPA (`APP_ORIGIN`, e.g. `:8700`) is a different origin than the broker (`:8500`), so its
credentialed fetch to `/getFrontdoorURL` needs these on every response:

- `Access-Control-Allow-Origin: <APP_ORIGIN>` — the **exact** origin, never `*` when
  credentials are used.
- `Access-Control-Allow-Credentials: true`
- `Access-Control-Allow-Headers: Content-Type`
- `Vary: Origin`
- Answer `OPTIONS` preflight with `204`.

### Session & cookie

A server-side session holds `codeVerifier`, `accessToken`, `instanceUrl`, and `refreshToken`
(the last stored at callback and used for the 401/403 auto-refresh above). The session
cookie must be **`HttpOnly`** and **`SameSite=Lax`**. On localhost, `:8700 → :8500` is
same-*site*, so the Lax cookie is still sent; CORS only governs whether the browser may
*read* the response. (Cross-*site* production needs `SameSite=None; Secure` over HTTPS.)

### Security invariants (non-negotiable)

- `CLIENT_SECRET` and the raw `access_token` **never** leave the server. The browser only
  ever receives the FrontDoor URL.
- PKCE uses **`S256`**, never `plain`.
- The OAuth `state` is a **random per-session** value, stored server-side and **validated on
  the callback** (single-use). PKCE + `state` are separate defenses — both are implemented.
- OAuth-error responses on `/getAccessToken` are served as **`text/plain`**, so a reflected
  `error_description` can't execute as HTML.
- Do not log secrets or tokens.

> **If you deploy this beyond localhost, add two more.** These are deliberately left out of the
> localhost reference (they'd add framework-specific machinery without changing the demo), but
> they matter once the broker is internet-facing — apply them in **every** port you ship, not
> just one, so the ports stay behavior-identical:
> - **Rotate the session id after authentication** (e.g. Express `req.session.regenerate`,
>   PHP `session_regenerate_id`, Spring `changeSessionId`) to close session-fixation. The random
>   `state` above already blocks the login-CSRF path; rotation hardens the fixation variant.
> - **Serve over HTTPS with `SameSite=None; Secure` cookies**, and back the in-memory session
>   store with a shared store (Redis, a DB) so sessions survive restarts and work across workers.
>
> One intentional cross-port difference to know about: on a **transport-level** failure reaching
> Salesforce (DNS/connect/timeout), the Java port returns **502** while the other six return
> **500**; and a non-GET request to a known path returns **405** on the Go/Java/.NET ports vs
> **404** on Node/PHP/Ruby. Both are outside the documented GET-only, happy-path contract and
> don't affect the SPA.

---

## Node.js (Express)

> Execution-verified reference implementation, distilled from the repo's production
> `authRouter.ts`. The six ports that follow mirror it exactly.

**Run it.** From `token-broker/`: install, copy `.env.example` to `.env` and fill in your
org + ECA values, load `.env` into the environment, then start. The broker listens on
**http://localhost:8500**.

```bash
cd token-broker
npm install
cp .env.example .env
set -a; source .env; set +a
npm start
```

> **Loading `.env` (applies to every port below).** The broker reads its config from the
> environment, so you must load `.env` **before** starting it — the start command alone does
> **not** read the file. `set -a; source .env; set +a` exports every line for the launched
> process. The `source` path is **relative to your current directory**, so it points at
> wherever `.env` actually lives: it's `.env` from `token-broker/`, `../.env` from a port
> subdirectory like `token-broker/java/`, `../../.env` if your broker sits a level deeper.
> After sourcing, confirm it worked with `echo "ORG_URL=$ORG_URL"` — a blank value means the
> path is wrong. If a secret contains shell-special characters, keep it quoted in `.env`.
> (Node-only shortcut: `node --env-file=.env server.js`, supported natively on Node 20.6+.)
>
> **zsh footgun — keep pasted commands comment-free.** Don't paste a command line with a
> trailing `# comment`. The default macOS interactive shell (zsh, without
> `interactive_comments` set) does **not** strip `#…` — it passes it on as a literal
> argument, which breaks the command (e.g. `go run . # …` fails with a bogus-path error).
> Every run-block below is comment-free for exactly this reason; explanatory text sits in
> prose above the block instead.

Then open **http://localhost:8500/login** once to authenticate. After login you're
redirected back, and your SPA can call `GET /getFrontdoorURL` to get
`{ frontdoorUrl, orgUrl }`.

**`token-broker/package.json`:**

```json
{
  "name": "tableau-next-token-broker",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "Server-side OAuth token broker that mints a Salesforce FrontDoor URL for the Analytics Embedding SDK",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "engines": {
    "node": ">=18"
  },
  "dependencies": {
    "express": "^4.19.2",
    "express-session": "^1.18.0"
  }
}
```

**`token-broker/.env.example`** (copy to `.env`, never commit the real one):

```properties
# Copy to .env and fill in. Never commit the real .env.

# Port the broker listens on (defaults to 8500)
PORT=8500

# Your org's OAuth login host (My Domain login URL or login.salesforce.com)
LOGIN_URL=https://yourorg.my.salesforce.com

# Your org's Lightning My Domain host — this is the SDK `orgUrl`
ORG_URL=https://yourorg.lightning.force.com

# ECA Consumer Key
CLIENT_ID=your_consumer_key_here

# ECA Consumer Secret. The reference ECA in this repo requires it
# (isSecretRequiredForTokenExchange: true), so set it. Leave blank ONLY if your ECA was
# provisioned PKCE-only. An invalid_client error on token exchange means it's required.
CLIENT_SECRET=

# Must EXACTLY match a Callback URL on the ECA (this is the broker's own callback, port 8500)
REDIRECT_URI=http://localhost:8500/getAccessToken

# Your SPA's origin. Two jobs: (1) where the browser is sent after login, and (2) the exact
# origin the broker allows via CORS for /getFrontdoorURL. The samples all run on port 8700,
# so this must match wherever you serve the frontend.
APP_ORIGIN=http://localhost:8700

# Session cookie signing secret — set a strong random value in production
SESSION_SECRET=change-me-in-production
```

**`token-broker/server.js`:**

```js
/**
 * Tableau Next embedding — server-side token broker.
 *
 * Runs the OAuth Authorization Code + PKCE flow and mints a FrontDoor URL that the
 * frontend passes to the SDK as `authCredential`. Keep this on the server: it holds the
 * client secret (if any) and the raw access token; the browser only ever sees the
 * short-lived FrontDoor URL.
 *
 * Distilled from packages/playground/sdk_test/routers/authRouter.ts.
 *
 * Endpoints:
 *   GET  /login            → starts OAuth (redirects to Salesforce)
 *   GET  /getAccessToken   → OAuth callback; exchanges code → access token (stored in session)
 *   GET  /getFrontdoorURL  → exchanges access token → { frontdoorUrl }  (call this from the SPA)
 *   GET  /health           → { ok: true }
 */
import express from 'express';
import session from 'express-session';
import crypto from 'node:crypto';

// ---- Config (from environment) -------------------------------------------------------
const {
  PORT = 8500,
  // Your org's login host, e.g. https://yourorg.my.salesforce.com or https://login.salesforce.com
  LOGIN_URL,
  // Your org's Lightning My Domain host, e.g. https://yourorg.lightning.force.com
  ORG_URL,
  // ECA Consumer Key
  CLIENT_ID,
  // ECA Consumer Secret. The reference ECA requires it (isSecretRequiredForTokenExchange: true);
  // set it. Leave unset only for an ECA provisioned PKCE-only. invalid_client => it's required.
  CLIENT_SECRET,
  // Must exactly match a Callback URL registered on the ECA.
  REDIRECT_URI = `http://localhost:${PORT}/getAccessToken`,
  // Where to send the user back after a successful login (the SPA's origin, port 8700 —
  // NOT the broker's PORT). Also the exact origin allowed for the credentialed CORS fetch.
  APP_ORIGIN = 'http://localhost:8700',
  SESSION_SECRET = 'change-me-in-production',
} = process.env;

for (const [k, v] of Object.entries({ LOGIN_URL, ORG_URL, CLIENT_ID })) {
  if (!v) {
    console.error(`Missing required env var: ${k}. See .env.example.`);
    process.exit(1);
  }
}

// ---- PKCE helpers --------------------------------------------------------------------
const base64url = (buf) =>
  buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

function generatePkce() {
  const verifier = base64url(crypto.randomBytes(64));
  const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
  return { verifier, challenge };
}

// ---- App -----------------------------------------------------------------------------
const app = express();

// CORS: the SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this
// broker (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these
// headers or the browser drops the response. Allow-Origin must name the exact origin (not '*')
// when credentials are involved. On localhost, :8700 and :8500 are the same *site*, so the
// SameSite=Lax session cookie is still sent — CORS just permits reading the response.
// (Dependency-free; for many origins or production, use the `cors` package + HTTPS + SameSite=None.)
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', APP_ORIGIN);
  res.header('Access-Control-Allow-Credentials', 'true');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  res.header('Vary', 'Origin');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

app.use(
  session({
    secret: SESSION_SECRET,
    resave: false,
    saveUninitialized: true,
    cookie: { httpOnly: true, sameSite: 'lax' },
  }),
);

// 1) Kick off OAuth with PKCE.
app.get('/login', (req, res) => {
  const { verifier, challenge } = generatePkce();
  req.session.codeVerifier = verifier;
  // Random per-session `state` for CSRF / login-mix-up protection: it's echoed back on the
  // callback and must match. PKCE defends against code interception; `state` is the separate
  // login-CSRF defense — you want both. (A fixed constant here would leave the callback forgeable.)
  const state = base64url(crypto.randomBytes(32));
  req.session.oauthState = state;

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'api refresh_token lightning web',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
  });
  res.redirect(`${LOGIN_URL}/services/oauth2/authorize?${params}`);
});

// 2) OAuth callback → exchange the code for an access token.
app.get('/getAccessToken', async (req, res) => {
  const { code, state, error, error_description } = req.query;
  if (error) return res.type('text/plain').status(400).send(`OAuth error: ${error_description || error}`);
  // Reject the callback unless `state` matches the value we stored at /login (CSRF defense).
  const expectedState = req.session.oauthState;
  if (!state || !expectedState || state !== expectedState) {
    return res.type('text/plain').status(400).send('Invalid or missing OAuth state');
  }
  delete req.session.oauthState; // single-use
  const codeVerifier = req.session.codeVerifier;
  if (!code || !codeVerifier) return res.type('text/plain').status(400).send('Missing code or PKCE verifier');

  const params = new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code: String(code),
    code_verifier: String(codeVerifier),
  });
  // Sent when configured. The reference ECA requires it; a PKCE-only ECA omits it.
  if (CLIENT_SECRET) params.append('client_secret', CLIENT_SECRET);

  try {
    const resp = await fetch(`${LOGIN_URL}/services/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: params.toString(),
    });
    const data = await resp.json();
    if (!resp.ok || data.error) {
      return res
        .type('text/plain')
        .status(resp.status || 500)
        .send(`Token exchange failed: ${data.error_description || data.error || 'unknown'}`);
    }
    req.session.accessToken = data.access_token;
    req.session.instanceUrl = data.instance_url;
    // Stored so we can silently re-mint after the ~2h access-token TTL elapses. Present only
    // if the ECA granted the `refresh_token` scope (it's requested in /login); if the org
    // withholds it, refreshAccessToken() is a no-op and the user re-runs /login by hand.
    req.session.refreshToken = data.refresh_token;
    res.redirect(APP_ORIGIN); // back to the SPA; it will call /getFrontdoorURL
  } catch (e) {
    res.type('text/plain').status(500).send(`Token exchange error: ${e.message}`);
  }
});

// Exchange the stored refresh token for a fresh access token. Returns true on success and
// updates the session in place; false if there's no refresh token or the refresh fails
// (e.g. the org withheld the scope, or the refresh token itself was revoked/expired).
async function refreshAccessToken(req) {
  const { refreshToken } = req.session;
  if (!refreshToken) return false;
  const params = new URLSearchParams({
    grant_type: 'refresh_token',
    client_id: CLIENT_ID,
    refresh_token: refreshToken,
  });
  if (CLIENT_SECRET) params.append('client_secret', CLIENT_SECRET);
  try {
    const resp = await fetch(`${LOGIN_URL}/services/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: params.toString(),
    });
    const data = await resp.json();
    if (!resp.ok || data.error || !data.access_token) return false;
    req.session.accessToken = data.access_token;
    // A refresh_token grant returns instance_url; keep it in sync. It does NOT return a new
    // refresh_token, so leave the stored one as-is.
    if (data.instance_url) req.session.instanceUrl = data.instance_url;
    return true;
  } catch {
    return false;
  }
}

// POST the stored access token to the singleaccess endpoint to mint a FrontDoor URL.
async function mintFrontdoor(instanceUrl, accessToken) {
  return fetch(`${instanceUrl}/services/oauth2/singleaccess`, {
    method: 'POST',
    headers: {
      accept: 'application/json',
      authorization: `Bearer ${accessToken}`,
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams(),
  });
}

// 3) Exchange the stored access token for a FrontDoor URL. The SPA fetches this.
app.get('/getFrontdoorURL', async (req, res) => {
  if (!req.session.accessToken || !req.session.instanceUrl) {
    return res.status(401).json({ error: 'Not authenticated. Visit /login first.' });
  }
  try {
    let resp = await mintFrontdoor(req.session.instanceUrl, req.session.accessToken);
    // The access token expires (~2h on sandbox). On 401/403, refresh once and retry so the
    // SPA keeps working without bouncing the user back through /login.
    if ((resp.status === 401 || resp.status === 403) && (await refreshAccessToken(req))) {
      resp = await mintFrontdoor(req.session.instanceUrl, req.session.accessToken);
    }
    if (!resp.ok) {
      // Still failing after the refresh attempt — the session is stale; the SPA should /login.
      // Surface Salesforce's own reason: the body carries {"error","error_description"} that
      // distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
      // it's safe to forward; truncate defensively.
      const body = (await resp.text().catch(() => '')).trim();
      const detail = body ? `: ${body.length > 500 ? `${body.slice(0, 500)}…` : body}` : '';
      return res.status(resp.status).json({
        error: `Salesforce responded ${resp.status}${detail}. Re-authenticate at /login.`,
      });
    }
    const data = await resp.json();
    res.json({ frontdoorUrl: data.frontdoor_uri, orgUrl: ORG_URL });
  } catch (e) {
    res.status(500).json({ error: e.message || 'Failed to generate FrontDoor URL' });
  }
});

app.get('/health', (_req, res) => res.json({ ok: true }));

app.listen(PORT, () => {
  console.log(`Token broker on http://localhost:${PORT}`);
  console.log(`  Log in:  http://localhost:${PORT}/login`);
});
```

**Production notes:** cross-*site* cookies need `SameSite=None; Secure` over HTTPS; use
durable session storage and a real `SESSION_SECRET`; for many origins swap the hand-rolled
CORS header for the `cors` package.

---

## JWT Bearer broker (Node.js)

> **Alternative flow — verified end-to-end on a real org.** This is the server-to-server
> [JWT Bearer flow](#auth-alternative-the-jwt-bearer-flow), not the interactive Auth Code + PKCE
> flow the seven brokers above use. It has been run against a live org (token minted via JWT →
> FrontDoor URL → dashboard rendered); the one caveat is that it's **not part of the official
> SDK guide**, which documents Auth Code + PKCE. This Node.js broker is the verified reference;
> faithful ports follow it in [Java](#java-spring-boot--jwt-bearer),
> [Python](#python-fastapi--jwt-bearer), [Go](#go-nethttp--jwt-bearer),
> [C#](#c-aspnet-core--jwt-bearer), [Ruby](#ruby-sinatra--jwt-bearer), and
> [PHP](#php-built-in-server--jwt-bearer) — only the JWT-signing library changes per language; the
> two Salesforce calls are identical. For any other language, port the same way.

Before using this, complete the one-time
[certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time): generate `private.key` + `public.crt`,
upload `public.crt` to the ECA, and enable the JWT Bearer flow on it. This broker signs with
the private key — **there is no `CLIENT_SECRET`** in this flow.

**`token-broker-jwt/package.json`:**

```json
{
  "name": "tableau-next-token-broker-jwt",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "description": "Server-side OAuth JWT Bearer broker that mints a Salesforce FrontDoor URL for the Analytics Embedding SDK",
  "scripts": {
    "start": "node server.js",
    "dev": "node --watch server.js"
  },
  "engines": {
    "node": ">=18"
  },
  "dependencies": {
    "express": "^4.19.2",
    "express-session": "^1.18.0",
    "jsonwebtoken": "^9.0.2"
  }
}
```

**`token-broker-jwt/.env.example`** (copy to `.env`, never commit the real one; and never
commit `private.key`):

```properties
# Copy to .env and fill in. Never commit the real .env or the private key.

# Port the broker listens on (defaults to 8500)
PORT=8500

# The org's OAuth login host — this is BOTH where we POST the token request and the JWT `aud`.
# Prod: https://login.salesforce.com  |  Sandbox: https://test.salesforce.com  |  or your My Domain login host.
LOGIN_URL=https://login.salesforce.com

# Your org's Lightning My Domain host — this is the SDK `orgUrl`
ORG_URL=https://yourorg.lightning.force.com

# ECA Consumer Key — becomes the JWT `iss` claim
CLIENT_ID=your_consumer_key_here

# The Salesforce username to impersonate — becomes the JWT `sub` claim. This user must be
# pre-authorized for the ECA and hold a Tableau+/Tableau Next license.
SF_USERNAME=user@yourorg.com

# Path to the RSA private key whose public cert is uploaded to the ECA. Keep it server-side.
# (Alternatively set PRIVATE_KEY to the PEM contents directly, with \n for newlines.)
PRIVATE_KEY_PATH=./private.key

# Your SPA's origin — where the browser is sent and the CORS-allowed origin for /getFrontdoorURL.
APP_ORIGIN=http://localhost:8700

# Session cookie signing secret — set a strong random value in production
SESSION_SECRET=change-me-in-production
```

**`token-broker-jwt/server.js`:**

```js
/**
 * Tableau Next embedding — server-side token broker (JWT Bearer flow).
 *
 * ALTERNATIVE to the Authorization Code + PKCE broker (server.js). Instead of an interactive
 * login, the server signs a JWT with its private key and exchanges it for an access token,
 * impersonating SF_USERNAME. It then mints the SAME FrontDoor URL the SDK consumes.
 *
 * Keep this on the server: it holds the private key and the raw access token; the browser
 * only ever sees the short-lived FrontDoor URL. There is NO client secret in this flow —
 * the RS256 signature authenticates the client.
 *
 * Endpoints:
 *   GET  /login            → runs the JWT Bearer exchange, stores the session, redirects to the SPA
 *   GET  /getFrontdoorURL  → mints { frontdoorUrl, orgUrl } from the stored token (call this from the SPA)
 *   GET  /health           → { ok: true }
 */
import express from 'express';
import session from 'express-session';
import fs from 'node:fs';
import jwt from 'jsonwebtoken';

const {
  PORT = 8500,
  LOGIN_URL,
  ORG_URL,
  CLIENT_ID,
  SF_USERNAME,
  PRIVATE_KEY,
  PRIVATE_KEY_PATH = './private.key',
  APP_ORIGIN = 'http://localhost:8700',
  SESSION_SECRET = 'change-me-in-production',
} = process.env;

for (const [k, v] of Object.entries({ LOGIN_URL, ORG_URL, CLIENT_ID, SF_USERNAME })) {
  if (!v) {
    console.error(`Missing required env var: ${k}. See .env.example.`);
    process.exit(1);
  }
}

// Read the signing key once, from PRIVATE_KEY (PEM inline, \n-escaped) or PRIVATE_KEY_PATH.
// Fail fast with a clear message if it's missing — otherwise fs throws a raw ENOENT stack.
// Note: PRIVATE_KEY_PATH is resolved relative to the CURRENT WORKING DIRECTORY, not this file —
// run the broker from token-broker-jwt/, or set an absolute path. The key must be UNENCRYPTED
// (the openssl -nodes command in the cert setup produces one); an encrypted key needs a passphrase.
let privateKey;
try {
  privateKey = PRIVATE_KEY
    ? PRIVATE_KEY.replace(/\\n/g, '\n')
    : fs.readFileSync(PRIVATE_KEY_PATH, 'utf8');
} catch {
  console.error(
    `Cannot read private key at ${PRIVATE_KEY_PATH}. Copy private.key into this directory ` +
      `(or set PRIVATE_KEY_PATH to an absolute path). See the certificate & ECA setup section.`,
  );
  process.exit(1);
}

const app = express();

app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', APP_ORIGIN);
  res.header('Access-Control-Allow-Credentials', 'true');
  res.header('Access-Control-Allow-Headers', 'Content-Type');
  res.header('Vary', 'Origin');
  if (req.method === 'OPTIONS') return res.sendStatus(204);
  next();
});

app.use(
  session({
    secret: SESSION_SECRET,
    resave: false,
    saveUninitialized: true,
    cookie: { httpOnly: true, sameSite: 'lax' },
  }),
);

// Sign the JWT assertion. Claims: iss=client_id, sub=user to impersonate, aud=login host,
// exp≤5min. jsonwebtoken sets iat and the RS256 header; we set expiresIn to bound exp.
function createAssertion() {
  return jwt.sign({ iss: CLIENT_ID, sub: SF_USERNAME, aud: LOGIN_URL }, privateKey, {
    algorithm: 'RS256',
    expiresIn: '3m',
  });
}

// Exchange the signed JWT for an access token. No client_secret — the signature is the auth.
// On a Salesforce-side rejection (e.g. invalid_grant) we throw an error carrying the upstream
// HTTP status so callers can forward it — a config error is NOT a 5xx (see /login).
async function getAccessTokenViaJwt() {
  const params = new URLSearchParams({
    grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    assertion: createAssertion(),
  });
  const resp = await fetch(`${LOGIN_URL}/services/oauth2/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: params.toString(),
  });
  const data = await resp.json().catch(() => ({}));
  if (!resp.ok || !data.access_token) {
    // Salesforce returns {error, error_description}; forward it (no secret/token in the body).
    const reason = data.error_description || data.error || `HTTP ${resp.status}`;
    const err = new Error(`JWT token exchange failed: ${reason}`);
    err.upstreamStatus = resp.status; // e.g. 400 for invalid_grant — an expected config error
    throw err;
  }
  return { accessToken: data.access_token, instanceUrl: data.instance_url };
}

// POST the access token to singleaccess to mint a FrontDoor URL (identical to the PKCE broker).
async function mintFrontdoor(instanceUrl, accessToken) {
  return fetch(`${instanceUrl}/services/oauth2/singleaccess`, {
    method: 'POST',
    headers: {
      accept: 'application/json',
      authorization: `Bearer ${accessToken}`,
      'content-type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams(),
  });
}

// 1) Run the JWT Bearer exchange and stash the token server-side, then bounce to the SPA.
// There's no interactive redirect to Salesforce — the exchange happens entirely server-side.
app.get('/login', async (req, res) => {
  try {
    const { accessToken, instanceUrl } = await getAccessTokenViaJwt();
    req.session.accessToken = accessToken;
    req.session.instanceUrl = instanceUrl;
    res.redirect(APP_ORIGIN);
  } catch (e) {
    // A Salesforce rejection (invalid_grant etc.) is an expected CONFIG error — forward its
    // status (a 4xx). Reserve 502 for genuine transport faults (no upstreamStatus = fetch failed).
    const status = e.upstreamStatus || 502;
    res.type('text/plain').status(status).send(e.message || 'JWT authentication failed');
  }
});

// 2) Mint a FrontDoor URL from the stored token. The SPA fetches this.
// On 401/403 (token expired), just re-run the exchange — a JWT Bearer client can silently
// re-mint with no user interaction, so there's no refresh-token dance here.
app.get('/getFrontdoorURL', async (req, res) => {
  try {
    if (!req.session.accessToken || !req.session.instanceUrl) {
      const t = await getAccessTokenViaJwt();
      req.session.accessToken = t.accessToken;
      req.session.instanceUrl = t.instanceUrl;
    }
    let resp = await mintFrontdoor(req.session.instanceUrl, req.session.accessToken);
    if (resp.status === 401 || resp.status === 403) {
      const t = await getAccessTokenViaJwt();
      req.session.accessToken = t.accessToken;
      req.session.instanceUrl = t.instanceUrl;
      resp = await mintFrontdoor(t.instanceUrl, t.accessToken);
    }
    if (!resp.ok) {
      const body = (await resp.text().catch(() => '')).trim();
      const detail = body ? `: ${body.length > 500 ? `${body.slice(0, 500)}…` : body}` : '';
      return res.status(resp.status).json({ error: `Salesforce responded ${resp.status}${detail}.` });
    }
    const data = await resp.json();
    res.json({ frontdoorUrl: data.frontdoor_uri, orgUrl: ORG_URL });
  } catch (e) {
    // Same as /login: a Salesforce config rejection carries upstreamStatus (a 4xx); forward it.
    // A genuine transport fault (no upstreamStatus = the token/instance host was unreachable) is
    // a 502 — an upstream we depend on failed, not a bug in this broker.
    const status = e.upstreamStatus || 502;
    res.status(status).json({ error: e.message || 'Failed to generate FrontDoor URL' });
  }
});

app.get('/health', (_req, res) => res.json({ ok: true }));

app.listen(PORT, () => {
  console.log(`JWT Bearer token broker on http://localhost:${PORT}`);
  console.log(`  Authenticate: http://localhost:${PORT}/login`);
});
```

**Run it.** From `token-broker-jwt/`, with `.env` filled in. First get the private key into
place — `PRIVATE_KEY_PATH` is resolved **relative to the directory you run from**, not to
`server.js`, so the key must sit in `token-broker-jwt/` (or use an absolute path):

```bash
cp /path/to/private.key token-broker-jwt/private.key   # only public.crt was uploaded to the ECA; keep this local
cd token-broker-jwt
ls -la private.key                                      # confirm it's actually here before you start
npm install
set -a; source .env; set +a                             # re-run this after ANY edit to .env — a running
node server.js                                          # process keeps the OLD values until you re-source + restart
```

Then open **http://localhost:8500/login** once — it runs the exchange server-side (no
Salesforce login screen) and redirects to your SPA, which calls `GET /getFrontdoorURL`.

> **Troubleshooting the exchange:** if `/login` returns a `400` with `invalid_grant`, that's an
> expected config error (the broker forwards Salesforce's status, not a 502) — the usual causes
> are the `sub` user not being pre-authorized on the ECA, an `aud` that doesn't match `LOGIN_URL`,
> a clock skew pushing `exp`, or the uploaded public cert not matching `private.key`. A `502`
> means the token host was unreachable (wrong `LOGIN_URL`, network). A successful
> `/getFrontdoorURL` returning a `frontdoorUrl` that renders in the SDK is the confirmation.

---

## Java (Spring Boot) — JWT Bearer

> **Alternative flow — the [JWT Bearer broker](#jwt-bearer-broker-nodejs) above was verified end-to-end on a real org; this is its faithful Java port** (static-reviewed here — no offline Maven build toolchain — not separately run on an org), **not part of the official SDK guide**. Server-to-server, **no interactive login screen**. Same Spring Boot shape as the Auth Code + PKCE broker above — an env-bound `@ConfigurationProperties`, a `@RestController` with the endpoints, a `OncePerRequestFilter` for CORS, and `java.net.http.HttpClient` for the POSTs — but instead of redirecting a user to log in, the server signs a JWT with its private key and exchanges it for an access token, impersonating `SF_USERNAME`. Session state lives in the servlet `HttpSession` (server-side; the cookie is only the opaque `JSESSIONID`). There is **no `CLIENT_SECRET`** and **no `REDIRECT_URI`** in this flow — the RS256 signature is the client authentication. The private key and the raw access token stay server-side; the browser only ever sees the short-lived FrontDoor URL.

Before using this, complete the one-time [certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time): generate `private.key` + `public.crt`, upload `public.crt` to the ECA, and enable the JWT Bearer flow on it. This broker signs with the private key.

**Run it.** From `token-broker-jwt/java/`. Get the signing key into place **first** — `PRIVATE_KEY_PATH` (default `./private.key`) is resolved **relative to the directory you run from**, not to the source tree, so the key must sit in `token-broker-jwt/java/` (or use an absolute path). Then load the shared `.env` (see the Node section for how `.env` loading and the `source` path work), confirm it loaded, and start. The broker listens on **http://localhost:8500**.

```bash
cp /path/to/private.key token-broker-jwt/java/private.key   # only public.crt went to the ECA; keep this local
cd token-broker-jwt/java
ls -la private.key                                          # confirm it's actually here before you start
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
mvn spring-boot:run
```

Then open **http://localhost:8500/login** once. Unlike the PKCE broker there is **no Salesforce login screen** — the exchange runs entirely server-side and `/login` bounces straight to your SPA, which calls `GET /getFrontdoorURL`.

Requires **JDK 21 (LTS)** and **Maven 3.9+**. Env vars are the same set as the Node JWT `.env.example` above (`PORT`, `LOGIN_URL`, `ORG_URL`, `CLIENT_ID`, `SF_USERNAME`, `PRIVATE_KEY_PATH` / optional inline `PRIVATE_KEY`, `APP_ORIGIN`, `SESSION_SECRET`) — no `CLIENT_SECRET`, no `REDIRECT_URI`. Pin JDK **21**, not "21+": the pinned Spring Boot 3.3.4 does **not** support the newest JDKs (e.g. 26) — running Maven under one fails to build with obscure compiler/bytecode errors. Install Zulu 21 (or any LTS 21 build) and point Maven at it. On a corporate/proxied Maven setup (Salesforce-internal especially) the same **two-layer TLS wall** described in the [PKCE Java section](#java-spring-boot) applies — import the Salesforce Internal Root CA into the JDK's `cacerts` and force `JAVA_HOME` onto that JDK; on a clean, non-proxied setup none of it applies.

> **Troubleshooting the exchange:** if `/login` returns a `400` with `invalid_grant`, that's an expected config error — the broker forwards Salesforce's status, not a 502. Usual causes: the `sub` user (`SF_USERNAME`) not pre-authorized on the ECA, an `aud` that doesn't match `LOGIN_URL`, a clock skew pushing `exp`, or the uploaded `public.crt` not matching `private.key`. A `502` means the token host was unreachable (wrong `LOGIN_URL`, network). If the app fails to *start* with "Cannot read private key at …", the key isn't in the working directory (see the `cp`/`ls` step above). A successful `/getFrontdoorURL` returning a `frontdoorUrl` that renders in the SDK is the confirmation.

**`token-broker-jwt/java/pom.xml`:**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
    <relativePath/>
  </parent>

  <groupId>com.example</groupId>
  <artifactId>token-broker-jwt</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <name>token-broker-jwt</name>
  <description>Salesforce OAuth JWT Bearer token broker for the Analytics Embedding SDK</description>

  <properties>
    <java.version>21</java.version>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <!-- Web MVC + embedded Tomcat; also brings in Jackson for JSON. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Signs the RS256 JWT client assertion. Single, well-maintained dependency; version is
         NOT managed by the Spring Boot BOM, so pin it explicitly. -->
    <dependency>
      <groupId>com.auth0</groupId>
      <artifactId>java-jwt</artifactId>
      <version>4.4.0</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <finalName>token-broker-jwt</finalName>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

</project>
```

**`token-broker-jwt/java/src/main/resources/application.properties`:**

```properties
# Port the broker listens on. Same env var / default as the Node reference (PORT=8500).
server.port=${PORT:8500}

# Keep Spring's banner/log noise down; the broker never logs secrets, keys, or tokens.
logging.level.root=INFO
logging.level.com.example.broker=INFO

# Session cookie: HttpOnly + SameSite=Lax, matching the Node reference.
# (:8700 -> :8500 on localhost is same-site, so the Lax cookie is still sent cross-port.)
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
```

**`token-broker-jwt/java/src/main/java/com/example/broker/BrokerApplication.java`:**

```java
package com.example.broker;

import com.example.broker.config.BrokerProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Bootstraps the Salesforce OAuth token broker (JWT Bearer flow).
 *
 * <p>Instead of an interactive login, the server signs a JWT with its private key and exchanges
 * it for an access token — impersonating {@code SF_USERNAME} — then mints the same FrontDoor URL
 * the Analytics Embedding SDK uses as {@code authCredential}. There is no client secret in this
 * flow; the RS256 signature is the authentication. The private key and the raw access token never
 * leave this process; the browser only ever receives the short-lived FrontDoor URL.
 *
 * <p>See {@link com.example.broker.api.TokenBrokerController} for the endpoints this exposes, and
 * {@link BrokerProperties} for how configuration is read (and validated) from the environment.
 */
@SpringBootApplication
public class BrokerApplication {

	public static void main(String[] args) {
		SpringApplication.run(BrokerApplication.class, args);
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/config/BrokerProperties.java`:**

```java
package com.example.broker.config;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;

/**
 * Broker configuration, read straight from the environment (not Spring {@code @Value}/
 * {@code application.properties}) so the env var names and defaults line up exactly with the Node
 * JWT Bearer reference implementation.
 *
 * <p>{@code LOGIN_URL}, {@code ORG_URL}, {@code CLIENT_ID} and {@code SF_USERNAME} are required; a
 * missing one fails application startup immediately (mirrors the Node reference's {@code
 * process.exit(1)}). There is deliberately no {@code CLIENT_SECRET} and no {@code REDIRECT_URI} in
 * this flow. The signing key comes from {@code PRIVATE_KEY} (inline PEM) or {@code
 * PRIVATE_KEY_PATH} (default {@code ./private.key}); see {@link
 * com.example.broker.oauth.PrivateKeyLoader}.
 */
@Component
public class BrokerProperties {

	private final String port;
	private final String loginUrl;
	private final String orgUrl;
	private final String clientId;
	private final String username;
	private final String privateKeyInline;
	private final String privateKeyPath;
	private final String appOrigin;
	private final String sessionSecret;

	public BrokerProperties() {
		this.port = env("PORT", "8500");
		this.loginUrl = requireEnv("LOGIN_URL");
		this.orgUrl = requireEnv("ORG_URL");
		this.clientId = requireEnv("CLIENT_ID");
		this.username = requireEnv("SF_USERNAME");
		this.privateKeyInline = env("PRIVATE_KEY", null);
		this.privateKeyPath = env("PRIVATE_KEY_PATH", "./private.key");
		this.appOrigin = env("APP_ORIGIN", "http://localhost:8700");
		this.sessionSecret = env("SESSION_SECRET", "change-me-in-production");
	}

	private static String env(String name, String defaultValue) {
		String value = System.getenv(name);
		return (value == null || value.isBlank()) ? defaultValue : value;
	}

	/** Fails fast (mirrors the Node reference's {@code process.exit(1)} on a missing var). */
	private static String requireEnv(String name) {
		String value = System.getenv(name);
		if (value == null || value.isBlank()) {
			throw new IllegalStateException("Missing required env var: " + name + ". See .env.example.");
		}
		return value;
	}

	public String getPort() {
		return port;
	}

	public String getLoginUrl() {
		return loginUrl;
	}

	public String getOrgUrl() {
		return orgUrl;
	}

	/** The JWT {@code iss} claim (the ECA Consumer Key). */
	public String getClientId() {
		return clientId;
	}

	/** The JWT {@code sub} claim — the Salesforce user to impersonate. */
	public String getUsername() {
		return username;
	}

	/** Inline PEM ({@code \n}-escaped), or {@code null} to read {@link #getPrivateKeyPath()}. */
	public String getPrivateKeyInline() {
		return privateKeyInline;
	}

	public String getPrivateKeyPath() {
		return privateKeyPath;
	}

	public String getAppOrigin() {
		return appOrigin;
	}

	/**
	 * Read for parity with the Node reference's env var contract. Unlike Express's signed-cookie
	 * session store, the embedded Tomcat session used here (cookie settings in {@code
	 * application.properties}) identifies sessions by an opaque, server-generated ID rather than a
	 * value derived from this secret, so it's not wired into the session mechanism itself. Swap in
	 * a signed/distributed session store (e.g. Spring Session) for production and use this value
	 * there.
	 */
	public String getSessionSecret() {
		return sessionSecret;
	}

	/** Non-secret snapshot, safe to log at startup (never the key or username's credentials). */
	public Map<String, String> describeNonSecret() {
		Map<String, String> summary = new LinkedHashMap<>();
		summary.put("port", port);
		summary.put("loginUrl", loginUrl);
		summary.put("orgUrl", orgUrl);
		summary.put("username", username);
		summary.put("privateKeyPath", privateKeyInline != null ? "(inline PRIVATE_KEY)" : privateKeyPath);
		summary.put("appOrigin", appOrigin);
		return summary;
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/config/CorsFilter.java`:**

```java
package com.example.broker.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/**
 * CORS for the SPA on {@code APP_ORIGIN}: a different origin than this broker, so its
 * credentialed {@code fetch('/getFrontdoorURL')} needs these headers or the browser drops the
 * response. {@code Access-Control-Allow-Origin} must name the exact origin (never {@code *})
 * because credentials are involved; on localhost, {@code :8700 -> :8500} is the same *site*, so
 * the {@code SameSite=Lax} session cookie is still sent regardless — CORS just lets the browser
 * read the response.
 *
 * <p>Runs ahead of every other filter ({@link Ordered#HIGHEST_PRECEDENCE}) so every response —
 * including the OPTIONS preflight, which never reaches a controller — carries these headers.
 * Mirrors the Node reference's hand-rolled middleware; for many origins or production, reach for
 * {@code WebMvcConfigurer#addCorsMappings} instead.
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter extends OncePerRequestFilter {

	private final BrokerProperties properties;

	public CorsFilter(BrokerProperties properties) {
		this.properties = properties;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, properties.getAppOrigin());
		response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
		response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type");
		response.setHeader(HttpHeaders.VARY, "Origin");

		if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
			response.setStatus(HttpStatus.NO_CONTENT.value());
			return;
		}
		chain.doFilter(request, response);
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/config/StartupLogger.java`:**

```java
package com.example.broker.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
 * Logs the broker's URL and a non-secret config summary once the embedded server is actually
 * listening — mirrors the Node reference's post-{@code app.listen} console output.
 */
@Component
public class StartupLogger {

	private static final Logger log = LoggerFactory.getLogger(StartupLogger.class);

	private final BrokerProperties properties;

	public StartupLogger(BrokerProperties properties) {
		this.properties = properties;
	}

	@EventListener(ApplicationReadyEvent.class)
	void logStartup() {
		log.info("JWT Bearer token broker on http://localhost:{}", properties.getPort());
		log.info("  Authenticate:  http://localhost:{}/login", properties.getPort());
		log.info("Config: {}", properties.describeNonSecret());
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/PrivateKeyLoader.java`:**

```java
package com.example.broker.oauth;

import com.example.broker.config.BrokerProperties;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.interfaces.RSAPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import org.springframework.stereotype.Component;

/**
 * Loads the RSA signing key once, at startup, from {@code PRIVATE_KEY} (inline PEM, {@code
 * \n}-escaped) or {@code PRIVATE_KEY_PATH} (default {@code ./private.key}). The key must be
 * <strong>unencrypted</strong> — the {@code openssl … -nodes} command in the certificate &amp; ECA
 * setup produces one; an encrypted key would need a passphrase this broker never prompts for.
 *
 * <p>The JDK's {@link KeyFactory} natively parses PKCS#8 ({@code -----BEGIN PRIVATE KEY-----}),
 * which is exactly what that openssl command emits. A legacy PKCS#1 key ({@code -----BEGIN RSA
 * PRIVATE KEY-----}) is wrapped into PKCS#8 here so it works too, with no BouncyCastle dependency.
 *
 * <p>{@code PRIVATE_KEY_PATH} is resolved relative to the <strong>current working directory</strong>,
 * not this class — run the broker from {@code token-broker-jwt/java/}, or set an absolute path. A
 * missing/unreadable key fails startup fast with a clean message, not a raw stack trace.
 */
@Component
public class PrivateKeyLoader {

	private final RSAPrivateKey privateKey;

	public PrivateKeyLoader(BrokerProperties properties) {
		this.privateKey = load(properties);
	}

	public RSAPrivateKey getPrivateKey() {
		return privateKey;
	}

	private static RSAPrivateKey load(BrokerProperties properties) {
		String pem = readPem(properties);
		if (pem.contains("ENCRYPTED")) {
			throw new IllegalStateException(
					"Private key is encrypted. This broker needs an unencrypted key (regenerate with "
							+ "openssl … -nodes). See the certificate & ECA setup section.");
		}
		boolean pkcs1 = pem.contains("BEGIN RSA PRIVATE KEY");
		byte[] der = Base64.getDecoder().decode(stripPem(pem));
		try {
			byte[] pkcs8 = pkcs1 ? wrapPkcs1AsPkcs8(der) : der;
			return (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(pkcs8));
		} catch (GeneralSecurityException e) {
			throw new IllegalStateException(
					"Could not parse the RSA private key. Ensure it is an unencrypted PEM (PKCS#8 or "
							+ "PKCS#1). See the certificate & ECA setup section.",
					e);
		}
	}

	private static String readPem(BrokerProperties properties) {
		String inline = properties.getPrivateKeyInline();
		if (inline != null && !inline.isBlank()) {
			return inline.replace("\\n", "\n");
		}
		try {
			return Files.readString(Path.of(properties.getPrivateKeyPath()), StandardCharsets.UTF_8);
		} catch (IOException e) {
			// Match the Node reference's fail-fast message — no raw ENOENT stack trace.
			throw new IllegalStateException("Cannot read private key at " + properties.getPrivateKeyPath()
					+ ". Copy private.key into this directory (or set PRIVATE_KEY_PATH to an absolute path). "
					+ "See the certificate & ECA setup section.");
		}
	}

	private static String stripPem(String pem) {
		return pem.replaceAll("-----BEGIN [^-]+-----", "")
				.replaceAll("-----END [^-]+-----", "")
				.replaceAll("\\s", "");
	}

	/**
	 * Wraps a PKCS#1 {@code RSAPrivateKey} DER blob in a PKCS#8 {@code PrivateKeyInfo} so the JDK's
	 * {@link KeyFactory} (which reads only PKCS#8) can parse it — {@code SEQUENCE { version 0,
	 * AlgorithmIdentifier(rsaEncryption, NULL), OCTET STRING(pkcs1) }}.
	 */
	private static byte[] wrapPkcs1AsPkcs8(byte[] pkcs1) {
		byte[] version = {0x02, 0x01, 0x00};
		byte[] rsaAlgorithmId = {
			0x30, 0x0D, 0x06, 0x09, 0x2A, (byte) 0x86, 0x48, (byte) 0x86,
			(byte) 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00
		};
		byte[] privateKeyOctet = derElement((byte) 0x04, pkcs1);
		byte[] body = concat(version, rsaAlgorithmId, privateKeyOctet);
		return derElement((byte) 0x30, body); // SEQUENCE
	}

	private static byte[] derElement(byte tag, byte[] content) {
		byte[] length = derLength(content.length);
		byte[] out = new byte[1 + length.length + content.length];
		out[0] = tag;
		System.arraycopy(length, 0, out, 1, length.length);
		System.arraycopy(content, 0, out, 1 + length.length, content.length);
		return out;
	}

	private static byte[] derLength(int length) {
		if (length < 0x80) {
			return new byte[] {(byte) length};
		}
		ByteArrayOutputStream valueBytes = new ByteArrayOutputStream();
		for (int shift = 24; shift >= 0; shift -= 8) {
			int b = (length >>> shift) & 0xFF;
			if (b != 0 || valueBytes.size() > 0) {
				valueBytes.write(b);
			}
		}
		byte[] value = valueBytes.toByteArray();
		byte[] out = new byte[1 + value.length];
		out[0] = (byte) (0x80 | value.length);
		System.arraycopy(value, 0, out, 1, value.length);
		return out;
	}

	private static byte[] concat(byte[]... arrays) {
		ByteArrayOutputStream out = new ByteArrayOutputStream();
		for (byte[] array : arrays) {
			out.write(array, 0, array.length);
		}
		return out.toByteArray();
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/JwtAssertionFactory.java`:**

```java
package com.example.broker.oauth;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.example.broker.config.BrokerProperties;
import java.time.Duration;
import java.time.Instant;
import org.springframework.stereotype.Component;

/**
 * Signs the JWT client assertion — the heart of the JWT Bearer flow. RS256 over the claims
 * {@code iss=CLIENT_ID}, {@code sub=SF_USERNAME}, {@code aud=LOGIN_URL}, with {@code exp} bounded
 * to ~3 minutes (Salesforce rejects an assertion whose lifetime exceeds 5). The RS256 signature
 * <em>is</em> the client authentication; there is no client secret in this flow.
 */
@Component
public class JwtAssertionFactory {

	// Salesforce caps the assertion lifetime at 5 minutes; stay well under it.
	private static final Duration LIFETIME = Duration.ofMinutes(3);

	private final BrokerProperties properties;
	private final Algorithm algorithm;

	public JwtAssertionFactory(BrokerProperties properties, PrivateKeyLoader keyLoader) {
		this.properties = properties;
		// Signing only: java-jwt's RSA256 takes (publicKey, privateKey); the public key is unused here.
		this.algorithm = Algorithm.RSA256(null, keyLoader.getPrivateKey());
	}

	public String createAssertion() {
		Instant now = Instant.now();
		return JWT.create()
				.withIssuer(properties.getClientId())
				.withSubject(properties.getUsername())
				.withAudience(properties.getLoginUrl())
				.withIssuedAt(now)
				.withExpiresAt(now.plus(LIFETIME))
				.sign(algorithm);
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/FormEncoding.java`:**

```java
package com.example.broker.oauth;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.StringJoiner;

/**
 * Encodes a map of parameters as {@code application/x-www-form-urlencoded}. Shared by the outbound
 * token-exchange and single-access POST bodies ({@link SalesforceOAuthClient}), so the encoding
 * rules live in exactly one place.
 */
public final class FormEncoding {

	private FormEncoding() {}

	public static String encode(Map<String, String> params) {
		StringJoiner joiner = new StringJoiner("&");
		params.forEach((key, value) -> joiner.add(urlEncode(key) + "=" + urlEncode(value)));
		return joiner.toString();
	}

	private static String urlEncode(String value) {
		return URLEncoder.encode(value, StandardCharsets.UTF_8);
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/TokenResponse.java`:**

```java
package com.example.broker.oauth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
 * Subset of the Salesforce {@code /services/oauth2/token} response we care about. The JWT Bearer
 * grant issues no refresh token, so there's none here. Unknown fields (e.g. {@code id}, {@code
 * scope}, {@code issued_at}) are ignored.
 */
@JsonIgnoreProperties(ignoreUnknown = true)
public record TokenResponse(
		@JsonProperty("access_token") String accessToken,
		@JsonProperty("instance_url") String instanceUrl,
		@JsonProperty("error") String error,
		@JsonProperty("error_description") String errorDescription) {}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/SingleAccessResponse.java`:**

```java
package com.example.broker.oauth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/** Subset of the Salesforce {@code /services/oauth2/singleaccess} response we care about. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record SingleAccessResponse(@JsonProperty("frontdoor_uri") String frontdoorUri) {}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/UpstreamException.java`:**

```java
package com.example.broker.oauth;

/**
 * Raised when Salesforce responds to the token exchange or the FrontDoor (single-access) request
 * with a non-2xx status, or the call itself fails (network error, timeout). Carries the HTTP
 * status to propagate back to our own caller: a Salesforce config rejection (e.g. {@code
 * invalid_grant}) keeps its 4xx; a genuine transport fault is a 502. This is what keeps an
 * expected config error from being collapsed into a generic 5xx.
 */
public class UpstreamException extends RuntimeException {

	private final int statusCode;

	public UpstreamException(int statusCode, String message) {
		super(message);
		this.statusCode = statusCode;
	}

	public UpstreamException(int statusCode, String message, Throwable cause) {
		super(message, cause);
		this.statusCode = statusCode;
	}

	public int getStatusCode() {
		return statusCode;
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/BrokerSession.java`:**

```java
package com.example.broker.oauth;

import jakarta.servlet.http.HttpSession;

/**
 * Thin, typed wrapper over {@link HttpSession} for the values this broker stashes there: the
 * access token + instance URL from the JWT Bearer exchange. There's no PKCE verifier or OAuth
 * state to keep (no browser round-trip), and no refresh token (the grant issues none) — on
 * expiry the broker simply re-runs the exchange. Keeps the raw attribute-name strings in one
 * place instead of scattered across the controller.
 */
public final class BrokerSession {

	private static final String ACCESS_TOKEN = "accessToken";
	private static final String INSTANCE_URL = "instanceUrl";

	private final HttpSession session;

	public BrokerSession(HttpSession session) {
		this.session = session;
	}

	public void storeTokens(String accessToken, String instanceUrl) {
		session.setAttribute(ACCESS_TOKEN, accessToken);
		session.setAttribute(INSTANCE_URL, instanceUrl);
	}

	public String getAccessToken() {
		return (String) session.getAttribute(ACCESS_TOKEN);
	}

	public String getInstanceUrl() {
		return (String) session.getAttribute(INSTANCE_URL);
	}

	public boolean isAuthenticated() {
		return getAccessToken() != null && getInstanceUrl() != null;
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/oauth/SalesforceOAuthClient.java`:**

```java
package com.example.broker.oauth;

import com.example.broker.config.BrokerProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;

/**
 * The two server-to-server calls this broker makes to Salesforce: the JWT Bearer token exchange,
 * and the FrontDoor (single-access) mint. Both are plain {@code application/x-www-form-urlencoded}
 * POSTs over {@link HttpClient} — no need for a heavier REST client here, and
 * <strong>no client secret anywhere</strong>: the JWT's RS256 signature is the authentication.
 */
@Component
public class SalesforceOAuthClient {

	private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);

	private final HttpClient httpClient;
	private final ObjectMapper objectMapper;
	private final BrokerProperties properties;
	private final JwtAssertionFactory assertionFactory;

	public SalesforceOAuthClient(BrokerProperties properties, JwtAssertionFactory assertionFactory) {
		this.properties = properties;
		this.assertionFactory = assertionFactory;
		this.httpClient = HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).build();
		this.objectMapper = new ObjectMapper();
	}

	/**
	 * Signs a fresh assertion and POSTs it to {@code <LOGIN_URL>/services/oauth2/token} with
	 * {@code grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer}. No {@code client_secret} —
	 * the signature is the auth. A Salesforce rejection (e.g. {@code invalid_grant}: the {@code
	 * sub} user not pre-authorized, an {@code aud} mismatch, clock skew, or a cert/key mismatch)
	 * becomes an {@link UpstreamException} carrying its HTTP status (e.g. 400), so the caller can
	 * forward it as a 4xx rather than collapse it to a 5xx.
	 */
	public TokenResponse getAccessTokenViaJwt() {
		Map<String, String> form = new LinkedHashMap<>();
		form.put("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer");
		form.put("assertion", assertionFactory.createAssertion());

		HttpRequest request = HttpRequest.newBuilder()
				.uri(URI.create(properties.getLoginUrl() + "/services/oauth2/token"))
				.timeout(REQUEST_TIMEOUT)
				.header("Content-Type", "application/x-www-form-urlencoded")
				.POST(HttpRequest.BodyPublishers.ofString(FormEncoding.encode(form)))
				.build();

		HttpResponse<String> response = send(request, "JWT token exchange");
		TokenResponse token = parse(response.body(), TokenResponse.class, "token response");

		if (response.statusCode() / 100 != 2 || token.accessToken() == null) {
			// Salesforce returns {error, error_description}; forward it (no secret/token in the body).
			String reason = token.errorDescription() != null ? token.errorDescription() : token.error();
			int status = response.statusCode() >= 400 ? response.statusCode() : 500;
			throw new UpstreamException(status, "JWT token exchange failed" + (reason != null ? ": " + reason : ""));
		}
		return token;
	}

	/**
	 * POSTs (empty body) to {@code <instanceUrl>/services/oauth2/singleaccess} with the session's
	 * bearer token, to mint a short-lived FrontDoor URL.
	 */
	public SingleAccessResponse mintFrontdoorUrl(String instanceUrl, String accessToken) {
		HttpRequest request = HttpRequest.newBuilder()
				.uri(URI.create(instanceUrl + "/services/oauth2/singleaccess"))
				.timeout(REQUEST_TIMEOUT)
				.header("Accept", "application/json")
				.header("Authorization", "Bearer " + accessToken)
				.header("Content-Type", "application/x-www-form-urlencoded")
				.POST(HttpRequest.BodyPublishers.ofString(""))
				.build();

		HttpResponse<String> response = send(request, "FrontDoor request");
		if (response.statusCode() / 100 != 2) {
			// Surface Salesforce's own reason: the body carries {"error","error_description"} that
			// distinguishes e.g. an expired token (401/403 -> re-mint in the controller) from a missing
			// scope. No token/secret is in it, so it's safe to forward; truncate defensively.
			String body = response.body() == null ? "" : response.body().strip();
			String detail = body.isEmpty() ? "" : ": " + (body.length() > 500 ? body.substring(0, 500) + "…" : body);
			throw new UpstreamException(response.statusCode(), "Salesforce responded " + response.statusCode() + detail);
		}
		return parse(response.body(), SingleAccessResponse.class, "single-access response");
	}

	private HttpResponse<String> send(HttpRequest request, String label) {
		try {
			return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
		} catch (IOException e) {
			throw new UpstreamException(502, label + " failed: " + e.getMessage(), e);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			throw new UpstreamException(502, label + " interrupted", e);
		}
	}

	private <T> T parse(String body, Class<T> type, String label) {
		try {
			return objectMapper.readValue(body, type);
		} catch (IOException e) {
			throw new UpstreamException(502, "Malformed " + label + " from Salesforce", e);
		}
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/api/TokenBrokerController.java`:**

```java
package com.example.broker.api;

import com.example.broker.config.BrokerProperties;
import com.example.broker.oauth.BrokerSession;
import com.example.broker.oauth.SalesforceOAuthClient;
import com.example.broker.oauth.SingleAccessResponse;
import com.example.broker.oauth.TokenResponse;
import com.example.broker.oauth.UpstreamException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * The endpoints of the Salesforce OAuth token broker (JWT Bearer flow). Unlike the Auth Code +
 * PKCE broker there is no {@code /getAccessToken} callback and no Salesforce login screen: {@code
 * /login} runs the exchange server-side and redirects straight to the SPA, {@code
 * /getFrontdoorURL} trades the session's access token for a short-lived FrontDoor URL the SPA can
 * use as the SDK's {@code authCredential}, and {@code /health} is a liveness check.
 *
 * <p>Status codes mirror the Node JWT reference: an expected Salesforce config rejection keeps its
 * 4xx (it is <em>not</em> collapsed to a 5xx), a transport fault is a 502, and only an unexpected
 * server fault is a 500.
 */
@RestController
public class TokenBrokerController {

	private final BrokerProperties properties;
	private final SalesforceOAuthClient oauthClient;

	public TokenBrokerController(BrokerProperties properties, SalesforceOAuthClient oauthClient) {
		this.properties = properties;
		this.oauthClient = oauthClient;
	}

	/**
	 * Runs the JWT Bearer exchange server-side, stashes the token in the session, then bounces to
	 * the SPA. There is no interactive redirect to Salesforce — no login screen.
	 */
	@GetMapping("/login")
	public ResponseEntity<String> login(HttpServletRequest request) {
		BrokerSession session = new BrokerSession(request.getSession(true));
		try {
			storeFreshToken(session);
		} catch (UpstreamException e) {
			// A Salesforce rejection (invalid_grant etc.) is an expected CONFIG error — forward its
			// 4xx status. Reserve 502 for genuine transport faults (token host unreachable), which
			// getAccessTokenViaJwt already tags.
			return ResponseEntity.status(e.getStatusCode()).contentType(MediaType.TEXT_PLAIN).body(e.getMessage());
		}
		return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(properties.getAppOrigin())).build();
	}

	/**
	 * Mints a FrontDoor URL from the session's token; the SPA calls this. If nothing is stored yet
	 * (or the token has expired, surfacing as 401/403 from single-access), the broker silently
	 * re-runs the JWT exchange and retries once — a JWT Bearer client re-mints with no user
	 * interaction, so there is no refresh-token dance here.
	 */
	@GetMapping("/getFrontdoorURL")
	public ResponseEntity<?> getFrontdoorUrl(HttpSession httpSession) {
		BrokerSession session = new BrokerSession(httpSession);
		try {
			if (!session.isAuthenticated()) {
				storeFreshToken(session);
			}
			SingleAccessResponse singleAccess;
			try {
				singleAccess = oauthClient.mintFrontdoorUrl(session.getInstanceUrl(), session.getAccessToken());
			} catch (UpstreamException e) {
				// Only an expired/invalid token (401/403) is retryable by re-minting; anything else
				// propagates to the outer handler with its own status.
				if (e.getStatusCode() != 401 && e.getStatusCode() != 403) {
					throw e;
				}
				storeFreshToken(session);
				singleAccess = oauthClient.mintFrontdoorUrl(session.getInstanceUrl(), session.getAccessToken());
			}
			return ResponseEntity.ok(new FrontdoorResponse(singleAccess.frontdoorUri(), properties.getOrgUrl()));
		} catch (UpstreamException e) {
			// A Salesforce config rejection carries its 4xx; a transport fault is a 502. Forward it —
			// never collapse a config error to a 5xx. (No "re-authenticate" hint: a JWT client re-mints
			// silently, there's no login screen to send anyone to.)
			return ResponseEntity.status(e.getStatusCode()).body(new ErrorResponse(e.getMessage()));
		}
	}

	@GetMapping("/health")
	public HealthResponse health() {
		return new HealthResponse(true);
	}

	/** Runs the JWT Bearer exchange and stores the fresh access token + instance URL in the session. */
	private void storeFreshToken(BrokerSession session) {
		TokenResponse token = oauthClient.getAccessTokenViaJwt();
		session.storeTokens(token.accessToken(), token.instanceUrl());
	}
}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/api/FrontdoorResponse.java`:**

```java
package com.example.broker.api;

import com.fasterxml.jackson.annotation.JsonInclude;

/** JSON body returned by {@code GET /getFrontdoorURL}: {@code {"frontdoorUrl", "orgUrl"}}. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FrontdoorResponse(String frontdoorUrl, String orgUrl) {}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/api/ErrorResponse.java`:**

```java
package com.example.broker.api;

/** JSON error body returned by the broker's endpoints: {@code {"error": "..."}}. */
public record ErrorResponse(String error) {}
```

**`token-broker-jwt/java/src/main/java/com/example/broker/api/HealthResponse.java`:**

```java
package com.example.broker.api;

/** JSON body returned by {@code GET /health}: {@code {"ok": true}}. */
public record HealthResponse(boolean ok) {}
```

---

## Python (FastAPI) — JWT Bearer

> **Alternative flow — the [JWT Bearer broker (Node.js)](#jwt-bearer-broker-nodejs) above was verified end-to-end on a real org; this is its faithful Python port** (byte-compiles clean via `py_compile` here, not separately run on an org). FastAPI + `httpx`, signing the assertion with **PyJWT** (RS256). There's no interactive login and **no Salesforce login screen** — the server signs a JWT with its private key and exchanges it for a token, impersonating a fixed user, so there's **no `CLIENT_SECRET` and no `REDIRECT_URI`** (the RS256 signature is the auth). Verified via that Node reference (JWT → FrontDoor URL → dashboard rendered); the one caveat is that it's **not part of the official SDK guide**, which documents Auth Code + PKCE. Complete the one-time [certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time) first: generate `private.key` + `public.crt`, upload `public.crt` to the ECA, and enable the JWT Bearer flow on it.

**Run it.** From `token-broker-jwt/python/`, create a venv and install deps, then get the private key into place — `PRIVATE_KEY_PATH` is resolved **relative to the directory you launch from**, not to `main.py`, so the key must sit in `token-broker-jwt/python/` (or use an absolute path). Load the shared `.env` (the JWT broker's `.env`, one dir up — see the Node section for how `.env` loading and the `source` path work), confirm it loaded, then start. The broker listens on **http://localhost:8500**.

```bash
cd token-broker-jwt/python
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp /path/to/private.key ./private.key   # only public.crt was uploaded to the ECA; keep this one local
ls -la private.key                       # PRIVATE_KEY_PATH is relative to THIS dir -- confirm it's here first
set -a; source ../.env; set +a           # re-source after ANY .env edit, then restart -- a running
echo "ORG_URL=$ORG_URL"                   # process keeps the OLD values until you re-source + restart
uvicorn main:app --port 8500
```

Then open **http://localhost:8500/login** once. Unlike the PKCE broker there is **no Salesforce login screen** — it runs the exchange entirely server-side and redirects straight to your SPA, which calls `GET /getFrontdoorURL`.

Requires **Python 3.9+**. Uses **PyJWT** (with the `cryptography` extra) to sign the RS256 assertion; the private key must be **unencrypted** — the `openssl ... -nodes` command in the cert setup produces one, and an encrypted key would need a passphrase. (Note: Starlette's `CORSMiddleware` answers preflight with `200`, not the contract's literal `204`; browsers treat any 2xx preflight identically.)

**`token-broker-jwt/python/requirements.txt`:**

```text
fastapi==0.141.1
uvicorn[standard]==0.52.1
httpx==0.28.1
pyjwt[crypto]==2.10.1
```

**Config.** Reads the same environment as the [JWT Bearer broker (Node.js)](#jwt-bearer-broker-nodejs) — the same `.env`, minus `CLIENT_SECRET` and `REDIRECT_URI` (neither exists in this flow): `PORT`, `LOGIN_URL` (also the JWT `aud`), `ORG_URL` (the SDK `orgUrl`), `CLIENT_ID` (the JWT `iss`), `SF_USERNAME` (the JWT `sub`), `PRIVATE_KEY_PATH` (default `./private.key`, cwd-relative) or `PRIVATE_KEY` (inline PEM, `\n`-escaped), `APP_ORIGIN`, and `SESSION_SECRET`.

**`token-broker-jwt/python/main.py`:**

```python
"""Tableau Next embedding -- server-side token broker (Python / FastAPI, JWT Bearer flow).

ALTERNATIVE to the Authorization Code + PKCE broker (main.py). Instead of an interactive login,
the server signs a JWT with its private key and exchanges it for an access token, impersonating
SF_USERNAME, then mints the SAME FrontDoor URL the SDK consumes as ``authCredential``.

Port of the Node/Express JWT Bearer broker (``token-broker-jwt/server.js``). Keep this on the
server: it holds the private key and the raw access token; the browser only ever sees the
short-lived FrontDoor URL. There is NO client secret in this flow -- the RS256 signature is the auth.

Endpoints:
    GET  /login            -> runs the JWT Bearer exchange server-side, stores the session, redirects to the SPA
    GET  /getFrontdoorURL  -> mints {frontdoorUrl, orgUrl} from the stored token (call this from the SPA)
    GET  /health           -> {"ok": true}
"""

import os
import secrets
import sys
import threading
from datetime import datetime, timedelta, timezone
from typing import Optional

import httpx
import jwt
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse
from starlette.middleware.cors import CORSMiddleware

# ---- Config (from environment) -----------------------------------------------------

PORT = int(os.environ.get("PORT", "8500"))

# The org's OAuth login host -- BOTH where we POST the token request and the JWT `aud` claim.
LOGIN_URL = os.environ.get("LOGIN_URL")
# The org's Lightning My Domain host -- the SDK `orgUrl`.
ORG_URL = os.environ.get("ORG_URL")
# ECA Consumer Key -- the JWT `iss` claim.
CLIENT_ID = os.environ.get("CLIENT_ID")
# Salesforce username to impersonate -- the JWT `sub` claim. Must be pre-authorized on the ECA
# and hold a Tableau+/Tableau Next license.
SF_USERNAME = os.environ.get("SF_USERNAME")
# RSA private key whose public cert is uploaded to the ECA: either a path (default ./private.key)
# or the PEM contents inline (PRIVATE_KEY, with \n for newlines). There is NO CLIENT_SECRET here.
PRIVATE_KEY = os.environ.get("PRIVATE_KEY")
PRIVATE_KEY_PATH = os.environ.get("PRIVATE_KEY_PATH", "./private.key")
# The SPA's origin -- where the browser is sent and the CORS-allowed origin for /getFrontdoorURL.
APP_ORIGIN = os.environ.get("APP_ORIGIN", "http://localhost:8700")
SESSION_SECRET = os.environ.get("SESSION_SECRET", "change-me-in-production")

for name, value in {
    "LOGIN_URL": LOGIN_URL,
    "ORG_URL": ORG_URL,
    "CLIENT_ID": CLIENT_ID,
    "SF_USERNAME": SF_USERNAME,
}.items():
    if not value:
        print(f"Missing required env var: {name}. See .env.example.", file=sys.stderr)
        sys.exit(1)

# ---- Signing key ---------------------------------------------------------------------
# Load the signing key once, from PRIVATE_KEY (inline PEM, \n-escaped) or PRIVATE_KEY_PATH.
# Fail fast with a clean message if it's missing -- otherwise open() throws a raw traceback.
# PRIVATE_KEY_PATH is resolved relative to the CURRENT WORKING DIRECTORY, not this file -- run
# from token-broker-jwt/python/, or set an absolute path. The key must be UNENCRYPTED (the
# `openssl ... -nodes` command in the cert setup produces one); an encrypted key needs a passphrase.
if PRIVATE_KEY:
    PRIVATE_KEY_PEM = PRIVATE_KEY.replace("\\n", "\n")
else:
    try:
        with open(PRIVATE_KEY_PATH, "r", encoding="utf-8") as fh:
            PRIVATE_KEY_PEM = fh.read()
    except OSError:
        print(
            f"Cannot read private key at {PRIVATE_KEY_PATH}. Copy private.key into this directory "
            f"(or set PRIVATE_KEY_PATH to an absolute path). See the certificate & ECA setup section.",
            file=sys.stderr,
        )
        sys.exit(1)


# ---- JWT Bearer exchange -------------------------------------------------------------


class TokenExchangeError(Exception):
    """A JWT Bearer exchange failed. ``upstream_status`` carries Salesforce's HTTP status for an
    expected config rejection (e.g. 400 invalid_grant) so endpoints can forward it as a 4xx; it's
    None for a genuine transport fault (token host unreachable), which callers map to a 502."""

    def __init__(self, message: str, upstream_status: Optional[int] = None):
        super().__init__(message)
        self.upstream_status = upstream_status


def create_assertion() -> str:
    """Sign the JWT assertion with RS256. Claims: iss=client_id, sub=user to impersonate,
    aud=login host, exp bounded to 3 min. (iat mirrors what jsonwebtoken sets automatically;
    Salesforce only requires exp.)"""
    now = datetime.now(timezone.utc)
    claims = {
        "iss": CLIENT_ID,
        "sub": SF_USERNAME,
        "aud": LOGIN_URL,
        "iat": now,
        "exp": now + timedelta(minutes=3),
    }
    return jwt.encode(claims, PRIVATE_KEY_PEM, algorithm="RS256")


async def get_access_token_via_jwt() -> tuple[str, str]:
    """Exchange the signed JWT for an access token. No client_secret -- the RS256 signature is the
    auth. Returns (access_token, instance_url). On a Salesforce rejection (e.g. invalid_grant) we
    raise TokenExchangeError carrying the upstream 4xx status so callers can forward it -- a config
    error is NOT a 5xx. A transport fault raises with upstream_status=None (a 502 for callers)."""
    data = {
        "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
        "assertion": create_assertion(),
    }
    try:
        async with httpx.AsyncClient() as client:
            resp = await client.post(f"{LOGIN_URL}/services/oauth2/token", data=data)
    except httpx.HTTPError as exc:
        raise TokenExchangeError(f"JWT token exchange failed: {exc}") from exc

    try:
        token_data = resp.json()
    except ValueError:
        token_data = {}
    if resp.is_error or not token_data.get("access_token"):
        # Salesforce returns {error, error_description}; forward it (no secret/token in the body).
        reason = token_data.get("error_description") or token_data.get("error") or f"HTTP {resp.status_code}"
        raise TokenExchangeError(f"JWT token exchange failed: {reason}", upstream_status=resp.status_code)
    return token_data["access_token"], token_data["instance_url"]


async def _mint_frontdoor(client: httpx.AsyncClient, instance_url: str, access_token: str) -> httpx.Response:
    """POST the access token to singleaccess to mint a FrontDoor URL (identical to the PKCE broker)."""
    return await client.post(
        f"{instance_url}/services/oauth2/singleaccess",
        headers={
            "accept": "application/json",
            "authorization": f"Bearer {access_token}",
            "content-type": "application/x-www-form-urlencoded",
        },
        content=b"",
    )


# ---- App -----------------------------------------------------------------------------

app = FastAPI(title="Tableau Next Token Broker (JWT Bearer)")

# CORS: the SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this broker
# (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these headers or the
# browser drops the response. Allow-Origin must name the exact origin (not '*') when credentials are
# involved. On localhost, :8700 and :8500 are the same *site*, so the SameSite=Lax session cookie is
# still sent -- CORS just permits reading the response.
app.add_middleware(
    CORSMiddleware,
    allow_origins=[APP_ORIGIN],
    allow_credentials=True,
    allow_methods=["GET", "OPTIONS"],
    allow_headers=["Content-Type"],
)

# ---- Server-side session store -------------------------------------------------------
# CRITICAL: session state (accessToken, instanceUrl) must stay SERVER-SIDE, exactly like the Node
# reference's express-session MemoryStore. The cookie carries ONLY an opaque, unguessable session
# id -- never the token, and never the private key. (Starlette's SessionMiddleware is a *signed
# cookie* store, which would serialize the raw access_token into the browser cookie; that violates
# the "token never leaves the server" invariant, so we don't use it.)
#
# This in-memory dict is fine for local dev / a single process. For production or multiple workers,
# back it with a shared store (Redis, a database, etc.) so sessions survive restarts.
SESSION_COOKIE = "token_broker_session"
_sessions: dict[str, dict] = {}
_sessions_lock = threading.Lock()


def _get_session(request: Request) -> dict:
    """Return the server-side session dict for this request (empty if none yet)."""
    sid = request.cookies.get(SESSION_COOKIE)
    if not sid:
        return {}
    with _sessions_lock:
        return _sessions.get(sid, {})


def _save_session(response: Response, request: Request, data: dict) -> None:
    """Persist the session dict server-side and set the opaque-id cookie if new."""
    sid = request.cookies.get(SESSION_COOKIE)
    with _sessions_lock:
        if not sid or sid not in _sessions:
            sid = secrets.token_urlsafe(32)
        _sessions[sid] = data
    # HttpOnly + SameSite=Lax; the value is only the opaque id, never the token.
    response.set_cookie(SESSION_COOKIE, sid, httponly=True, samesite="lax", secure=False, path="/")


@app.get("/login")
async def login(request: Request) -> Response:
    """Run the JWT Bearer exchange entirely server-side, stash the token, then bounce to the SPA.
    There is NO interactive redirect to Salesforce -- no login screen, no user prompt."""
    try:
        access_token, instance_url = await get_access_token_via_jwt()
    except TokenExchangeError as exc:
        # A Salesforce rejection (invalid_grant etc.) is an expected CONFIG error -- forward its
        # 4xx status. A transport fault (upstream_status=None, token host unreachable) is a 502.
        status = exc.upstream_status or 502
        return PlainTextResponse(str(exc) or "JWT authentication failed", status_code=status)

    response = RedirectResponse(url=APP_ORIGIN, status_code=302)  # straight to the SPA, no SF screen
    session = _get_session(request)
    session["accessToken"] = access_token
    session["instanceUrl"] = instance_url
    _save_session(response, request, session)
    return response


@app.get("/getFrontdoorURL")
async def get_frontdoor_url(request: Request) -> JSONResponse:
    """Mint a FrontDoor URL from the session's token. The SPA calls this. If there's no stored
    token yet, run the exchange first; on a 401/403 (expired token) re-run it once and retry -- a
    JWT Bearer client re-mints silently with no user interaction, so there's no refresh-token dance."""
    session = _get_session(request)
    try:
        if not session.get("accessToken") or not session.get("instanceUrl"):
            session["accessToken"], session["instanceUrl"] = await get_access_token_via_jwt()
        async with httpx.AsyncClient() as client:
            resp = await _mint_frontdoor(client, session["instanceUrl"], session["accessToken"])
            if resp.status_code in (401, 403):
                session["accessToken"], session["instanceUrl"] = await get_access_token_via_jwt()
                resp = await _mint_frontdoor(client, session["instanceUrl"], session["accessToken"])
    except TokenExchangeError as exc:
        # A Salesforce config rejection carries the upstream 4xx status -- forward it. A transport
        # fault (upstream_status=None, token host unreachable) is a 502, not a 500.
        status = exc.upstream_status or 502
        return JSONResponse({"error": str(exc) or "Failed to generate FrontDoor URL"}, status_code=status)
    except httpx.HTTPError as exc:
        # Transport fault talking to singleaccess -- the instance host was unreachable.
        return JSONResponse({"error": str(exc) or "Failed to generate FrontDoor URL"}, status_code=502)

    if resp.is_error:
        # Still failing after the retry -- surface Salesforce's own reason (the body carries
        # {"error","error_description"}). No token/secret is in it, so it's safe to forward;
        # truncate defensively.
        body = (resp.text or "").strip()
        detail = f": {body[:500]}…" if len(body) > 500 else (f": {body}" if body else "")
        return JSONResponse(
            {"error": f"Salesforce responded {resp.status_code}{detail}."},
            status_code=resp.status_code,
        )

    data = resp.json()
    result = JSONResponse({"frontdoorUrl": data.get("frontdoor_uri"), "orgUrl": ORG_URL})
    _save_session(result, request, session)  # persist token + set the opaque-id cookie if new
    return result


@app.get("/health")
async def health() -> JSONResponse:
    return JSONResponse({"ok": True})


if __name__ == "__main__":
    import uvicorn

    print(f"JWT Bearer token broker on http://localhost:{PORT}")
    print(f"  Authenticate: http://localhost:{PORT}/login  (no login screen -- bounces to the SPA)")
    uvicorn.run(app, host="0.0.0.0", port=PORT)
```

---

## Go (net/http) — JWT Bearer

> **Alternative flow — the [JWT Bearer broker](#jwt-bearer-broker-nodejs) above was verified end-to-end on a real org; this is its faithful Go port** (`go vet` + `go build` clean here, not separately run on an org). Server-to-server, so there's **no login screen** —
> the broker signs a JWT with its private key and exchanges it for an access token (impersonating
> `SF_USERNAME`), then mints the SAME FrontDoor URL the SDK consumes. Like the PKCE Go port it's
> **standard-library only** (`go run .`, no deps): the RS256 assertion is built and signed with
> `crypto/rsa` in a small `signJWT` helper — no JWT library. There is **no `CLIENT_SECRET` and no
> `REDIRECT_URI`** here; the signature is the auth. Session state is the same in-memory map keyed by a
> `crypto/rand` opaque id (server-side; the cookie carries only that id, `HttpOnly` + `SameSite=Lax`).
> Complete the one-time [certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time)
> first. Caveat: this is **not part of the official SDK guide**, which documents Auth Code + PKCE.

**Run it.** From `token-broker-jwt/go/`, with the JWT Bearer broker's `.env` filled in. First get the
private key into place — `PRIVATE_KEY_PATH` is resolved **relative to the directory you run from**,
not to `main.go`, so the key must sit in `token-broker-jwt/go/` (or set an absolute path). There's no
dependency-install step — this port is **standard-library only**, so `go run .` compiles and starts it
directly:

```bash
cd token-broker-jwt/go
cp /path/to/private.key ./private.key && ls -la private.key   # only public.crt went to the ECA; keep this local
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
go run .
```

Then open **http://localhost:8500/login** once — it runs the exchange server-side (**no Salesforce
login screen**) and bounces straight to your SPA, which calls `GET /getFrontdoorURL`.

Requires **Go 1.22+** (method-qualified `ServeMux` routes). Reads config directly from `os.Getenv` (no
`.env` loader) — export the same vars the JWT Bearer broker (Node.js) section documents.

**`token-broker-jwt/go/go.mod`:**

```text
module tableau-next-token-broker-jwt

go 1.22
```

**`token-broker-jwt/go/main.go`:**

```go
// Command tableau-next-token-broker-jwt runs the Salesforce OAuth JWT Bearer flow and mints a
// FrontDoor URL for the Analytics Embedding SDK.
//
// ALTERNATIVE to the Authorization Code + PKCE broker. Instead of an interactive login, the
// server signs a JWT with its private key and exchanges it for an access token, impersonating
// SF_USERNAME, then mints the SAME FrontDoor URL the SDK consumes.
//
// Keep this server-side: it holds the private key and the raw access token. The browser only
// ever receives the short-lived FrontDoor URL. There is NO client secret in this flow -- the
// RS256 signature authenticates the client.
//
// Endpoints:
//
//	GET /login            runs the JWT Bearer exchange server-side, stores the session, redirects to the SPA
//	GET /getFrontdoorURL  exchanges the stored token -> {frontdoorUrl, orgUrl} (call from the SPA)
//	GET /health           liveness check
package main

import (
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"
)

// config holds the broker's settings, all sourced from the environment. Note the absence of a
// client secret and redirect URI -- neither exists in the JWT Bearer flow.
type config struct {
	port           string
	loginURL       string // also the JWT `aud` claim
	orgURL         string
	clientID       string // the JWT `iss` claim
	sfUsername     string // the JWT `sub` claim -- the user to impersonate
	privateKeyPath string
	appOrigin      string
	// sessionSecret is read for parity with the other broker ports' env surface (SESSION_SECRET
	// is documented everywhere), but this broker's in-memory session store keys sessions by an
	// unguessable server-side id rather than a signed cookie, so it isn't consumed today.
	sessionSecret string
}

// loadConfig reads configuration from the environment and fails fast if a required value is
// missing, mirroring the Node reference implementation.
func loadConfig() config {
	cfg := config{
		port:           getEnv("PORT", "8500"),
		loginURL:       os.Getenv("LOGIN_URL"),
		orgURL:         os.Getenv("ORG_URL"),
		clientID:       os.Getenv("CLIENT_ID"),
		sfUsername:     os.Getenv("SF_USERNAME"),
		privateKeyPath: getEnv("PRIVATE_KEY_PATH", "./private.key"),
		appOrigin:      getEnv("APP_ORIGIN", "http://localhost:8700"),
		sessionSecret:  getEnv("SESSION_SECRET", "change-me-in-production"),
	}

	required := map[string]string{
		"LOGIN_URL":   cfg.loginURL,
		"ORG_URL":     cfg.orgURL,
		"CLIENT_ID":   cfg.clientID,
		"SF_USERNAME": cfg.sfUsername,
	}
	for _, name := range []string{"LOGIN_URL", "ORG_URL", "CLIENT_ID", "SF_USERNAME"} {
		if required[name] == "" {
			log.Fatalf("Missing required env var: %s. See .env.example.", name)
		}
	}
	return cfg
}

func getEnv(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

// loadPrivateKey reads the RSA signing key once at startup: it prefers the inline PRIVATE_KEY
// (PEM with \n-escaped newlines) and otherwise reads PRIVATE_KEY_PATH. It fails fast with a clean
// message rather than a raw file-read panic. Note PRIVATE_KEY_PATH is resolved relative to the
// CURRENT WORKING DIRECTORY, not this file -- run the broker from token-broker-jwt/go, or set an
// absolute path.
func loadPrivateKey(cfg config) *rsa.PrivateKey {
	var pemBytes []byte
	if inline := os.Getenv("PRIVATE_KEY"); inline != "" {
		pemBytes = []byte(strings.ReplaceAll(inline, `\n`, "\n"))
	} else {
		b, err := os.ReadFile(cfg.privateKeyPath)
		if err != nil {
			log.Fatalf("Cannot read private key at %s. Copy private.key into this directory or set an absolute path. See the certificate & ECA setup section.", cfg.privateKeyPath)
		}
		pemBytes = b
	}
	key, err := parsePrivateKey(pemBytes)
	if err != nil {
		log.Fatalf("Cannot parse private key: %v. It must be an UNENCRYPTED RSA key (the openssl -nodes command produces one). See the certificate & ECA setup section.", err)
	}
	return key
}

// parsePrivateKey decodes an unencrypted RSA private key from PEM. It accepts both encodings
// openssl emits: PKCS#8 ("BEGIN PRIVATE KEY", via x509.ParsePKCS8PrivateKey) and PKCS#1
// ("BEGIN RSA PRIVATE KEY", via x509.ParsePKCS1PrivateKey). An encrypted key
// ("BEGIN ENCRYPTED PRIVATE KEY") is rejected -- the key must be unencrypted.
func parsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
	block, _ := pem.Decode(pemBytes)
	if block == nil {
		return nil, errors.New("no PEM block found (expected BEGIN PRIVATE KEY or BEGIN RSA PRIVATE KEY)")
	}
	switch block.Type {
	case "PRIVATE KEY": // PKCS#8
		key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
		if err != nil {
			return nil, err
		}
		rsaKey, ok := key.(*rsa.PrivateKey)
		if !ok {
			return nil, errors.New("private key is not RSA (RS256 requires an RSA key)")
		}
		return rsaKey, nil
	case "RSA PRIVATE KEY": // PKCS#1
		return x509.ParsePKCS1PrivateKey(block.Bytes)
	default:
		return nil, fmt.Errorf("unsupported PEM block %q; expected an unencrypted RSA private key", block.Type)
	}
}

// signJWT builds a compact RS256 JWS -- base64url(header).base64url(claims).base64url(signature)
// -- with the standard library, so this port stays dependency-free. RS256 is "sign the SHA-256
// digest of the signing input with RSASSA-PKCS1-v1_5", which is exactly rsa.SignPKCS1v15 over a
// crypto.SHA256 hash. All three segments use base64url with no padding, per the JWT spec.
func signJWT(claims map[string]any, key *rsa.PrivateKey) (string, error) {
	headerJSON, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
	if err != nil {
		return "", err
	}
	claimsJSON, err := json.Marshal(claims)
	if err != nil {
		return "", err
	}
	signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." +
		base64.RawURLEncoding.EncodeToString(claimsJSON)

	digest := sha256.Sum256([]byte(signingInput))
	sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
	if err != nil {
		return "", err
	}
	return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}

// broker holds everything a request handler needs: config, an HTTP client for the
// server-to-server calls to Salesforce, the parsed signing key, and the session store.
type broker struct {
	cfg        config
	sessions   *sessionStore
	client     *http.Client
	privateKey *rsa.PrivateKey
}

const sessionCookieName = "token-broker-jwt-session"

// sessionData is the state the broker stashes across requests: the access token + instance URL
// obtained from the JWT exchange. It lives only server-side -- never in the cookie. There's no
// PKCE verifier or refresh token here: a JWT Bearer client silently re-mints by re-signing.
type sessionData struct {
	accessToken string
	instanceUrl string
}

// sessionStore is a minimal, single-process, in-memory session store: state lives in this map,
// keyed by a crypto/rand opaque id, and the browser's cookie carries only that id -- never the
// session payload. This is the Go analog of express-session's default MemoryStore. A real
// deployment (multi-process, or one that must survive restarts) should back this with a shared
// store instead (e.g. Redis); see the README.
type sessionStore struct {
	mu   sync.Mutex
	data map[string]*sessionData
}

func newSessionStore() *sessionStore {
	return &sessionStore{data: make(map[string]*sessionData)}
}

// newSessionID returns a high-entropy, unguessable id (32 random bytes, base64url-encoded)
// suitable for use as an opaque session-cookie value.
func newSessionID() (string, error) {
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(raw), nil
}

// load returns the session named by the request's cookie, or nil if there isn't one --
// e.g. no cookie yet, or a stale id (this store doesn't survive restarts).
func (s *sessionStore) load(r *http.Request) *sessionData {
	cookie, err := r.Cookie(sessionCookieName)
	if err != nil {
		return nil
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.data[cookie.Value]
}

// create allocates a new session under a fresh opaque id, sets that id as an HttpOnly+SameSite=Lax
// cookie on the response, and returns the (empty) session for the caller to populate.
func (s *sessionStore) create(w http.ResponseWriter) (*sessionData, error) {
	id, err := newSessionID()
	if err != nil {
		return nil, err
	}
	sess := &sessionData{}
	s.mu.Lock()
	s.data[id] = sess
	s.mu.Unlock()

	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    id,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})
	return sess, nil
}

// getOrCreate returns the request's existing session, or creates (and cookies) a fresh one if the
// request has none. A tampered or stale cookie shouldn't block the flow, so this never errors on a
// bad incoming cookie -- it just starts over.
func (s *sessionStore) getOrCreate(r *http.Request, w http.ResponseWriter) (*sessionData, error) {
	if sess := s.load(r); sess != nil {
		return sess, nil
	}
	return s.create(w)
}

// tokenError carries the upstream HTTP status from a token-endpoint failure so handlers can decide
// what to return. A completed Salesforce rejection (e.g. 400 invalid_grant) sets StatusCode; a
// request that never reached Salesforce sets transport=true (-> 502). A server-side fault (signing,
// undecodable response) leaves both zero/false (-> 500).
type tokenError struct {
	StatusCode int
	transport  bool
	msg        string
}

func (e *tokenError) Error() string { return e.msg }

// statusForTokenError maps a token-exchange failure to the HTTP status the broker returns: a
// transport fault (token host unreachable) becomes 502; an expected Salesforce config rejection (a
// 4xx like invalid_grant) is forwarded as-is; anything else -- a signing fault, an undecodable
// body, an upstream 5xx -- is an unexpected server fault and becomes 500.
func statusForTokenError(err error) int {
	var te *tokenError
	if errors.As(err, &te) {
		if te.transport {
			return http.StatusBadGateway
		}
		if te.StatusCode >= 400 && te.StatusCode < 500 {
			return te.StatusCode
		}
	}
	return http.StatusInternalServerError
}

// createAssertion builds and signs the RS256 JWT bearer assertion. Claims: iss=CLIENT_ID,
// sub=SF_USERNAME (the user to impersonate), aud=LOGIN_URL (the token host), plus iat and an exp
// bounded to 3 minutes out (Salesforce requires exp within 5 minutes).
func (b *broker) createAssertion() (string, error) {
	now := time.Now()
	claims := map[string]any{
		"iss": b.cfg.clientID,
		"sub": b.cfg.sfUsername,
		"aud": b.cfg.loginURL,
		"iat": now.Unix(),
		"exp": now.Add(3 * time.Minute).Unix(),
	}
	return signJWT(claims, b.privateKey)
}

// tokenResponse is the subset of Salesforce's /services/oauth2/token response we need.
type tokenResponse struct {
	AccessToken      string `json:"access_token"`
	InstanceURL      string `json:"instance_url"`
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description"`
}

// getAccessTokenViaJwt signs a JWT assertion and exchanges it for an access token. There is NO
// client_secret -- the RS256 signature is the auth. On a Salesforce-side rejection it returns a
// *tokenError carrying the upstream status (e.g. 400 for invalid_grant) so callers can forward a
// 4xx rather than a 5xx; a request that never reached Salesforce yields a transport *tokenError.
func (b *broker) getAccessTokenViaJwt() (accessToken, instanceURL string, err error) {
	assertion, serr := b.createAssertion()
	if serr != nil {
		return "", "", &tokenError{msg: "Failed to sign JWT assertion: " + serr.Error()}
	}

	form := url.Values{
		"grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"},
		"assertion":  {assertion},
	}
	resp, perr := b.client.Post(
		b.cfg.loginURL+"/services/oauth2/token",
		"application/x-www-form-urlencoded",
		strings.NewReader(form.Encode()),
	)
	if perr != nil {
		// The request never reached Salesforce -- a transport fault, not a config error.
		return "", "", &tokenError{transport: true, msg: "JWT token exchange error: " + perr.Error()}
	}
	defer resp.Body.Close()

	var data tokenResponse
	if derr := json.NewDecoder(resp.Body).Decode(&data); derr != nil {
		return "", "", &tokenError{StatusCode: resp.StatusCode, msg: "JWT token exchange: invalid response from Salesforce"}
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 || data.AccessToken == "" {
		// Salesforce returns {error, error_description}; forward it (no secret/token in the body).
		reason := data.ErrorDescription
		if reason == "" {
			reason = data.Error
		}
		if reason == "" {
			reason = fmt.Sprintf("HTTP %d", resp.StatusCode)
		}
		return "", "", &tokenError{StatusCode: resp.StatusCode, msg: "JWT token exchange failed: " + reason}
	}
	return data.AccessToken, data.InstanceURL, nil
}

// mintFrontdoor POSTs the access token to the singleaccess endpoint to mint a FrontDoor URL
// (identical to the PKCE broker).
func (b *broker) mintFrontdoor(instanceURL, accessToken string) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodPost, instanceURL+"/services/oauth2/singleaccess", strings.NewReader(""))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Authorization", "Bearer "+accessToken)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return b.client.Do(req)
}

// frontdoorResponse is the JSON shape returned to the SPA by handleGetFrontdoorURL.
type frontdoorResponse struct {
	FrontdoorURL string `json:"frontdoorUrl,omitempty"`
	OrgURL       string `json:"orgUrl,omitempty"`
	Error        string `json:"error,omitempty"`
}

// singleAccessResponse is the subset of /services/oauth2/singleaccess we need.
type singleAccessResponse struct {
	FrontdoorURI string `json:"frontdoor_uri"`
}

// handleLogin runs the JWT Bearer exchange entirely server-side, stashes the token, and bounces the
// browser to the SPA. There's NO interactive redirect to Salesforce -- no login screen.
func (b *broker) handleLogin(w http.ResponseWriter, r *http.Request) {
	accessToken, instanceURL, err := b.getAccessTokenViaJwt()
	if err != nil {
		// A Salesforce rejection (invalid_grant etc.) is an expected CONFIG error -- forward its
		// 4xx. Reserve 502 for a transport fault (token host unreachable), 500 for anything else.
		http.Error(w, err.Error(), statusForTokenError(err))
		return
	}

	sess, serr := b.sessions.getOrCreate(r, w)
	if serr != nil {
		http.Error(w, "Failed to persist session", http.StatusInternalServerError)
		return
	}
	sess.accessToken = accessToken
	sess.instanceUrl = instanceURL

	http.Redirect(w, r, b.cfg.appOrigin, http.StatusFound)
}

// handleGetFrontdoorURL exchanges the session's access token for a FrontDoor URL. If there's no
// stored token yet it runs the JWT exchange on the spot (a JWT Bearer client needs no user
// interaction), so the SPA can call this directly. The raw access token never leaves the server.
func (b *broker) handleGetFrontdoorURL(w http.ResponseWriter, r *http.Request) {
	sess, serr := b.sessions.getOrCreate(r, w)
	if serr != nil {
		writeJSON(w, http.StatusInternalServerError, frontdoorResponse{Error: "Failed to persist session"})
		return
	}

	// No stored token? Mint one now instead of bouncing the browser to /login.
	if sess.accessToken == "" || sess.instanceUrl == "" {
		accessToken, instanceURL, err := b.getAccessTokenViaJwt()
		if err != nil {
			writeJSON(w, statusForTokenError(err), frontdoorResponse{Error: err.Error()})
			return
		}
		sess.accessToken = accessToken
		sess.instanceUrl = instanceURL
	}

	resp, err := b.mintFrontdoor(sess.instanceUrl, sess.accessToken)
	if err != nil {
		// Couldn't reach Salesforce at all -- a transport fault, not a server bug.
		writeJSON(w, http.StatusBadGateway, frontdoorResponse{Error: "Failed to generate FrontDoor URL"})
		return
	}
	defer resp.Body.Close()

	// The access token expires. On 401/403, re-run the JWT exchange once and retry -- no refresh
	// token needed, since a JWT Bearer client silently re-mints with no user interaction.
	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		accessToken, instanceURL, rerr := b.getAccessTokenViaJwt()
		if rerr != nil {
			writeJSON(w, statusForTokenError(rerr), frontdoorResponse{Error: rerr.Error()})
			return
		}
		sess.accessToken = accessToken
		sess.instanceUrl = instanceURL

		resp.Body.Close()
		resp, err = b.mintFrontdoor(sess.instanceUrl, sess.accessToken)
		if err != nil {
			writeJSON(w, http.StatusBadGateway, frontdoorResponse{Error: "Failed to generate FrontDoor URL"})
			return
		}
		defer resp.Body.Close()
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		// Still failing after the retry. Surface Salesforce's own reason: the body carries
		// {"error","error_description"} with no token/secret in it, so it's safe to forward;
		// truncate defensively.
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		detail := strings.TrimSpace(string(raw))
		if len(detail) > 500 {
			detail = detail[:500] + "…"
		}
		if detail != "" {
			detail = ": " + detail
		}
		writeJSON(w, resp.StatusCode, frontdoorResponse{Error: fmt.Sprintf("Salesforce responded %d%s.", resp.StatusCode, detail)})
		return
	}

	var data singleAccessResponse
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		writeJSON(w, http.StatusInternalServerError, frontdoorResponse{Error: "Invalid response from Salesforce"})
		return
	}

	writeJSON(w, http.StatusOK, frontdoorResponse{FrontdoorURL: data.FrontdoorURI, OrgURL: b.cfg.orgURL})
}

func handleHealth(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

// withCORS answers preflight requests and adds the CORS headers every response needs so the SPA on
// appOrigin can read a credentialed response. Access-Control-Allow-Origin must name the exact
// origin (never "*") because credentials are involved.
func withCORS(appOrigin string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", appOrigin)
		w.Header().Set("Access-Control-Allow-Credentials", "true")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		w.Header().Set("Vary", "Origin")
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	cfg := loadConfig()
	privateKey := loadPrivateKey(cfg) // fail fast before we bind a port

	b := &broker{
		cfg:        cfg,
		sessions:   newSessionStore(),
		client:     &http.Client{},
		privateKey: privateKey,
	}

	// Method-qualified patterns (Go 1.22+) so each path responds only to GET, matching the golden
	// Express reference's app.get(...). A non-GET request to a known path gets a 405 from the mux
	// rather than falling through to a handler. There's no /getAccessToken -- the JWT flow has no
	// browser callback.
	mux := http.NewServeMux()
	mux.HandleFunc("GET /login", b.handleLogin)
	mux.HandleFunc("GET /getFrontdoorURL", b.handleGetFrontdoorURL)
	mux.HandleFunc("GET /health", handleHealth)

	addr := ":" + cfg.port
	log.Printf("JWT Bearer token broker on http://localhost:%s", cfg.port)
	log.Printf("  Authenticate: http://localhost:%s/login", cfg.port)
	if err := http.ListenAndServe(addr, withCORS(cfg.appOrigin, mux)); err != nil {
		log.Fatal(err)
	}
}
```

> **Troubleshooting the exchange:** if `/login` returns a `400` with `invalid_grant`, that's an
> expected config error (the broker forwards Salesforce's status, not a 502) — the usual causes are
> the `sub` user not being pre-authorized on the ECA, an `aud` that doesn't match `LOGIN_URL`, clock
> skew pushing `exp`, or the uploaded public cert not matching `private.key`. A `502` means the token
> host was unreachable (wrong `LOGIN_URL`, network). A `500` at startup that reads `Cannot read/parse
> private key` means the key isn't in `token-broker-jwt/go/` or isn't an unencrypted RSA key. A
> successful `/getFrontdoorURL` returning a `frontdoorUrl` that renders in the SDK is the confirmation.

---

## C# (ASP.NET Core) — JWT Bearer

> **Alternative flow — the [JWT Bearer broker](#jwt-bearer-broker-nodejs) above was verified
> end-to-end on a real org; this is its faithful C# port** (static-reviewed here — no offline
> .NET build toolchain — not separately run on an org). Instead of the interactive
> Authorization Code + PKCE flow, the server signs a JWT with its private key and exchanges it for
> an access token, impersonating `SF_USERNAME` — so there is **no interactive login screen** and
> **no `CLIENT_SECRET`** (the RS256 signature is the auth). This is the server-to-server
> [JWT Bearer flow](#auth-alternative-the-jwt-bearer-flow). Same caveat as the Node version: it's **not part
> of the official SDK guide**, which documents Auth Code + PKCE. Complete the one-time
> [certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time) first (generate
> `private.key` + `public.crt`, upload `public.crt` to the ECA, enable the JWT Bearer flow). Same
> ASP.NET Core 8 minimal API and server-side `HttpContext.Session` as the PKCE port; the one
> difference is a single first-party NuGet package (`System.IdentityModel.Tokens.Jwt`) for RS256
> signing. The private key and raw token stay server-side — the browser only ever sees the FrontDoor URL.

**Run it.** From `token-broker-jwt/dotnet/`. First get the private key into place — `PRIVATE_KEY_PATH`
is resolved **relative to the directory you run from**, not to `Program.cs`, so the key must sit in
`token-broker-jwt/dotnet/` (or set an absolute path). Then load the shared `.env`, confirm it loaded,
and run. The broker listens on **http://localhost:8500**.

```bash
cp /path/to/private.key token-broker-jwt/dotnet/private.key   # only public.crt was uploaded to the ECA; keep this local
cd token-broker-jwt/dotnet
ls -la private.key                                            # confirm it's actually here before you start
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
dotnet restore
dotnet run
```

Requires the **.NET 8 SDK**. Env vars are the JWT set from the Node `.env.example` above (`PORT`,
`LOGIN_URL` — also the JWT `aud`, `ORG_URL`, `CLIENT_ID` — the JWT `iss`, `SF_USERNAME` — the JWT
`sub`, `PRIVATE_KEY_PATH` — default `./private.key`, optionally `PRIVATE_KEY` inline PEM, `APP_ORIGIN`,
`SESSION_SECRET`). There is **no login screen**: hitting **http://localhost:8500/login** runs the
exchange server-side and bounces straight to the SPA, which then calls `GET /getFrontdoorURL`.

**`token-broker-jwt/dotnet/broker.csproj`:**

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>TokenBroker</RootNamespace>
    <AssemblyName>token-broker-jwt</AssemblyName>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>

  <ItemGroup>
    <!-- The only third-party package: RS256 JWT signing. Sessions, CORS primitives, and
         HttpClientFactory all ship in the ASP.NET Core shared framework. RSA key loading uses
         .NET's built-in RSA.ImportFromPem (no extra dependency). -->
    <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.1" />
  </ItemGroup>

</Project>
```

**`token-broker-jwt/dotnet/Program.cs`:**

```csharp
// Tableau Next embedding — server-side token broker (C# / ASP.NET Core 8, minimal API).
//
// ALTERNATIVE to the Authorization Code + PKCE broker (see the C# section above). Instead of an
// interactive login, the server signs a JWT with its private key and exchanges it for an access
// token, impersonating SF_USERNAME. It then mints the SAME FrontDoor URL the SDK consumes.
//
// Keep this on the server: it holds the private key and the raw access token; the browser only
// ever sees the short-lived FrontDoor URL. There is NO client secret in this flow — the RS256
// signature authenticates the client.
//
// Port of the Node/Express JWT Bearer reference (token-broker-jwt/server.js). Same contract,
// same env vars — pick whichever runtime fits your stack.
//
// Endpoints:
//   GET  /login            -> runs the JWT Bearer exchange, stores the session, redirects to the SPA
//   GET  /getFrontdoorURL  -> mints { frontdoorUrl, orgUrl } from the stored token (call from the SPA)
//   GET  /health           -> { "ok": true }

using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.IdentityModel.Tokens;

// ---- Config + signing key (from environment) ------------------------------------------
// Read directly from the environment (not appsettings.json/IConfiguration) so the env var
// names and defaults line up exactly with the Node reference's `.env.example`. A missing
// required var — or an unreadable/encrypted private key — fails fast, before we bind a port,
// mirroring the Node reference's `process.exit(1)`.
BrokerConfig config = BrokerConfig.LoadOrExit();
RSA privateKey = LoadPrivateKeyOrExit(config);

var builder = WebApplication.CreateBuilder(args);

// Server-side session: ASP.NET Core's session middleware stores the payload (accessToken,
// instanceUrl) in the configured cache — here, an in-memory distributed cache, the .NET analog
// of express-session's default MemoryStore — and puts only an opaque, server-generated session
// id in the cookie. The cookie never carries the token itself.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
	options.Cookie.Name = ".TokenBroker.Session";
	options.Cookie.HttpOnly = true;
	options.Cookie.SameSite = SameSiteMode.Lax;
	// On localhost, :8700 -> :8500 is the same *site*, so the Lax cookie is still sent
	// cross-port even though CORS treats them as different origins.
	options.IdleTimeout = TimeSpan.FromMinutes(30);
});

// A single named HttpClient (via IHttpClientFactory) for the two server-to-server POSTs:
// the JWT token exchange and the FrontDoor (single-access) mint.
builder.Services.AddHttpClient("salesforce", client =>
{
	client.Timeout = TimeSpan.FromSeconds(15);
});

var app = builder.Build();

// CORS: the SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this
// broker (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these
// headers or the browser drops the response. Allow-Origin must name the exact origin (never
// "*") because credentials are involved. Hand-rolled (rather than the AddCors/UseCors
// abstraction) so every response — CORS or not, preflight or not — carries these exact headers,
// mirroring the Node reference's middleware byte-for-byte.
app.Use(async (context, next) =>
{
	context.Response.Headers["Access-Control-Allow-Origin"] = config.AppOrigin;
	context.Response.Headers["Access-Control-Allow-Credentials"] = "true";
	context.Response.Headers["Access-Control-Allow-Headers"] = "Content-Type";
	context.Response.Headers["Vary"] = "Origin";
	if (HttpMethods.IsOptions(context.Request.Method))
	{
		context.Response.StatusCode = StatusCodes.Status204NoContent;
		return;
	}
	await next();
});

app.UseSession();

// 1) Run the JWT Bearer exchange, stash the token server-side, then bounce to the SPA.
// There's NO interactive redirect to Salesforce — the exchange happens entirely server-side.
app.MapGet("/login", async (HttpContext context, IHttpClientFactory httpClientFactory) =>
{
	var client = httpClientFactory.CreateClient("salesforce");
	try
	{
		var (accessToken, instanceUrl) = await GetAccessTokenViaJwtAsync(client, config, privateKey);
		context.Session.SetString("accessToken", accessToken);
		context.Session.SetString("instanceUrl", instanceUrl);
		return Results.Redirect(config.AppOrigin); // straight to the SPA — no Salesforce login screen
	}
	catch (JwtExchangeException ex)
	{
		// A Salesforce rejection (invalid_grant etc.) carries its own 4xx — forward it. A transport
		// fault carries 502. Either way it's ex.UpstreamStatus, never a blanket 500.
		return PlainText(ex.UpstreamStatus, ex.Message);
	}
});

// 2) Mint a FrontDoor URL from the stored token. The SPA fetches this.
app.MapGet("/getFrontdoorURL", async (HttpContext context, IHttpClientFactory httpClientFactory) =>
{
	var client = httpClientFactory.CreateClient("salesforce");
	try
	{
		var accessToken = context.Session.GetString("accessToken");
		var instanceUrl = context.Session.GetString("instanceUrl");

		// No stored token yet (e.g. the SPA called us before /login): a JWT Bearer client can mint
		// one non-interactively, so just run the exchange now — no user login required.
		if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(instanceUrl))
		{
			(accessToken, instanceUrl) = await GetAccessTokenViaJwtAsync(client, config, privateKey);
			context.Session.SetString("accessToken", accessToken);
			context.Session.SetString("instanceUrl", instanceUrl);
		}

		var response = await MintFrontdoorAsync(client, instanceUrl, accessToken);

		// The access token expires. On 401/403, re-run the JWT exchange once and retry — there's no
		// refresh-token dance here, because a JWT Bearer client re-mints silently with no user
		// interaction. Mirrors the Node broker's re-run-on-401/403.
		if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized
			|| response.StatusCode == System.Net.HttpStatusCode.Forbidden)
		{
			response.Dispose();
			(accessToken, instanceUrl) = await GetAccessTokenViaJwtAsync(client, config, privateKey);
			context.Session.SetString("accessToken", accessToken);
			context.Session.SetString("instanceUrl", instanceUrl);
			response = await MintFrontdoorAsync(client, instanceUrl, accessToken);
		}

		// Capture status + body, then dispose the (final) response so no HttpResponseMessage leaks on
		// any exit path below — the pre-retry response was already disposed in the retry branch above.
		var isSuccess = response.IsSuccessStatusCode;
		var statusCode = (int)response.StatusCode;
		var body = await response.Content.ReadAsStringAsync();
		response.Dispose();

		if (!isSuccess)
		{
			// Surface Salesforce's own reason: the body carries {"error","error_description"}. No
			// token/secret is in it, so it's safe to forward; truncate defensively.
			var trimmed = (body ?? string.Empty).Trim();
			var detail = trimmed.Length == 0 ? "" : ": " + (trimmed.Length > 500 ? trimmed[..500] + "…" : trimmed);
			return Results.Json(new { error = $"Salesforce responded {statusCode}{detail}." }, statusCode: statusCode);
		}

		SingleAccessResponse? data;
		try
		{
			data = JsonSerializer.Deserialize<SingleAccessResponse>(body);
		}
		catch (JsonException)
		{
			return Results.Json(new { error = "Invalid response from Salesforce" }, statusCode: StatusCodes.Status500InternalServerError);
		}

		return Results.Json(new { frontdoorUrl = data?.FrontdoorUri, orgUrl = config.OrgUrl });
	}
	catch (JwtExchangeException ex)
	{
		// A Salesforce config rejection carries its own 4xx status; a transport fault on the token
		// exchange carries 502. Both live on the exception — forward it, never a blanket 500.
		return Results.Json(new { error = ex.Message }, statusCode: ex.UpstreamStatus);
	}
	catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
	{
		// A transport fault on the FrontDoor (singleaccess) POST itself: the instance was
		// unreachable / timed out. That's an upstream fault -> 502, not a 500.
		return Results.Json(new { error = string.IsNullOrEmpty(ex.Message) ? "Failed to generate FrontDoor URL" : ex.Message }, statusCode: StatusCodes.Status502BadGateway);
	}
	// Anything else is a genuinely unexpected server fault — let it bubble to a framework 500.
});

app.MapGet("/health", () => Results.Json(new { ok = true }));

app.Urls.Add($"http://localhost:{config.Port}");

app.Lifetime.ApplicationStarted.Register(() =>
{
	Console.WriteLine($"JWT Bearer token broker on http://localhost:{config.Port}");
	Console.WriteLine($"  Authenticate: http://localhost:{config.Port}/login  (no login screen — bounces to the SPA)");
});

app.Run();

// ---- Helpers ---------------------------------------------------------------------------

static IResult PlainText(int statusCode, string body) => Results.Text(body, "text/plain", statusCode: statusCode);

// Sign the JWT assertion. Claims: iss=CLIENT_ID, sub=SF_USERNAME (the user to impersonate),
// aud=LOGIN_URL, exp≤5min. Framework-native System.IdentityModel.Tokens.Jwt: SigningCredentials
// with SecurityAlgorithms.RsaSha256 (RS256) over an RsaSecurityKey; the handler sets nbf/exp and
// the RS256 header. We bound exp to 3 minutes — Salesforce rejects a longer window.
static string CreateAssertion(BrokerConfig config, RSA privateKey)
{
	var now = DateTime.UtcNow;
	var signingCredentials = new SigningCredentials(new RsaSecurityKey(privateKey), SecurityAlgorithms.RsaSha256);
	var token = new JwtSecurityToken(
		issuer: config.ClientId,   // iss
		audience: config.LoginUrl, // aud — must equal LOGIN_URL
		claims: new[] { new Claim("sub", config.Username) }, // the impersonated user
		notBefore: now,
		expires: now.AddMinutes(3), // exp
		signingCredentials: signingCredentials);
	return new JwtSecurityTokenHandler().WriteToken(token);
}

// Exchange the signed JWT for an access token. No client_secret — the signature is the auth.
// On a Salesforce-side rejection (e.g. invalid_grant) throw carrying the upstream HTTP status so
// callers can forward it — a config error is NOT a 5xx. A transport fault becomes a 502.
static async Task<(string AccessToken, string InstanceUrl)> GetAccessTokenViaJwtAsync(
	HttpClient client, BrokerConfig config, RSA privateKey)
{
	var form = new Dictionary<string, string>
	{
		["grant_type"] = "urn:ietf:params:oauth:grant-type:jwt-bearer",
		["assertion"] = CreateAssertion(config, privateKey),
		// No client_secret in this flow.
	};

	HttpResponseMessage response;
	try
	{
		response = await client.PostAsync($"{config.LoginUrl}/services/oauth2/token", FormUrlEncoded(form));
	}
	catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
	{
		// The token host was unreachable / timed out — a transport fault, not a config error.
		// 502 (an upstream we depend on failed), never a 4xx.
		throw new JwtExchangeException($"JWT token exchange transport error: {ex.Message}", StatusCodes.Status502BadGateway);
	}

	var body = await response.Content.ReadAsStringAsync();
	var isSuccess = response.IsSuccessStatusCode;
	var statusCode = (int)response.StatusCode;
	response.Dispose();

	TokenResponse? token;
	try
	{
		// TokenResponse's [JsonPropertyName] attributes map each property to Salesforce's exact
		// snake_case key, so no custom JsonSerializerOptions are needed.
		token = JsonSerializer.Deserialize<TokenResponse>(body);
	}
	catch (JsonException)
	{
		token = null;
	}

	if (!isSuccess || string.IsNullOrEmpty(token?.AccessToken))
	{
		// Salesforce returns {error, error_description}; forward its status (e.g. 400 invalid_grant)
		// and reason — an expected config error. No secret/token is in this body.
		var reason = token?.ErrorDescription ?? token?.Error ?? $"HTTP {statusCode}";
		// A non-error status with no token is an upstream fault, not a config rejection.
		var status = statusCode < 400 ? StatusCodes.Status502BadGateway : statusCode;
		throw new JwtExchangeException($"JWT token exchange failed: {reason}", status);
	}

	return (token!.AccessToken!, token.InstanceUrl!);
}

// POST the access token to the singleaccess endpoint to mint a FrontDoor URL (identical to the
// PKCE broker): empty urlencoded body, Bearer auth.
static async Task<HttpResponseMessage> MintFrontdoorAsync(HttpClient client, string instanceUrl, string accessToken)
{
	using var request = new HttpRequestMessage(HttpMethod.Post, $"{instanceUrl}/services/oauth2/singleaccess")
	{
		Content = FormUrlEncoded(new Dictionary<string, string>()), // empty body
	};
	request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
	request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
	return await client.SendAsync(request);
}

// Encodes a form body as application/x-www-form-urlencoded ourselves (rather than
// FormUrlEncodedContent, which appends "; charset=utf-8") so the outbound Content-Type header
// matches the broker contract exactly, and so an empty dictionary produces a genuinely empty body
// for the FrontDoor (single-access) POST.
static HttpContent FormUrlEncoded(IEnumerable<KeyValuePair<string, string>> data)
{
	var encoded = string.Join("&", data.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
	var content = new StringContent(encoded, Encoding.ASCII);
	content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
	return content;
}

// Load the RSA signing key once, from PRIVATE_KEY (inline PEM, \n-escaped) or PRIVATE_KEY_PATH.
// RSA.ImportFromPem handles both PKCS#8 (-----BEGIN PRIVATE KEY-----) and PKCS#1 (-----BEGIN RSA
// PRIVATE KEY-----). The key must be UNENCRYPTED — the `openssl ... -nodes` command in the cert
// setup produces one; an encrypted key needs a passphrase and ImportFromPem will throw.
// Fail fast with a clean message rather than an unhandled exception dump. NOTE: PRIVATE_KEY_PATH
// is resolved relative to the CURRENT WORKING DIRECTORY, not this file — run from the broker's
// directory or set an absolute path.
static RSA LoadPrivateKeyOrExit(BrokerConfig config)
{
	string pem;
	try
	{
		pem = !string.IsNullOrEmpty(config.PrivateKeyInline)
			? config.PrivateKeyInline.Replace("\\n", "\n")
			: File.ReadAllText(config.PrivateKeyPath);
	}
	catch
	{
		Console.Error.WriteLine(
			$"Cannot read private key at {config.PrivateKeyPath}. Copy private.key into this directory " +
			"(or set PRIVATE_KEY_PATH to an absolute path). See the certificate & ECA setup section.");
		Environment.Exit(1);
		throw; // unreachable; satisfies the compiler's non-null return
	}

	try
	{
		var rsa = RSA.Create();
		rsa.ImportFromPem(pem); // PKCS#8 or PKCS#1; throws on an ENCRYPTED key
		return rsa;
	}
	catch (Exception ex)
	{
		Console.Error.WriteLine(
			$"Private key at {config.PrivateKeyPath} could not be parsed ({ex.Message}). It must be an " +
			"UNENCRYPTED RSA key (the openssl -nodes command in the cert setup produces one).");
		Environment.Exit(1);
		throw; // unreachable
	}
}

// ---- Exceptions -------------------------------------------------------------------------

/// <summary>
/// Thrown by the JWT token exchange. <see cref="UpstreamStatus"/> is the status the caller should
/// forward: Salesforce's own 4xx for a config rejection (invalid_grant etc.), or 502 for a
/// transport fault (token host unreachable) — never a blanket 500.
/// </summary>
sealed class JwtExchangeException : Exception
{
	public int UpstreamStatus { get; }

	public JwtExchangeException(string message, int upstreamStatus)
		: base(message) => UpstreamStatus = upstreamStatus;
}

// ---- Config -----------------------------------------------------------------------------

/// <summary>
/// Broker configuration, read straight from the environment. <see cref="LoginUrl"/>,
/// <see cref="OrgUrl"/>, <see cref="ClientId"/>, and <see cref="Username"/> are required; a
/// missing one fails startup immediately (mirrors the Node reference's <c>process.exit(1)</c>).
/// There is no client secret in the JWT Bearer flow. <see cref="PrivateKeyInline"/> (PRIVATE_KEY)
/// wins over <see cref="PrivateKeyPath"/> (PRIVATE_KEY_PATH, cwd-relative) when both are set.
/// </summary>
sealed record BrokerConfig(
	string Port,
	string LoginUrl,
	string OrgUrl,
	string ClientId,
	string Username,
	string PrivateKeyPath,
	string? PrivateKeyInline,
	string AppOrigin,
	string SessionSecret)
{
	public static BrokerConfig LoadOrExit()
	{
		var port = GetEnv("PORT", "8500");
		var loginUrl = RequireEnv("LOGIN_URL");   // also the JWT `aud`
		var orgUrl = RequireEnv("ORG_URL");       // the SDK `orgUrl`
		var clientId = RequireEnv("CLIENT_ID");   // the JWT `iss`
		var username = RequireEnv("SF_USERNAME"); // the JWT `sub` — the user to impersonate
		var privateKeyPath = GetEnv("PRIVATE_KEY_PATH", "./private.key");
		var privateKeyInline = Environment.GetEnvironmentVariable("PRIVATE_KEY");
		var appOrigin = GetEnv("APP_ORIGIN", "http://localhost:8700");
		// Kept for parity with the other brokers' env var surface (SESSION_SECRET is documented
		// everywhere). ASP.NET Core's session middleware identifies sessions by an opaque,
		// server-generated id backed by the distributed cache rather than a value signed with this
		// secret, so it isn't wired into the session mechanism itself.
		var sessionSecret = GetEnv("SESSION_SECRET", "change-me-in-production");

		return new BrokerConfig(port, loginUrl, orgUrl, clientId, username, privateKeyPath, privateKeyInline, appOrigin, sessionSecret);
	}

	private static string GetEnv(string name, string fallback)
	{
		var value = Environment.GetEnvironmentVariable(name);
		return string.IsNullOrEmpty(value) ? fallback : value;
	}

	private static string RequireEnv(string name)
	{
		var value = Environment.GetEnvironmentVariable(name);
		if (string.IsNullOrEmpty(value))
		{
			Console.Error.WriteLine($"Missing required env var: {name}. See .env.example.");
			Environment.Exit(1);
		}
		return value!;
	}
}

// ---- Salesforce response shapes ---------------------------------------------------------

/// <summary>Subset of the Salesforce <c>/services/oauth2/token</c> response we need.</summary>
sealed record TokenResponse(
	[property: JsonPropertyName("access_token")] string? AccessToken,
	[property: JsonPropertyName("instance_url")] string? InstanceUrl,
	[property: JsonPropertyName("error")] string? Error,
	[property: JsonPropertyName("error_description")] string? ErrorDescription);

/// <summary>Subset of the Salesforce <c>/services/oauth2/singleaccess</c> response we need.</summary>
sealed record SingleAccessResponse(
	[property: JsonPropertyName("frontdoor_uri")] string? FrontdoorUri);
```

> **Troubleshooting the exchange:** if `/login` returns a `400` with `invalid_grant`, that's an
> expected config error (the broker forwards Salesforce's status, not a 502) — the usual causes are
> the `SF_USERNAME` (`sub`) user not being pre-authorized on the ECA, a `LOGIN_URL` (`aud`) mismatch,
> clock skew pushing `exp`, or the uploaded public cert not matching `private.key`. A `502` means the
> token host was unreachable (wrong `LOGIN_URL`, network). A `500` is a genuinely unexpected server
> fault. A successful `/getFrontdoorURL` returning a `frontdoorUrl` that renders in the SDK is the
> confirmation.

---

## Ruby (Sinatra) — JWT Bearer

> *JWT Bearer alternative — server-to-server, no login screen.* Signs a JWT with the org's private key and exchanges it for a token (impersonating `SF_USERNAME`), then mints the same FrontDoor URL the SDK consumes — so there is **no interactive redirect to Salesforce**. The Node JWT Bearer broker it ports was verified end-to-end on a real org (JWT → FrontDoor URL → dashboard rendered), and this is its faithful Ruby port (`ruby -c` syntax-clean here, not separately run on an org), but **not part of the official SDK guide**, which documents Auth Code + PKCE. There is **no `CLIENT_SECRET`** and **no `REDIRECT_URI`** in this flow — the RS256 signature is the auth. Same Sinatra `Rack::Session::Pool` server-side sessions and the same session/CORS contract as the PKCE port above. Complete the one-time [certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time) first (generate `private.key` + `public.crt`, upload the cert to the ECA, enable the JWT Bearer flow); this broker signs with the private key, which stays server-side.

**Run it.** From `token-broker-jwt/ruby/`, with the shared `.env` filled in (see the Node section for how `.env` loading and the `source` path work). First get the private key into place — `PRIVATE_KEY_PATH` is resolved **relative to the directory you run from**, not to `app.rb`, so the key must sit in `token-broker-jwt/ruby/` (or use an absolute path). The broker listens on **http://localhost:8500**.

```bash
cp /path/to/private.key token-broker-jwt/ruby/private.key   # only public.crt was uploaded to the ECA; keep this local
cd token-broker-jwt/ruby
ls -la private.key                                          # confirm it's actually here before you start
bundle install
set -a; source ../.env; set +a                              # re-run after ANY .env edit — a running process keeps the OLD values
echo "ORG_URL=$ORG_URL"
ruby app.rb
```

Then open **http://localhost:8500/login** once — it runs the exchange server-side (**no Salesforce login screen**) and bounces straight to your SPA, which calls `GET /getFrontdoorURL`.

> **Requires Ruby 3.x.** macOS's system Ruby (2.6) is too old for the Gemfile's `>= 3.0` floor — install a current Ruby with Homebrew and put it ahead of the system one on `PATH` (the same Homebrew fix shown in the PKCE Ruby section above). Env vars are the same set as the Node JWT `.env.example`, minus `CLIENT_SECRET` and `REDIRECT_URI`; add `SF_USERNAME` (the JWT `sub`) and `PRIVATE_KEY_PATH`.

**`token-broker-jwt/ruby/Gemfile`:**

```ruby
source 'https://rubygems.org'

ruby '>= 3.0' # matches the "Ruby 3.x" prereq; Sinatra 3.2 / Puma 6.6 run on Ruby 3

gem 'sinatra', '~> 3.2'
gem 'puma', '~> 6.6'
gem 'jwt', '~> 2.9' # RS256 signing of the JWT Bearer assertion
```

**`token-broker-jwt/ruby/app.rb`:**

```ruby
# frozen_string_literal: true

# Tableau Next embedding — server-side token broker (Ruby/Sinatra, JWT Bearer flow).
#
# ALTERNATIVE to the Authorization Code + PKCE broker (../ruby/app.rb / server.js). Instead of an
# interactive login, the server signs a JWT with its private key and exchanges it for an access
# token, impersonating SF_USERNAME, then mints the SAME FrontDoor URL the SDK consumes.
#
# Keep this on the server: it holds the private key and the raw access token; the browser only
# ever sees the short-lived FrontDoor URL. There is NO client secret in this flow — the RS256
# signature authenticates the client.
#
# Port of the reference Node JWT broker (see ../server.js) — same env vars, endpoints, and
# session/CORS contract.
#
# Endpoints:
#   GET  /login            → runs the JWT Bearer exchange, stores the session, redirects to the SPA
#   GET  /getFrontdoorURL  → mints { frontdoorUrl, orgUrl } from the stored token (call this from the SPA)
#   GET  /health           → { ok: true }

require 'sinatra'
require 'rack/session/pool'
require 'net/http'
require 'uri'
require 'json'
require 'openssl'
require 'jwt'

# ---- Config (from environment) ---------------------------------------------------------
PORT = (ENV['PORT'] || 8500).to_i

# OAuth login host — this is BOTH where we POST the token request and the JWT `aud`.
# Prod: https://login.salesforce.com | Sandbox: https://test.salesforce.com | or a My Domain host.
LOGIN_URL = ENV['LOGIN_URL']
# Your org's Lightning My Domain host, e.g. https://yourorg.lightning.force.com — the SDK `orgUrl`.
ORG_URL = ENV['ORG_URL']
# ECA Consumer Key — becomes the JWT `iss` claim.
CLIENT_ID = ENV['CLIENT_ID']
# Salesforce username to impersonate — becomes the JWT `sub`. Must be pre-authorized on the ECA
# and hold a Tableau+/Tableau Next license.
SF_USERNAME = ENV['SF_USERNAME']
# Path to the RSA private key whose public cert is uploaded to the ECA. Resolved relative to the
# CURRENT WORKING DIRECTORY, not this file. (Or set PRIVATE_KEY to the PEM contents, \n-escaped.)
PRIVATE_KEY_PATH = ENV['PRIVATE_KEY_PATH'] || './private.key'
# Where to send the browser after login; also the CORS-allowed origin for /getFrontdoorURL.
APP_ORIGIN = ENV['APP_ORIGIN'] || 'http://localhost:8700'
SESSION_SECRET = ENV['SESSION_SECRET'] || 'change-me-in-production'

# There is deliberately NO CLIENT_SECRET and NO REDIRECT_URI in this flow.
{ 'LOGIN_URL' => LOGIN_URL, 'ORG_URL' => ORG_URL, 'CLIENT_ID' => CLIENT_ID, 'SF_USERNAME' => SF_USERNAME }.each do |name, value|
  next unless value.nil? || value.empty?

  warn "Missing required env var: #{name}. See .env.example."
  exit 1
end

# Load the RSA signing key once, from PRIVATE_KEY (inline PEM, \n-escaped) or PRIVATE_KEY_PATH.
# Fail fast with a clean message if it's missing/unreadable — no raw backtrace. The key must be
# UNENCRYPTED (the `openssl ... -nodes` command in the cert setup produces one); an encrypted key
# needs a passphrase and OpenSSL::PKey::RSA.new will reject it here. Note: PRIVATE_KEY_PATH is
# cwd-relative — run this broker from token-broker-jwt/ruby/, or set an absolute path.
RSA_PRIVATE_KEY = begin
  pem = ENV['PRIVATE_KEY'] ? ENV['PRIVATE_KEY'].gsub('\n', "\n") : File.read(PRIVATE_KEY_PATH)
  OpenSSL::PKey::RSA.new(pem)
rescue StandardError
  abort("Cannot read private key at #{PRIVATE_KEY_PATH}. Copy private.key into this directory " \
        'or set an absolute path. See the certificate & ECA setup section.')
end

configure do
  set :port, PORT
  set :bind, '0.0.0.0'
  set :session_secret, SESSION_SECRET
  # Session state (access_token, instance_url) must stay server-side — the cookie may only carry
  # an opaque session id, never the payload. Rack::Session::Pool keeps sessions in an in-memory
  # server-side store and puts just a signed id in the cookie. A single-process in-memory Pool is
  # fine for this demo; a real deployment should back sessions with Redis or another shared store.
  set :session_store, Rack::Session::Pool
  set :sessions, httponly: true, same_site: :lax
end

# Raised when Salesforce rejects the exchange (e.g. invalid_grant). Carries the upstream HTTP
# status so callers can forward it — a config error is an expected 4xx, NOT a 5xx.
class JwtExchangeError < StandardError
  attr_reader :upstream_status

  def initialize(message, upstream_status)
    super(message)
    @upstream_status = upstream_status
  end
end

# ---- CORS ----------------------------------------------------------------------------------
# The SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this broker
# (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these headers or
# the browser drops the response. Allow-Origin must name the exact origin (not '*') when
# credentials are involved. On localhost, :8700 and :8500 are the same *site*, so the
# SameSite=Lax session cookie is still sent — CORS just permits reading the response.
before do
  headers 'Access-Control-Allow-Origin' => APP_ORIGIN,
          'Access-Control-Allow-Credentials' => 'true',
          'Access-Control-Allow-Headers' => 'Content-Type',
          'Vary' => 'Origin'
end

options '*' do
  204
end

# ---- JWT Bearer helpers --------------------------------------------------------------------
# Sign the JWT assertion. Claims: iss=client_id, sub=user to impersonate, aud=login host,
# exp≤5min (~3m to match the Node broker), plus iat. RS256 with the unencrypted RSA private key.
def create_assertion
  now = Time.now.to_i
  payload = {
    iss: CLIENT_ID,
    sub: SF_USERNAME,
    aud: LOGIN_URL,
    iat: now,
    exp: now + 180
  }
  JWT.encode(payload, RSA_PRIVATE_KEY, 'RS256')
end

# Exchange the signed JWT for an access token. No client_secret — the signature is the auth.
# On a Salesforce-side rejection (e.g. invalid_grant) we raise JwtExchangeError carrying the
# upstream HTTP status so callers can forward it — a config error is NOT a 5xx (see /login).
# (Net::HTTP.post_form enables SSL for https URIs, so LOGIN_URL over https works as-is.)
def get_access_token_via_jwt
  body = {
    grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
    assertion: create_assertion
  }
  response = Net::HTTP.post_form(URI("#{LOGIN_URL}/services/oauth2/token"), body)
  data = begin
    JSON.parse(response.body)
  rescue JSON::ParserError
    {}
  end

  unless response.is_a?(Net::HTTPSuccess) && data['access_token']
    # Salesforce returns {error, error_description}; forward it (no secret/token in the body).
    reason = data['error_description'] || data['error'] || "HTTP #{response.code}"
    raise JwtExchangeError.new("JWT token exchange failed: #{reason}", response.code.to_i)
  end

  [data['access_token'], data['instance_url']]
end

# POST the access token to the singleaccess endpoint to mint a FrontDoor URL (identical to the
# PKCE broker).
def mint_frontdoor(instance_url, access_token)
  uri = URI("#{instance_url}/services/oauth2/singleaccess")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == 'https'

  request = Net::HTTP::Post.new(uri)
  request['Accept'] = 'application/json'
  request['Authorization'] = "Bearer #{access_token}"
  request['Content-Type'] = 'application/x-www-form-urlencoded'
  request.body = ''

  http.request(request)
end

# 1) Run the JWT Bearer exchange, stash the token server-side, then bounce to the SPA. There's no
# interactive redirect to Salesforce — the exchange happens entirely server-side.
get '/login' do
  begin
    access_token, instance_url = get_access_token_via_jwt
    session[:access_token] = access_token
    session[:instance_url] = instance_url
    redirect APP_ORIGIN
  rescue JwtExchangeError => e
    # A Salesforce rejection (invalid_grant etc.) is an expected CONFIG error — forward its
    # status (a 4xx). Reserve 502 for genuine transport faults (token host unreachable).
    content_type :text
    halt e.upstream_status, e.message
  rescue StandardError => e
    content_type :text
    halt 502, "JWT authentication failed: #{e.message}"
  end
end

# 2) Mint a FrontDoor URL from the stored token. The SPA fetches this. On 401/403 (token expired)
# just re-run the exchange once and retry — a JWT Bearer client can silently re-mint with no user
# interaction, so there's no refresh-token dance here.
get '/getFrontdoorURL' do
  content_type :json

  begin
    if session[:access_token].nil? || session[:instance_url].nil?
      access_token, instance_url = get_access_token_via_jwt
      session[:access_token] = access_token
      session[:instance_url] = instance_url
    end

    response = mint_frontdoor(session[:instance_url], session[:access_token])

    if [401, 403].include?(response.code.to_i)
      access_token, instance_url = get_access_token_via_jwt
      session[:access_token] = access_token
      session[:instance_url] = instance_url
      response = mint_frontdoor(instance_url, access_token)
    end

    unless response.is_a?(Net::HTTPSuccess)
      # Surface Salesforce's own reason: the body carries {"error","error_description"}. No
      # token/secret is in it, so it's safe to forward; truncate defensively.
      body = response.body.to_s.strip
      detail = body.empty? ? '' : ": #{body.length > 500 ? "#{body[0, 500]}…" : body}"
      halt response.code.to_i, { error: "Salesforce responded #{response.code}#{detail}." }.to_json
    end

    data = JSON.parse(response.body)
    { frontdoorUrl: data['frontdoor_uri'], orgUrl: ORG_URL }.to_json
  rescue JwtExchangeError => e
    # Same as /login: a Salesforce config rejection carries the upstream 4xx status; forward it.
    halt e.upstream_status, { error: e.message }.to_json
  rescue StandardError => e
    # A transport fault (token/instance host unreachable, timeout) is a 502 — an upstream we
    # depend on failed, not a bug in this broker. Matches the other ports' 502 for this case.
    halt 502, { error: e.message || 'Failed to generate FrontDoor URL' }.to_json
  end
end

get '/health' do
  content_type :json
  { ok: true }.to_json
end
```

---

## PHP (built-in server) — JWT Bearer

> *The JWT Bearer alternative — server-to-server, no login screen.* Like the Node JWT broker, this
> signs an RS256 assertion with the org's private key and exchanges it for an access token,
> impersonating a fixed user; the browser only ever sees the short-lived FrontDoor URL. The Node JWT
> broker it mirrors was **verified end-to-end on a real org** (JWT → FrontDoor URL → dashboard
> rendered); this is its faithful PHP port (`php -l` syntax-clean here, not separately run on an
> org), but **not part of the official SDK guide**, which documents Auth Code + PKCE. Complete the one-time
> [certificate & ECA setup](#certificate--eca-setup-for-jwt-bearer-one-time) first (generate
> `private.key` + `public.crt`, upload the cert to the ECA, enable the JWT Bearer flow).
> Dependency-free like the PKCE PHP broker — a small `createAssertion()` builds the JWT and signs it
> with the built-in **OpenSSL** extension (`openssl_sign(..., OPENSSL_ALGO_SHA256)`), so there is no
> Composer and no `firebase/php-jwt`. There is **no `CLIENT_SECRET`** and **no `REDIRECT_URI`** in
> this flow — the RS256 signature is the authentication.

**Run it.** From `token-broker-jwt/php/`. First get the private key into place — `PRIVATE_KEY_PATH`
is resolved **relative to the directory you run from**, not to `broker.php`, so the key must sit in
`token-broker-jwt/php/` (or set an absolute path). Then load the shared `.env`, confirm it loaded,
and start PHP's built-in server. The broker listens on **http://localhost:8500**.

```bash
cp /path/to/private.key token-broker-jwt/php/private.key   # only public.crt was uploaded to the ECA; keep this local
cd token-broker-jwt/php
ls -la private.key                                         # PRIVATE_KEY_PATH is cwd-relative — confirm the key is actually here
set -a; source ../.env; set +a                             # re-run after ANY .env edit, then restart the server
echo "ORG_URL=$ORG_URL"
php -S localhost:8500 broker.php
```

Then open **http://localhost:8500/login** once. There is **no Salesforce login screen** — the
exchange runs entirely server-side and `/login` bounces straight to your SPA, which calls
`GET /getFrontdoorURL`. Requires **PHP 8.x** with the **cURL and OpenSSL** extensions (both ship
enabled in a standard build). Env vars are the same set as the Node JWT `.env.example` above, minus
`CLIENT_SECRET` and `REDIRECT_URI`, which this flow doesn't use.

**`token-broker-jwt/php/broker.php`:**

```php
<?php

/**
 * Tableau Next embedding — server-side token broker (PHP, JWT Bearer flow).
 *
 * ALTERNATIVE to the Authorization Code + PKCE broker (token-broker/php/broker.php). Instead of
 * an interactive login, the server signs a JWT with its private key and exchanges it for an
 * access token, impersonating SF_USERNAME. It then mints the SAME FrontDoor URL the SDK consumes.
 *
 * Keep this on the server: it holds the private key and the raw access token; the browser only
 * ever sees the short-lived FrontDoor URL. There is NO client secret in this flow — the RS256
 * signature authenticates the client.
 *
 * Single-file front controller — run with the PHP built-in server:
 *   php -S localhost:8500 broker.php
 *
 * Endpoints:
 *   GET  /login            -> runs the JWT Bearer exchange server-side, stores the session, redirects to the SPA (no login screen)
 *   GET  /getFrontdoorURL  -> mints { frontdoorUrl, orgUrl } from the stored token (call this from the SPA)
 *   GET  /health           -> { ok: true }
 */

declare(strict_types=1);

// -----------------------------------------------------------------------------------------
// Config (from environment)
// -----------------------------------------------------------------------------------------

/** Read an env var, falling back to $default when unset or empty. */
function env(string $name, ?string $default = null): ?string
{
    $value = getenv($name);

    return $value === false || $value === '' ? $default : $value;
}

/** Fail fast (matching the Node reference's process.exit(1) at startup) when config is missing. */
function requireEnv(array $required): void
{
    foreach ($required as $name => $value) {
        if (!$value) {
            error_log("Missing required env var: {$name}. See .env.example.");
            http_response_code(500);
            header('Content-Type: text/plain; charset=utf-8');
            echo "Server misconfigured: missing required env var {$name}. See .env.example.";
            exit;
        }
    }
}

// The built-in server's listen port comes from the `php -S localhost:PORT` flag, not this var;
// read anyway so the env surface stays identical to the Node broker.
$port = env('PORT', '8500');
// OAuth login host. This is BOTH where we POST the token request AND the JWT `aud` claim.
$loginUrl = env('LOGIN_URL');
// Lightning My Domain host, e.g. https://yourorg.lightning.force.com — the SDK `orgUrl`.
$orgUrl = env('ORG_URL');
// ECA Consumer Key — becomes the JWT `iss` claim.
$clientId = env('CLIENT_ID');
// Salesforce username to impersonate — becomes the JWT `sub` claim. Must be pre-authorized on the ECA.
$sfUsername = env('SF_USERNAME');
// Optional: the private key PEM inline (\n-escaped). Takes precedence over PRIVATE_KEY_PATH.
$privateKeyInline = env('PRIVATE_KEY');
// Path to the unencrypted RSA private key whose cert is on the ECA. cwd-relative — see loadPrivateKey().
$privateKeyPath = env('PRIVATE_KEY_PATH', './private.key');
// SPA origin: where the browser is sent after /login, and the sole allowed CORS origin.
$appOrigin = env('APP_ORIGIN', 'http://localhost:8700');
// Read for env parity with the other brokers; PHP's native session module keys sessions by an
// unguessable server-side ID rather than a signed cookie, so this isn't consumed directly.
$sessionSecret = env('SESSION_SECRET', 'change-me-in-production');

requireEnv(['LOGIN_URL' => $loginUrl, 'ORG_URL' => $orgUrl, 'CLIENT_ID' => $clientId, 'SF_USERNAME' => $sfUsername]);

// -----------------------------------------------------------------------------------------
// Private key (fail fast)
//
// Load the signing key ONCE, from PRIVATE_KEY (inline PEM) or PRIVATE_KEY_PATH. Fail fast with a
// clean message — otherwise openssl_sign() later dies with a cryptic warning. PRIVATE_KEY_PATH is
// resolved relative to the CURRENT WORKING DIRECTORY, not this file — run the broker from its own
// directory or set an absolute path. The key must be UNENCRYPTED (the `openssl ... -nodes` command
// in the cert setup produces one); an encrypted key needs a passphrase this broker doesn't supply.
// -----------------------------------------------------------------------------------------

function loadPrivateKey(?string $inlinePem, string $path): OpenSSLAsymmetricKey
{
    if ($inlinePem !== null && $inlinePem !== '') {
        $pem = str_replace('\n', "\n", $inlinePem);
    } else {
        $pem = @file_get_contents($path); // @ so a missing file is a clean message, not a raw warning
        if ($pem === false) {
            error_log("Cannot read private key at {$path}.");
            http_response_code(500);
            header('Content-Type: text/plain; charset=utf-8');
            echo "Cannot read private key at {$path}. Copy private.key into this directory "
                . "(or set PRIVATE_KEY_PATH to an absolute path). See the certificate & ECA setup section.";
            exit;
        }
    }

    $key = openssl_pkey_get_private($pem);
    if ($key === false) {
        error_log('Private key is not a valid unencrypted PEM.');
        http_response_code(500);
        header('Content-Type: text/plain; charset=utf-8');
        echo 'Private key is not a valid unencrypted PEM. It must be an UNENCRYPTED RSA key '
            . '(generate it with the `openssl ... -nodes` command from the certificate & ECA setup).';
        exit;
    }

    return $key;
}

$privateKey = loadPrivateKey($privateKeyInline, $privateKeyPath);

// -----------------------------------------------------------------------------------------
// JWT helpers
// -----------------------------------------------------------------------------------------

function base64UrlEncode(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/**
 * Build and RS256-sign the JWT assertion. Claims: iss=client_id, sub=user to impersonate,
 * aud=login host, exp≤5min (Salesforce caps JWT Bearer assertions at 5 minutes), plus iat.
 * `openssl_sign(..., OPENSSL_ALGO_SHA256)` over an RSA key IS RS256 — no library needed.
 */
function createAssertion(OpenSSLAsymmetricKey $key, string $clientId, string $sub, string $aud): string
{
    $now = time();
    $header = ['alg' => 'RS256', 'typ' => 'JWT'];
    $claims = [
        'iss' => $clientId,
        'sub' => $sub,
        'aud' => $aud,
        'iat' => $now,
        'exp' => $now + 180, // 3 min — comfortably under Salesforce's 5-minute ceiling
    ];

    $signingInput = base64UrlEncode(json_encode($header, JSON_UNESCAPED_SLASHES))
        . '.' . base64UrlEncode(json_encode($claims, JSON_UNESCAPED_SLASHES));

    if (!openssl_sign($signingInput, $signature, $key, OPENSSL_ALGO_SHA256)) {
        // Unexpected: the key was validated at startup. A genuine server fault -> 500 (see handlers).
        throw new RuntimeException('Failed to sign the JWT assertion with the private key.');
    }

    return $signingInput . '.' . base64UrlEncode($signature);
}

// -----------------------------------------------------------------------------------------
// Errors
//
// Two distinct types so the handlers can map each to the RIGHT status: a Salesforce rejection is
// an expected 4xx config error, a dead token host is a 502, and anything else is a 500.
// -----------------------------------------------------------------------------------------

/** Salesforce rejected the assertion (e.g. invalid_grant). ->getCode() carries the upstream 4xx. */
final class JwtExchangeException extends RuntimeException {}

/** Transport-level failure (DNS, connect, timeout). Maps to 502. */
final class TransportException extends RuntimeException {}

// -----------------------------------------------------------------------------------------
// HTTP client helper (cURL)
// -----------------------------------------------------------------------------------------

/**
 * POST application/x-www-form-urlencoded to $url and return [statusCode, body].
 * $fields may be an empty array to send an empty body (used by mintFrontdoor).
 *
 * @throws TransportException on a transport-level failure (DNS, connect, timeout, ...).
 */
function httpPostForm(string $url, array $fields, array $extraHeaders = []): array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($fields),
        CURLOPT_HTTPHEADER => array_merge(
            ['Content-Type: application/x-www-form-urlencoded'],
            $extraHeaders,
        ),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new TransportException($error ?: 'cURL request failed');
    }

    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return [$status, (string) $body];
}

// -----------------------------------------------------------------------------------------
// Salesforce calls
// -----------------------------------------------------------------------------------------

/**
 * Exchange the signed JWT for an access token. No client_secret — the RS256 signature is the auth.
 *
 * @return array{0: string, 1: ?string} [$accessToken, $instanceUrl]
 * @throws JwtExchangeException on a Salesforce rejection, carrying the upstream HTTP status.
 * @throws TransportException   if the token host is unreachable.
 */
function getAccessTokenViaJwt(string $loginUrl, OpenSSLAsymmetricKey $key, string $clientId, string $sub): array
{
    $assertion = createAssertion($key, $clientId, $sub, $loginUrl); // aud == LOGIN_URL

    [$status, $body] = httpPostForm("{$loginUrl}/services/oauth2/token", [
        'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
        'assertion' => $assertion,
    ]);

    $data = json_decode($body, true) ?: [];
    if ($status < 200 || $status >= 300 || empty($data['access_token'])) {
        // Salesforce returns {error, error_description}; forward it (no secret/token in the body).
        // Carry the upstream status (e.g. 400 invalid_grant) so callers forward a 4xx, not a 5xx.
        $reason = $data['error_description'] ?? $data['error'] ?? "HTTP {$status}";
        throw new JwtExchangeException("JWT token exchange failed: {$reason}", $status ?: 400);
    }

    return [$data['access_token'], $data['instance_url'] ?? null];
}

/** POST the access token to the singleaccess endpoint to mint a FrontDoor URL. */
function mintFrontdoor(string $instanceUrl, string $accessToken): array
{
    return httpPostForm("{$instanceUrl}/services/oauth2/singleaccess", [], [
        'Accept: application/json',
        "Authorization: Bearer {$accessToken}",
    ]);
}

// -----------------------------------------------------------------------------------------
// Response helpers
// -----------------------------------------------------------------------------------------

function sendJson(int $status, array $payload): void
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($payload);
}

function sendText(int $status, string $message): void
{
    http_response_code($status);
    header('Content-Type: text/plain; charset=utf-8');
    echo $message;
}

// -----------------------------------------------------------------------------------------
// CORS
//
// The SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this broker
// (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these headers
// or the browser drops the response. Allow-Origin must name the exact origin (not '*') when
// credentials are involved. On localhost, :8700 and :8500 are the same *site*, so the
// SameSite=Lax session cookie is still sent — CORS just permits reading the response.
// -----------------------------------------------------------------------------------------

header('Access-Control-Allow-Origin: ' . $appOrigin);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Content-Type');
header('Vary: Origin');

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

if ($method === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// -----------------------------------------------------------------------------------------
// Session
//
// Server-side session holding accessToken and instanceUrl. HttpOnly + SameSite=Lax so the token
// never touches JS and the cookie still rides along on the /login -> SPA redirect.
// -----------------------------------------------------------------------------------------

session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'secure' => false, // set true once the broker is served over HTTPS
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

// -----------------------------------------------------------------------------------------
// Route handlers
// -----------------------------------------------------------------------------------------

/**
 * 1) Run the JWT Bearer exchange entirely server-side, stash the token, then bounce to the SPA.
 * There is NO interactive redirect to Salesforce — no login screen appears.
 */
function handleLogin(
    string $loginUrl,
    OpenSSLAsymmetricKey $key,
    string $clientId,
    string $sub,
    string $appOrigin,
): void {
    try {
        [$accessToken, $instanceUrl] = getAccessTokenViaJwt($loginUrl, $key, $clientId, $sub);
    } catch (JwtExchangeException $e) {
        // Salesforce rejected the assertion (invalid_grant etc.) — an expected CONFIG error;
        // forward its 4xx status, NOT a 5xx.
        sendText($e->getCode() ?: 400, $e->getMessage());
        return;
    } catch (TransportException $e) {
        // Couldn't reach the token host (wrong LOGIN_URL, network) — a transport fault.
        sendText(502, "JWT authentication failed: {$e->getMessage()}");
        return;
    } catch (Throwable $e) {
        // Anything else is a genuine, unexpected server fault.
        sendText(500, "JWT authentication failed: {$e->getMessage()}");
        return;
    }

    $_SESSION['accessToken'] = $accessToken;
    $_SESSION['instanceUrl'] = $instanceUrl;
    header("Location: {$appOrigin}"); // straight back to the SPA; it will call /getFrontdoorURL
}

/**
 * 2) Mint a FrontDoor URL from the stored token — running the exchange first if the session is
 * empty. The SPA fetches this. A JWT Bearer client can silently re-mint with no user interaction,
 * so on 401/403 we just re-run the exchange once and retry (no refresh-token dance).
 */
function handleGetFrontdoorUrl(
    string $orgUrl,
    string $loginUrl,
    OpenSSLAsymmetricKey $key,
    string $clientId,
    string $sub,
): void {
    try {
        if (empty($_SESSION['accessToken']) || empty($_SESSION['instanceUrl'])) {
            [$_SESSION['accessToken'], $_SESSION['instanceUrl']] = getAccessTokenViaJwt($loginUrl, $key, $clientId, $sub);
        }

        [$status, $body] = mintFrontdoor($_SESSION['instanceUrl'], $_SESSION['accessToken']);

        if ($status === 401 || $status === 403) {
            [$_SESSION['accessToken'], $_SESSION['instanceUrl']] = getAccessTokenViaJwt($loginUrl, $key, $clientId, $sub);
            [$status, $body] = mintFrontdoor($_SESSION['instanceUrl'], $_SESSION['accessToken']);
        }
    } catch (JwtExchangeException $e) {
        // Salesforce config rejection during the (re-)exchange — forward its 4xx.
        sendJson($e->getCode() ?: 400, ['error' => $e->getMessage()]);
        return;
    } catch (TransportException $e) {
        // Token host unreachable — transport fault.
        sendJson(502, ['error' => "Token host unreachable: {$e->getMessage()}"]);
        return;
    } catch (Throwable $e) {
        // Unexpected server fault.
        sendJson(500, ['error' => $e->getMessage() ?: 'Failed to generate FrontDoor URL']);
        return;
    }

    if ($status < 200 || $status >= 300) {
        // singleaccess itself failed (not the token exchange). Surface Salesforce's own reason:
        // the body carries {"error","error_description"} and holds no token/secret, so forward it,
        // truncated defensively.
        $trimmed = trim((string) $body);
        $detail = $trimmed === '' ? '' : ': ' . (strlen($trimmed) > 500 ? substr($trimmed, 0, 500) . '…' : $trimmed);
        sendJson($status, ['error' => "Salesforce responded {$status}{$detail}."]);
        return;
    }

    $data = json_decode($body, true) ?: [];
    sendJson(200, ['frontdoorUrl' => $data['frontdoor_uri'] ?? null, 'orgUrl' => $orgUrl]);
}

function handleHealth(): void
{
    sendJson(200, ['ok' => true]);
}

// -----------------------------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------------------------

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';

if ($method === 'GET' && $path === '/login') {
    handleLogin($loginUrl, $privateKey, $clientId, $sfUsername, $appOrigin);
} elseif ($method === 'GET' && $path === '/getFrontdoorURL') {
    handleGetFrontdoorUrl($orgUrl, $loginUrl, $privateKey, $clientId, $sfUsername);
} elseif ($method === 'GET' && $path === '/health') {
    handleHealth();
} else {
    sendText(404, 'Not Found');
}
```

> **Troubleshooting the exchange:** if `/login` returns a `400` with `invalid_grant`, that's an
> expected config error (the broker forwards Salesforce's status, not a 502) — the usual causes are
> the `sub` user not being pre-authorized on the ECA, an `aud` that doesn't match `LOGIN_URL`, clock
> skew pushing `exp`, or the uploaded public cert not matching `private.key`. A `502` means the token
> host was unreachable (wrong `LOGIN_URL`, network); a `500` is a genuine server fault (e.g. the
> private key couldn't sign). A successful `/getFrontdoorURL` returning a `frontdoorUrl` that renders
> in the SDK is the confirmation.

---

## Java (Spring Boot)

> *Reviewed against the contract, not executed here.* A Spring Boot app: an env-bound
> `@ConfigurationProperties`, a `@RestController` with the four endpoints, a `OncePerRequestFilter`
> for CORS, and `java.net.http.HttpClient` for the server-to-server POSTs. Session state lives in the
> servlet `HttpSession` (server-side; the cookie is only the opaque `JSESSIONID`).

**Run it.** From `token-broker/java/`, load the shared `.env` (see the Node section for how
`.env` loading and the `source` path work), confirm it loaded, then start. The broker
listens on **http://localhost:8500**.

```bash
cd token-broker/java
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
mvn spring-boot:run
```

Requires **JDK 21 (LTS)** and **Maven 3.9+**. Env vars are the same set as the Node
`.env.example` above. Pin JDK **21**, not "21+": the pinned Spring Boot 3.3.4 does **not**
support the newest JDKs (e.g. 26) — running Maven under one fails to build with obscure
compiler/bytecode errors. Install Zulu 21 (or any LTS 21 build) and point Maven at it.

> **Corporate / proxied Maven environments (Salesforce-internal especially): two-layer TLS wall.**
> On a first `mvn` behind the corporate Nexus proxy you'll likely hit
> `PKIX path building failed … unable to find valid certification path`. Two distinct layers,
> in order:
>
> 1. **Truststore.** The proxy chains to the *Salesforce Internal Root CA*, which is in the macOS
>    System keychain but **not** in the JDK's own `cacerts`. Export the cert from Keychain Access
>    and import it into the JDK you build with:
>    ```bash
>    keytool -import -trustcacerts -alias sfdc-internal-root \
>      -file sfdc-internal-root.pem \
>      -keystore "$(/usr/libexec/java_home -v 21)/lib/security/cacerts" -storepass changeit
>    ```
> 2. **Which JDK Maven actually runs.** After importing, it can *still* fail if Maven is running
>    under a **different** JDK than the one you imported into (a common trap: a Homebrew OpenJDK on
>    `PATH` shadowing your Zulu 21). Force Maven onto the JDK that has the cert:
>    ```bash
>    export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
>    mvn -version   # confirm "Java version: 21" and the Zulu/LTS path before retrying
>    ```
>
> On a clean, non-proxied setup none of this applies — it's specific to corporate TLS interception.

**`token-broker/java/pom.xml`:**

```xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.4</version>
    <relativePath/>
  </parent>

  <groupId>com.example</groupId>
  <artifactId>token-broker</artifactId>
  <version>1.0.0</version>
  <packaging>jar</packaging>

  <name>token-broker</name>
  <description>Salesforce OAuth token broker for the Analytics Embedding SDK</description>

  <properties>
    <java.version>21</java.version>
    <maven.compiler.source>21</maven.compiler.source>
    <maven.compiler.target>21</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>

  <dependencies>
    <!-- Web MVC + embedded Tomcat; also brings in Jackson for JSON. -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <finalName>token-broker</finalName>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>

</project>
```

**`token-broker/java/src/main/resources/application.properties`:**

```properties
# Port the broker listens on. Same env var / default as the Node reference (PORT=8500).
server.port=${PORT:8500}

# Keep Spring's banner/log noise down; the broker never logs secrets or tokens.
logging.level.root=INFO
logging.level.com.example.broker=INFO

# Session cookie: HttpOnly + SameSite=Lax, matching the Node reference.
# (:8700 -> :8500 on localhost is same-site, so the Lax cookie is still sent cross-port.)
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.same-site=lax
```

**`token-broker/java/src/main/java/com/example/broker/BrokerApplication.java`:**

```java
package com.example.broker;

import com.example.broker.config.BrokerProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * Bootstraps the Salesforce OAuth token broker.
 *
 * <p>Runs the Authorization Code + PKCE flow and mints a FrontDoor URL that the Analytics
 * Embedding SDK uses as {@code authCredential}. The raw access token — and the client secret,
 * if one is configured — never leave this process; the browser only ever receives the
 * short-lived FrontDoor URL.
 *
 * <p>See {@link com.example.broker.api.TokenBrokerController} for the four endpoints this
 * exposes, and {@link BrokerProperties} for how configuration is read (and validated) from the
 * environment.
 */
@SpringBootApplication
public class BrokerApplication {

	public static void main(String[] args) {
		SpringApplication.run(BrokerApplication.class, args);
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/config/BrokerProperties.java`:**

```java
package com.example.broker.config;

import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;

/**
 * Broker configuration, read straight from the environment (not Spring {@code @Value}/
 * {@code application.properties}) so the env var names and defaults line up exactly with the
 * Node reference implementation.
 *
 * <p>{@code LOGIN_URL}, {@code ORG_URL} and {@code CLIENT_ID} are required; a missing one fails
 * application startup immediately (mirrors the Node reference's {@code process.exit(1)}).
 * {@code CLIENT_SECRET} is optional: when set, it is appended to the token exchange request
 * (confidential client) and is never returned to the browser.
 */
@Component
public class BrokerProperties {

	private final String port;
	private final String loginUrl;
	private final String orgUrl;
	private final String clientId;
	private final String clientSecret;
	private final String redirectUri;
	private final String appOrigin;
	private final String sessionSecret;

	public BrokerProperties() {
		this.port = env("PORT", "8500");
		this.loginUrl = requireEnv("LOGIN_URL");
		this.orgUrl = requireEnv("ORG_URL");
		this.clientId = requireEnv("CLIENT_ID");
		this.clientSecret = env("CLIENT_SECRET", null);
		this.redirectUri = env("REDIRECT_URI", "http://localhost:" + this.port + "/getAccessToken");
		this.appOrigin = env("APP_ORIGIN", "http://localhost:8700");
		this.sessionSecret = env("SESSION_SECRET", "change-me-in-production");
	}

	private static String env(String name, String defaultValue) {
		String value = System.getenv(name);
		return (value == null || value.isBlank()) ? defaultValue : value;
	}

	/** Fails fast (mirrors the Node reference's {@code process.exit(1)} on a missing var). */
	private static String requireEnv(String name) {
		String value = System.getenv(name);
		if (value == null || value.isBlank()) {
			throw new IllegalStateException("Missing required env var: " + name + ". See .env.example.");
		}
		return value;
	}

	public String getPort() {
		return port;
	}

	public String getLoginUrl() {
		return loginUrl;
	}

	public String getOrgUrl() {
		return orgUrl;
	}

	public String getClientId() {
		return clientId;
	}

	/** May be {@code null} for a PKCE-only (public) client — never exposed outside this class. */
	public String getClientSecret() {
		return clientSecret;
	}

	public boolean hasClientSecret() {
		return clientSecret != null && !clientSecret.isBlank();
	}

	public String getRedirectUri() {
		return redirectUri;
	}

	public String getAppOrigin() {
		return appOrigin;
	}

	/**
	 * Read for parity with the Node reference's env var contract. Unlike Express's signed-cookie
	 * session store, the embedded Tomcat session used here (cookie settings in {@code
	 * application.properties}) identifies sessions by an opaque, server-generated ID rather than a
	 * value derived from this secret, so it's not wired into the session mechanism itself. Swap in
	 * a signed/distributed session store (e.g. Spring Session) for production and use this value
	 * there.
	 */
	public String getSessionSecret() {
		return sessionSecret;
	}

	/** Non-secret snapshot, safe to log at startup. */
	public Map<String, String> describeNonSecret() {
		Map<String, String> summary = new LinkedHashMap<>();
		summary.put("port", port);
		summary.put("loginUrl", loginUrl);
		summary.put("orgUrl", orgUrl);
		summary.put("redirectUri", redirectUri);
		summary.put("appOrigin", appOrigin);
		summary.put("clientSecretConfigured", String.valueOf(hasClientSecret()));
		return summary;
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/config/CorsFilter.java`:**

```java
package com.example.broker.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

/**
 * CORS for the SPA on {@code APP_ORIGIN}: a different origin than this broker, so its
 * credentialed {@code fetch('/getFrontdoorURL')} needs these headers or the browser drops the
 * response. {@code Access-Control-Allow-Origin} must name the exact origin (never {@code *})
 * because credentials are involved; on localhost, {@code :8700 -> :8500} is the same *site*, so
 * the {@code SameSite=Lax} session cookie is still sent regardless — CORS just lets the browser
 * read the response.
 *
 * <p>Runs ahead of every other filter ({@link Ordered#HIGHEST_PRECEDENCE}) so every response —
 * including the OPTIONS preflight, which never reaches a controller — carries these headers.
 * Mirrors the Node reference's hand-rolled middleware; for many origins or production, reach for
 * {@code WebMvcConfigurer#addCorsMappings} instead.
 */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter extends OncePerRequestFilter {

	private final BrokerProperties properties;

	public CorsFilter(BrokerProperties properties) {
		this.properties = properties;
	}

	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws ServletException, IOException {
		response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, properties.getAppOrigin());
		response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
		response.setHeader(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type");
		response.setHeader(HttpHeaders.VARY, "Origin");

		if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
			response.setStatus(HttpStatus.NO_CONTENT.value());
			return;
		}
		chain.doFilter(request, response);
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/config/StartupLogger.java`:**

```java
package com.example.broker.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

/**
 * Logs the broker's URL and a non-secret config summary once the embedded server is actually
 * listening — mirrors the Node reference's post-{@code app.listen} console output.
 */
@Component
public class StartupLogger {

	private static final Logger log = LoggerFactory.getLogger(StartupLogger.class);

	private final BrokerProperties properties;

	public StartupLogger(BrokerProperties properties) {
		this.properties = properties;
	}

	@EventListener(ApplicationReadyEvent.class)
	void logStartup() {
		log.info("Token broker on http://localhost:{}", properties.getPort());
		log.info("  Log in:  http://localhost:{}/login", properties.getPort());
		log.info("Config: {}", properties.describeNonSecret());
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/Pkce.java`:**

```java
package com.example.broker.oauth;

import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;

/**
 * PKCE (RFC 7636) helper: a high-entropy random {@code code_verifier} plus its S256
 * {@code code_challenge}. Matches the Node reference exactly — 64 random bytes, base64url with
 * no padding, SHA-256 for the challenge (never {@code plain}).
 */
public final class Pkce {

	private static final SecureRandom RANDOM = new SecureRandom();
	private static final Base64.Encoder BASE64_URL_NO_PAD = Base64.getUrlEncoder().withoutPadding();

	private Pkce() {}

	/** One generated verifier/challenge pair; the verifier is what gets stashed in the session. */
	public record Pair(String verifier, String challenge) {}

	public static Pair generate() {
		byte[] verifierBytes = new byte[64];
		RANDOM.nextBytes(verifierBytes);
		String verifier = base64Url(verifierBytes);

		String challenge = base64Url(sha256(verifier.getBytes(java.nio.charset.StandardCharsets.US_ASCII)));
		return new Pair(verifier, challenge);
	}

	/**
	 * A high-entropy random OAuth {@code state} value (32 bytes, base64url) for CSRF / login-mix-up
	 * protection — stored in the session at {@code /login} and verified on the callback.
	 */
	public static String generateState() {
		byte[] stateBytes = new byte[32];
		RANDOM.nextBytes(stateBytes);
		return base64Url(stateBytes);
	}

	private static byte[] sha256(byte[] input) {
		try {
			return MessageDigest.getInstance("SHA-256").digest(input);
		} catch (NoSuchAlgorithmException e) {
			// SHA-256 is guaranteed present on every standard JVM; this can't actually happen.
			throw new IllegalStateException("SHA-256 not available", e);
		}
	}

	private static String base64Url(byte[] bytes) {
		return BASE64_URL_NO_PAD.encodeToString(bytes);
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/FormEncoding.java`:**

```java
package com.example.broker.oauth;

import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.StringJoiner;

/**
 * Encodes a map of parameters as {@code application/x-www-form-urlencoded}. Shared by the
 * outbound token-exchange / single-access POST bodies ({@link SalesforceOAuthClient}) and the
 * {@code /login} authorize-URL query string, so the encoding rules live in exactly one place.
 */
public final class FormEncoding {

	private FormEncoding() {}

	public static String encode(Map<String, String> params) {
		StringJoiner joiner = new StringJoiner("&");
		params.forEach((key, value) -> joiner.add(urlEncode(key) + "=" + urlEncode(value)));
		return joiner.toString();
	}

	private static String urlEncode(String value) {
		return URLEncoder.encode(value, StandardCharsets.UTF_8);
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/TokenResponse.java`:**

```java
package com.example.broker.oauth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/**
 * Subset of the Salesforce {@code /services/oauth2/token} response we care about. Unknown
 * fields (e.g. {@code id}, {@code signature}, {@code issued_at}) are ignored.
 */
@JsonIgnoreProperties(ignoreUnknown = true)
public record TokenResponse(
		@JsonProperty("access_token") String accessToken,
		@JsonProperty("instance_url") String instanceUrl,
		@JsonProperty("refresh_token") String refreshToken,
		@JsonProperty("error") String error,
		@JsonProperty("error_description") String errorDescription) {}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/SingleAccessResponse.java`:**

```java
package com.example.broker.oauth;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

/** Subset of the Salesforce {@code /services/oauth2/singleaccess} response we care about. */
@JsonIgnoreProperties(ignoreUnknown = true)
public record SingleAccessResponse(@JsonProperty("frontdoor_uri") String frontdoorUri) {}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/UpstreamException.java`:**

```java
package com.example.broker.oauth;

/**
 * Raised when Salesforce responds to the token exchange or the FrontDoor (single-access)
 * request with a non-2xx status, or the call itself fails (network error, timeout). Carries
 * the HTTP status to propagate back to our own caller, per the broker contract.
 */
public class UpstreamException extends RuntimeException {

	private final int statusCode;

	public UpstreamException(int statusCode, String message) {
		super(message);
		this.statusCode = statusCode;
	}

	public UpstreamException(int statusCode, String message, Throwable cause) {
		super(message, cause);
		this.statusCode = statusCode;
	}

	public int getStatusCode() {
		return statusCode;
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/BrokerSession.java`:**

```java
package com.example.broker.oauth;

import jakarta.servlet.http.HttpSession;

/**
 * Thin, typed wrapper over {@link HttpSession} for the values this broker stashes there: the
 * PKCE verifier (between {@code /login} and {@code /getAccessToken}), and the access token +
 * instance URL + refresh token (between {@code /getAccessToken} and {@code /getFrontdoorURL}).
 * Keeps the raw attribute-name strings in one place instead of scattered across the controller.
 */
public final class BrokerSession {

	private static final String CODE_VERIFIER = "codeVerifier";
	private static final String OAUTH_STATE = "oauthState";
	private static final String ACCESS_TOKEN = "accessToken";
	private static final String INSTANCE_URL = "instanceUrl";
	private static final String REFRESH_TOKEN = "refreshToken";

	private final HttpSession session;

	public BrokerSession(HttpSession session) {
		this.session = session;
	}

	public void storeCodeVerifier(String verifier) {
		session.setAttribute(CODE_VERIFIER, verifier);
	}

	public String getCodeVerifier() {
		return (String) session.getAttribute(CODE_VERIFIER);
	}

	/** Stores the random per-session OAuth {@code state} (CSRF token) set at {@code /login}. */
	public void storeOAuthState(String state) {
		session.setAttribute(OAUTH_STATE, state);
	}

	public String getOAuthState() {
		return (String) session.getAttribute(OAUTH_STATE);
	}

	/** Clears the OAuth state after the callback validates it — it's single-use. */
	public void clearOAuthState() {
		session.removeAttribute(OAUTH_STATE);
	}

	/**
	 * Stores the access token + instance URL, and the refresh token when present. The refresh
	 * token powers the 401/403 auto-refresh in {@code /getFrontdoorURL}; it's granted only if the
	 * ECA allowed the {@code refresh_token} scope, so it may be {@code null}.
	 */
	public void storeTokens(String accessToken, String instanceUrl, String refreshToken) {
		session.setAttribute(ACCESS_TOKEN, accessToken);
		session.setAttribute(INSTANCE_URL, instanceUrl);
		if (refreshToken != null) {
			session.setAttribute(REFRESH_TOKEN, refreshToken);
		}
	}

	/** Updates just the access token (+ instance URL) after a successful refresh. */
	public void updateAccessToken(String accessToken, String instanceUrl) {
		session.setAttribute(ACCESS_TOKEN, accessToken);
		if (instanceUrl != null) {
			session.setAttribute(INSTANCE_URL, instanceUrl);
		}
	}

	public String getAccessToken() {
		return (String) session.getAttribute(ACCESS_TOKEN);
	}

	public String getInstanceUrl() {
		return (String) session.getAttribute(INSTANCE_URL);
	}

	public String getRefreshToken() {
		return (String) session.getAttribute(REFRESH_TOKEN);
	}

	public boolean isAuthenticated() {
		return getAccessToken() != null && getInstanceUrl() != null;
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/oauth/SalesforceOAuthClient.java`:**

```java
package com.example.broker.oauth;

import com.example.broker.config.BrokerProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Component;

/**
 * The two server-to-server calls this broker makes to Salesforce: the authorization-code token
 * exchange, and the FrontDoor (single-access) mint. Both are plain {@code application/x-www-
 * form-urlencoded} POSTs over {@link HttpClient} — no need for a heavier REST client here.
 */
@Component
public class SalesforceOAuthClient {

	private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(15);

	private final HttpClient httpClient;
	private final ObjectMapper objectMapper;
	private final BrokerProperties properties;

	public SalesforceOAuthClient(BrokerProperties properties) {
		this.properties = properties;
		this.httpClient = HttpClient.newBuilder().connectTimeout(REQUEST_TIMEOUT).build();
		this.objectMapper = new ObjectMapper();
	}

	/**
	 * POSTs the authorization code + PKCE verifier to {@code <LOGIN_URL>/services/oauth2/token}.
	 * Appends {@code client_secret} only when {@link BrokerProperties#hasClientSecret()} — the
	 * secret never leaves this method.
	 */
	public TokenResponse exchangeCodeForToken(String code, String codeVerifier) {
		Map<String, String> form = new LinkedHashMap<>();
		form.put("grant_type", "authorization_code");
		form.put("client_id", properties.getClientId());
		form.put("redirect_uri", properties.getRedirectUri());
		form.put("code", code);
		form.put("code_verifier", codeVerifier);
		if (properties.hasClientSecret()) {
			form.put("client_secret", properties.getClientSecret());
		}

		HttpRequest request = HttpRequest.newBuilder()
				.uri(URI.create(properties.getLoginUrl() + "/services/oauth2/token"))
				.timeout(REQUEST_TIMEOUT)
				.header("Content-Type", "application/x-www-form-urlencoded")
				.POST(HttpRequest.BodyPublishers.ofString(FormEncoding.encode(form)))
				.build();

		HttpResponse<String> response = send(request, "Token exchange");
		TokenResponse token = parse(response.body(), TokenResponse.class, "token response");

		if (response.statusCode() / 100 != 2 || token.error() != null) {
			String reason = token.errorDescription() != null ? token.errorDescription() : token.error();
			int status = response.statusCode() >= 400 ? response.statusCode() : 500;
			throw new UpstreamException(status, "Token exchange failed" + (reason != null ? ": " + reason : ""));
		}
		return token;
	}

	/**
	 * Exchanges a refresh token for a fresh access token at {@code <LOGIN_URL>/services/oauth2/token}.
	 * Returns the new {@link TokenResponse} on success, or {@code null} if the refresh failed (e.g.
	 * the org withheld the scope, or the refresh token was revoked/expired) — callers treat null as
	 * "couldn't refresh" and fall back to asking the user to re-authenticate.
	 */
	public TokenResponse refreshAccessToken(String refreshToken) {
		if (refreshToken == null || refreshToken.isEmpty()) {
			return null;
		}
		Map<String, String> form = new LinkedHashMap<>();
		form.put("grant_type", "refresh_token");
		form.put("client_id", properties.getClientId());
		form.put("refresh_token", refreshToken);
		if (properties.hasClientSecret()) {
			form.put("client_secret", properties.getClientSecret());
		}

		HttpRequest request = HttpRequest.newBuilder()
				.uri(URI.create(properties.getLoginUrl() + "/services/oauth2/token"))
				.timeout(REQUEST_TIMEOUT)
				.header("Content-Type", "application/x-www-form-urlencoded")
				.POST(HttpRequest.BodyPublishers.ofString(FormEncoding.encode(form)))
				.build();

		try {
			HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
			if (response.statusCode() / 100 != 2) {
				return null;
			}
			TokenResponse token = objectMapper.readValue(response.body(), TokenResponse.class);
			if (token.accessToken() == null || token.error() != null) {
				return null;
			}
			return token;
		} catch (IOException e) {
			return null;
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			return null;
		}
	}

	/**
	 * POSTs (empty body) to {@code <instanceUrl>/services/oauth2/singleaccess} with the session's
	 * bearer token, to mint a short-lived FrontDoor URL.
	 */
	public SingleAccessResponse mintFrontdoorUrl(String instanceUrl, String accessToken) {
		HttpRequest request = HttpRequest.newBuilder()
				.uri(URI.create(instanceUrl + "/services/oauth2/singleaccess"))
				.timeout(REQUEST_TIMEOUT)
				.header("Accept", "application/json")
				.header("Authorization", "Bearer " + accessToken)
				.header("Content-Type", "application/x-www-form-urlencoded")
				.POST(HttpRequest.BodyPublishers.ofString(""))
				.build();

		HttpResponse<String> response = send(request, "FrontDoor request");
		if (response.statusCode() / 100 != 2) {
			// Surface Salesforce's own reason: the body carries {"error","error_description"} that
			// distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
			// it's safe to forward; truncate defensively.
			String body = response.body() == null ? "" : response.body().strip();
			String detail = body.isEmpty() ? "" : ": " + (body.length() > 500 ? body.substring(0, 500) + "…" : body);
			throw new UpstreamException(response.statusCode(), "Salesforce responded " + response.statusCode() + detail);
		}
		return parse(response.body(), SingleAccessResponse.class, "single-access response");
	}

	private HttpResponse<String> send(HttpRequest request, String label) {
		try {
			return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
		} catch (IOException e) {
			throw new UpstreamException(502, label + " failed: " + e.getMessage(), e);
		} catch (InterruptedException e) {
			Thread.currentThread().interrupt();
			throw new UpstreamException(502, label + " interrupted", e);
		}
	}

	private <T> T parse(String body, Class<T> type, String label) {
		try {
			return objectMapper.readValue(body, type);
		} catch (IOException e) {
			throw new UpstreamException(502, "Malformed " + label + " from Salesforce", e);
		}
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/api/TokenBrokerController.java`:**

```java
package com.example.broker.api;

import com.example.broker.config.BrokerProperties;
import com.example.broker.oauth.BrokerSession;
import com.example.broker.oauth.FormEncoding;
import com.example.broker.oauth.Pkce;
import com.example.broker.oauth.SalesforceOAuthClient;
import com.example.broker.oauth.TokenResponse;
import com.example.broker.oauth.UpstreamException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.net.URI;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

/**
 * The four endpoints of the Salesforce OAuth token broker: {@code /login} kicks off the
 * Authorization Code + PKCE flow, {@code /getAccessToken} is its callback, {@code
 * /getFrontdoorURL} trades the session's access token for a short-lived FrontDoor URL the SPA
 * can use as the SDK's {@code authCredential}, and {@code /health} is a liveness check.
 *
 * <p>Mirrors the Node/Express reference route-for-route: same paths, same query params, same
 * status codes, and the same split between plain-text errors ({@code /getAccessToken}, matching
 * {@code res.send(...)}) and JSON errors ({@code /getFrontdoorURL}, matching {@code res.json(...)}).
 */
@RestController
public class TokenBrokerController {

	private final BrokerProperties properties;
	private final SalesforceOAuthClient oauthClient;

	public TokenBrokerController(BrokerProperties properties, SalesforceOAuthClient oauthClient) {
		this.properties = properties;
		this.oauthClient = oauthClient;
	}

	/** Starts OAuth: stash a fresh PKCE verifier in the session, redirect to Salesforce. */
	@GetMapping("/login")
	public ResponseEntity<Void> login(HttpServletRequest request) {
		Pkce.Pair pkce = Pkce.generate();
		BrokerSession session = new BrokerSession(request.getSession(true));
		session.storeCodeVerifier(pkce.verifier());
		// Random per-session state for CSRF / login-mix-up protection: echoed back on the callback
		// and must match. PKCE defends against code interception; state is the separate login-CSRF
		// defense — you want both. (A fixed constant would leave the callback forgeable.)
		String state = Pkce.generateState();
		session.storeOAuthState(state);

		Map<String, String> params = new LinkedHashMap<>();
		params.put("response_type", "code");
		params.put("client_id", properties.getClientId());
		params.put("redirect_uri", properties.getRedirectUri());
		params.put("scope", "api refresh_token lightning web");
		params.put("code_challenge", pkce.challenge());
		params.put("code_challenge_method", "S256");
		params.put("state", state);

		URI authorizeUri =
				URI.create(properties.getLoginUrl() + "/services/oauth2/authorize?" + FormEncoding.encode(params));
		return ResponseEntity.status(HttpStatus.FOUND).location(authorizeUri).build();
	}

	/** OAuth callback: exchange {@code code} (+ the session's PKCE verifier) for an access token. */
	@GetMapping("/getAccessToken")
	public ResponseEntity<String> getAccessToken(
			@RequestParam(required = false) String code,
			@RequestParam(required = false) String state,
			@RequestParam(required = false) String error,
			@RequestParam(name = "error_description", required = false) String errorDescription,
			HttpServletRequest request) {
		if (error != null) {
			return plainText(
					HttpStatus.BAD_REQUEST.value(), "OAuth error: " + (errorDescription != null ? errorDescription : error));
		}

		BrokerSession session = new BrokerSession(request.getSession(true));
		// Reject the callback unless state matches the value stored at /login (CSRF defense).
		String expectedState = session.getOAuthState();
		if (state == null || expectedState == null || !state.equals(expectedState)) {
			return plainText(HttpStatus.BAD_REQUEST.value(), "Invalid or missing OAuth state");
		}
		session.clearOAuthState(); // single-use

		String codeVerifier = session.getCodeVerifier();
		if (code == null || codeVerifier == null) {
			return plainText(HttpStatus.BAD_REQUEST.value(), "Missing code or PKCE verifier");
		}

		TokenResponse token;
		try {
			token = oauthClient.exchangeCodeForToken(code, codeVerifier);
		} catch (UpstreamException e) {
			// Use the raw status code (not HttpStatus.valueOf) — Salesforce/UpstreamException may
			// surface a code with no matching HttpStatus constant.
			return plainText(e.getStatusCode(), e.getMessage());
		}

		session.storeTokens(token.accessToken(), token.instanceUrl(), token.refreshToken());
		return ResponseEntity.status(HttpStatus.FOUND).location(URI.create(properties.getAppOrigin())).build();
	}

	/** Exchanges the session's access token for a FrontDoor URL. The SPA calls this. */
	@GetMapping("/getFrontdoorURL")
	public ResponseEntity<?> getFrontdoorUrl(HttpSession httpSession) {
		BrokerSession session = new BrokerSession(httpSession);
		if (!session.isAuthenticated()) {
			return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
					.body(new ErrorResponse("Not authenticated. Visit /login first."));
		}

		try {
			var singleAccess = oauthClient.mintFrontdoorUrl(session.getInstanceUrl(), session.getAccessToken());
			return ResponseEntity.ok(new FrontdoorResponse(singleAccess.frontdoorUri(), properties.getOrgUrl()));
		} catch (UpstreamException e) {
			// The access token expires (~2h). On 401/403, refresh once and retry so the SPA keeps
			// working without bouncing the user back through /login.
			if (e.getStatusCode() == 401 || e.getStatusCode() == 403) {
				TokenResponse refreshed = oauthClient.refreshAccessToken(session.getRefreshToken());
				if (refreshed != null) {
					session.updateAccessToken(refreshed.accessToken(), refreshed.instanceUrl());
					try {
						var singleAccess =
								oauthClient.mintFrontdoorUrl(session.getInstanceUrl(), session.getAccessToken());
						return ResponseEntity.ok(new FrontdoorResponse(singleAccess.frontdoorUri(), properties.getOrgUrl()));
					} catch (UpstreamException retryError) {
						return upstreamFailure(retryError);
					}
				}
			}
			// Non-401/403, or 401/403 with no usable refresh token: same terminal shape as the
			// other ports — status + "Salesforce responded N. Re-authenticate at /login."
			return upstreamFailure(e);
		}
	}

	/**
	 * Terminal FrontDoor failure response, matching the other six ports byte-for-byte: propagate
	 * the upstream status and append ". Re-authenticate at /login." to the message on every
	 * non-2xx path (not just the post-refresh retry).
	 */
	private static ResponseEntity<ErrorResponse> upstreamFailure(UpstreamException e) {
		return ResponseEntity.status(e.getStatusCode())
				.body(new ErrorResponse(e.getMessage() + ". Re-authenticate at /login."));
	}

	@GetMapping("/health")
	public HealthResponse health() {
		return new HealthResponse(true);
	}

	private static ResponseEntity<String> plainText(int status, String body) {
		return ResponseEntity.status(status).contentType(MediaType.TEXT_PLAIN).body(body);
	}
}
```

**`token-broker/java/src/main/java/com/example/broker/api/FrontdoorResponse.java`:**

```java
package com.example.broker.api;

import com.fasterxml.jackson.annotation.JsonInclude;

/** JSON body returned by {@code GET /getFrontdoorURL}: {@code {"frontdoorUrl", "orgUrl"}}. */
@JsonInclude(JsonInclude.Include.NON_NULL)
public record FrontdoorResponse(String frontdoorUrl, String orgUrl) {}
```

**`token-broker/java/src/main/java/com/example/broker/api/ErrorResponse.java`:**

```java
package com.example.broker.api;

/** JSON error body returned by the broker's endpoints: {@code {"error": "..."}}. */
public record ErrorResponse(String error) {}
```

**`token-broker/java/src/main/java/com/example/broker/api/HealthResponse.java`:**

```java
package com.example.broker.api;

/** JSON body returned by {@code GET /health}: {@code {"ok": true}}. */
public record HealthResponse(boolean ok) {}
```

---

## Python (FastAPI)

> *Reviewed against the contract, not executed here.* FastAPI + `httpx`, with Starlette's
> `SessionMiddleware` for server-side session state and `CORSMiddleware` for the credentialed origin.

**Run it.** From `token-broker/python/`, create a venv, install deps, load the shared `.env`
(see the Node section for how `.env` loading and the `source` path work), confirm it loaded,
then start. The broker listens on **http://localhost:8500**.

```bash
cd token-broker/python
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
uvicorn main:app --port 8500
```

Requires **Python 3.9+**. (Note: Starlette's `CORSMiddleware` answers preflight with `200`, not the
contract's literal `204`; browsers treat any 2xx preflight identically.)

**`token-broker/python/requirements.txt`:**

```text
fastapi==0.141.1
uvicorn[standard]==0.52.1
httpx==0.28.1
```

**`token-broker/python/main.py`:**

```python
"""Tableau Next embedding -- server-side token broker (Python / FastAPI).

Runs the OAuth Authorization Code + PKCE flow and mints a FrontDoor URL that the
frontend passes to the SDK as ``authCredential``. Keep this on the server: it holds the
client secret (if any) and the raw access token; the browser only ever sees the
short-lived FrontDoor URL.

Port of the Node/Express reference implementation (``token-broker/server.js``).

Endpoints:
    GET  /login            -> starts OAuth (redirects to Salesforce)
    GET  /getAccessToken   -> OAuth callback; exchanges code -> access token (stored in session)
    GET  /getFrontdoorURL  -> exchanges access token -> {frontdoorUrl, orgUrl} (call this from the SPA)
    GET  /health           -> {"ok": true}
"""

import base64
import hashlib
import os
import secrets
import sys
import threading

import httpx
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse
from starlette.middleware.cors import CORSMiddleware

# ---- Config (from environment) -----------------------------------------------------

PORT = int(os.environ.get("PORT", "8500"))

# Your org's login host, e.g. https://yourorg.my.salesforce.com or https://login.salesforce.com
LOGIN_URL = os.environ.get("LOGIN_URL")
# Your org's Lightning My Domain host, e.g. https://yourorg.lightning.force.com
ORG_URL = os.environ.get("ORG_URL")
# ECA Consumer Key
CLIENT_ID = os.environ.get("CLIENT_ID")
# ECA Consumer Secret. The reference ECA requires it (isSecretRequiredForTokenExchange: true);
# set it. Leave unset only for an ECA provisioned PKCE-only. invalid_client => it's required.
CLIENT_SECRET = os.environ.get("CLIENT_SECRET")
# Must exactly match a Callback URL registered on the ECA.
REDIRECT_URI = os.environ.get("REDIRECT_URI", f"http://localhost:{PORT}/getAccessToken")
# Where to send the user back after a successful login.
APP_ORIGIN = os.environ.get("APP_ORIGIN", "http://localhost:8700")
SESSION_SECRET = os.environ.get("SESSION_SECRET", "change-me-in-production")

for name, value in {"LOGIN_URL": LOGIN_URL, "ORG_URL": ORG_URL, "CLIENT_ID": CLIENT_ID}.items():
    if not value:
        print(f"Missing required env var: {name}. See .env.example.", file=sys.stderr)
        sys.exit(1)


# ---- PKCE helpers --------------------------------------------------------------------


def _base64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")


def generate_pkce() -> tuple[str, str]:
    """Return a (verifier, challenge) pair for OAuth 2.0 PKCE, S256 only."""
    verifier = _base64url(secrets.token_bytes(64))
    challenge = _base64url(hashlib.sha256(verifier.encode("ascii")).digest())
    return verifier, challenge


# ---- App -------------------------------------------------------------------------

app = FastAPI(title="Tableau Next Token Broker")

# CORS: the SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this
# broker (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these
# headers or the browser drops the response. Allow-Origin must name the exact origin (not '*')
# when credentials are involved. On localhost, :8700 and :8500 are the same *site*, so the
# SameSite=Lax session cookie is still sent -- CORS just permits reading the response.
app.add_middleware(
    CORSMiddleware,
    allow_origins=[APP_ORIGIN],
    allow_credentials=True,
    allow_methods=["GET", "OPTIONS"],
    allow_headers=["Content-Type"],
)

# ---- Server-side session store -------------------------------------------------------
# CRITICAL: session state (codeVerifier, accessToken, instanceUrl) must stay SERVER-SIDE,
# exactly like the Node reference's express-session MemoryStore. The cookie carries ONLY an
# opaque, unguessable session id -- never the token itself. (Starlette's SessionMiddleware is
# a *signed cookie* store, which would serialize the raw access_token into the browser cookie;
# that violates the "token never leaves the server" invariant, so we don't use it.)
#
# This in-memory dict is fine for local dev / a single process. For production or multiple
# workers, back it with a shared store (Redis, a database, etc.) so sessions survive restarts.
SESSION_COOKIE = "token_broker_session"
_sessions: dict[str, dict] = {}
_sessions_lock = threading.Lock()


def _get_session(request: Request) -> dict:
    """Return the server-side session dict for this request (empty if none yet)."""
    sid = request.cookies.get(SESSION_COOKIE)
    if not sid:
        return {}
    with _sessions_lock:
        return _sessions.get(sid, {})


def _save_session(response: Response, request: Request, data: dict) -> None:
    """Persist the session dict server-side and set the opaque-id cookie if new."""
    sid = request.cookies.get(SESSION_COOKIE)
    with _sessions_lock:
        if not sid or sid not in _sessions:
            sid = secrets.token_urlsafe(32)
        _sessions[sid] = data
    # HttpOnly + SameSite=Lax; the value is only the opaque id, never the token.
    response.set_cookie(
        SESSION_COOKIE, sid, httponly=True, samesite="lax", secure=False, path="/"
    )


@app.get("/login")
async def login(request: Request) -> RedirectResponse:
    """Kick off OAuth with PKCE."""
    verifier, challenge = generate_pkce()
    # Random per-session `state` for CSRF / login-mix-up protection: it's echoed back on the
    # callback and must match. PKCE defends against code interception; `state` is the separate
    # login-CSRF defense -- you want both. (A fixed constant would leave the callback forgeable.)
    state = secrets.token_urlsafe(32)

    params = {
        "response_type": "code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "scope": "api refresh_token lightning web",
        "code_challenge": challenge,
        "code_challenge_method": "S256",
        "state": state,
    }
    authorize_url = httpx.URL(f"{LOGIN_URL}/services/oauth2/authorize", params=params)
    response = RedirectResponse(url=str(authorize_url), status_code=302)
    # Stash the verifier + state server-side; the cookie on this redirect carries only the opaque id.
    session = _get_session(request)
    session["codeVerifier"] = verifier
    session["oauthState"] = state
    _save_session(response, request, session)
    return response


@app.get("/getAccessToken")
async def get_access_token(request: Request):
    """OAuth callback: exchange the authorization code for an access token."""
    query = request.query_params
    error = query.get("error")
    if error:
        error_description = query.get("error_description", error)
        return PlainTextResponse(f"OAuth error: {error_description}", status_code=400)

    session = _get_session(request)
    # Reject the callback unless `state` matches the value stored at /login (CSRF defense).
    state = query.get("state")
    expected_state = session.get("oauthState")
    if not state or not expected_state or state != expected_state:
        return PlainTextResponse("Invalid or missing OAuth state", status_code=400)
    session.pop("oauthState", None)  # single-use

    code = query.get("code")
    code_verifier = session.get("codeVerifier")
    if not code or not code_verifier:
        return PlainTextResponse("Missing code or PKCE verifier", status_code=400)

    data = {
        "grant_type": "authorization_code",
        "client_id": CLIENT_ID,
        "redirect_uri": REDIRECT_URI,
        "code": code,
        "code_verifier": code_verifier,
    }
    # Sent when configured. The reference ECA requires it; a PKCE-only ECA omits it.
    if CLIENT_SECRET:
        data["client_secret"] = CLIENT_SECRET

    try:
        async with httpx.AsyncClient() as client:
            resp = await client.post(f"{LOGIN_URL}/services/oauth2/token", data=data)
    except httpx.HTTPError as exc:
        return PlainTextResponse(f"Token exchange error: {exc}", status_code=500)

    token_data = resp.json()
    if resp.is_error or token_data.get("error"):
        message = token_data.get("error_description") or token_data.get("error") or "unknown"
        return PlainTextResponse(
            f"Token exchange failed: {message}", status_code=resp.status_code or 500
        )

    # Store the token + instance URL server-side, keyed by the same opaque session id.
    response = RedirectResponse(url=APP_ORIGIN, status_code=302)  # back to the SPA
    session["accessToken"] = token_data["access_token"]
    session["instanceUrl"] = token_data["instance_url"]
    # Stored so we can silently re-mint after the ~2h access-token TTL elapses. Present only
    # if the ECA granted the refresh_token scope (requested in /login); if the org withholds
    # it, refresh_access_token() is a no-op and the user re-runs /login by hand.
    session["refreshToken"] = token_data.get("refresh_token")
    _save_session(response, request, session)
    return response


async def _mint_frontdoor(client: httpx.AsyncClient, instance_url: str, access_token: str):
    """POST the access token to singleaccess to mint a FrontDoor URL."""
    return await client.post(
        f"{instance_url}/services/oauth2/singleaccess",
        headers={
            "accept": "application/json",
            "authorization": f"Bearer {access_token}",
            "content-type": "application/x-www-form-urlencoded",
        },
        content=b"",
    )


async def _refresh_access_token(client: httpx.AsyncClient, session: dict) -> bool:
    """Exchange the stored refresh token for a fresh access token, updating the session in
    place. Returns False if there's no refresh token or the refresh fails."""
    refresh_token = session.get("refreshToken")
    if not refresh_token:
        return False
    data = {"grant_type": "refresh_token", "client_id": CLIENT_ID, "refresh_token": refresh_token}
    if CLIENT_SECRET:
        data["client_secret"] = CLIENT_SECRET
    try:
        resp = await client.post(f"{LOGIN_URL}/services/oauth2/token", data=data)
    except httpx.HTTPError:
        return False
    token_data = resp.json()
    if resp.is_error or token_data.get("error") or not token_data.get("access_token"):
        return False
    session["accessToken"] = token_data["access_token"]
    # A refresh grant returns instance_url but no new refresh_token; keep the stored one.
    if token_data.get("instance_url"):
        session["instanceUrl"] = token_data["instance_url"]
    return True


@app.get("/getFrontdoorURL")
async def get_frontdoor_url(request: Request) -> JSONResponse:
    """Exchange the session's access token for a FrontDoor URL. The SPA calls this."""
    session = _get_session(request)
    access_token = session.get("accessToken")
    instance_url = session.get("instanceUrl")
    if not access_token or not instance_url:
        return JSONResponse({"error": "Not authenticated. Visit /login first."}, status_code=401)

    try:
        async with httpx.AsyncClient() as client:
            resp = await _mint_frontdoor(client, instance_url, access_token)
            # The access token expires (~2h). On 401/403, refresh once and retry so the SPA
            # keeps working without bouncing the user back through /login.
            if resp.status_code in (401, 403) and await _refresh_access_token(client, session):
                resp = await _mint_frontdoor(client, session["instanceUrl"], session["accessToken"])
    except httpx.HTTPError as exc:
        return JSONResponse({"error": str(exc) or "Failed to generate FrontDoor URL"}, status_code=500)

    if resp.is_error:
        # Still failing after the refresh attempt — the session is stale; the SPA should /login.
        # Surface Salesforce's own reason: the body carries {"error","error_description"} that
        # distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
        # it's safe to forward; truncate defensively.
        body = (resp.text or "").strip()
        detail = f": {body[:500]}…" if len(body) > 500 else (f": {body}" if body else "")
        return JSONResponse(
            {"error": f"Salesforce responded {resp.status_code}{detail}. Re-authenticate at /login."},
            status_code=resp.status_code,
        )

    data = resp.json()
    return JSONResponse({"frontdoorUrl": data.get("frontdoor_uri"), "orgUrl": ORG_URL})


@app.get("/health")
async def health() -> JSONResponse:
    return JSONResponse({"ok": True})


if __name__ == "__main__":
    import uvicorn

    print(f"Token broker on http://localhost:{PORT}")
    print(f"  Log in:  http://localhost:{PORT}/login")
    uvicorn.run(app, host="0.0.0.0", port=PORT)
```

---

## Go (net/http)

> *Reviewed against the contract; `go vet` clean.* Standard-library `net/http` with method-qualified
> `ServeMux` routes (Go 1.22+). Session state is an in-memory map keyed by a `crypto/rand` opaque id
> (server-side; the cookie carries only that id, `HttpOnly` + `SameSite=Lax`) — the closest match to
> the Node reference's `express-session` `MemoryStore`.

**Run it.** From `token-broker/go/`, load the shared `.env` (see the Node section for how
`.env` loading and the `source` path work), confirm it loaded, then run. The broker listens
on **http://localhost:8500**. There's no dependency-install step — this port is
**standard-library only**, so `go run .` compiles and starts it directly. (If you ran
`go mod download`, seeing `go: no module dependencies to download` is expected, not an
error.)

```bash
cd token-broker/go
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
go run .
```

Requires **Go 1.22+**. Reads config directly from `os.Getenv` (no `.env` loader) — export the same vars.

**`token-broker/go/go.mod`:**

```text
module tableau-next-token-broker

go 1.22
```

**`token-broker/go/main.go`:**

```go
// Command tableau-next-token-broker runs the Salesforce OAuth Authorization Code + PKCE
// flow and mints a FrontDoor URL for the Analytics Embedding SDK.
//
// Keep this server-side: it holds the client secret (if any) and the raw access token.
// The browser only ever receives the short-lived FrontDoor URL.
//
// Endpoints:
//
//	GET /login            starts OAuth (redirects to Salesforce)
//	GET /getAccessToken   OAuth callback; exchanges code -> access token (stored in session)
//	GET /getFrontdoorURL  exchanges access token -> {frontdoorUrl, orgUrl} (call from the SPA)
//	GET /health           liveness check
package main

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
)

// config holds the broker's settings, all sourced from the environment.
type config struct {
	port         string
	loginURL     string
	orgURL       string
	clientID     string
	clientSecret string
	redirectURI  string
	appOrigin    string
	// sessionSecret is read for parity with the other broker ports' env surface (SESSION_SECRET
	// is documented everywhere), but this broker's in-memory session store keys sessions by an
	// unguessable server-side id rather than a signed cookie, so it isn't consumed today.
	sessionSecret string
}

// loadConfig reads configuration from the environment and fails fast if a required
// value is missing, mirroring the Node reference implementation.
func loadConfig() config {
	port := getEnv("PORT", "8500")
	cfg := config{
		port:          port,
		loginURL:      os.Getenv("LOGIN_URL"),
		orgURL:        os.Getenv("ORG_URL"),
		clientID:      os.Getenv("CLIENT_ID"),
		clientSecret:  os.Getenv("CLIENT_SECRET"),
		redirectURI:   getEnv("REDIRECT_URI", fmt.Sprintf("http://localhost:%s/getAccessToken", port)),
		appOrigin:     getEnv("APP_ORIGIN", "http://localhost:8700"),
		sessionSecret: getEnv("SESSION_SECRET", "change-me-in-production"),
	}

	required := map[string]string{
		"LOGIN_URL": cfg.loginURL,
		"ORG_URL":   cfg.orgURL,
		"CLIENT_ID": cfg.clientID,
	}
	for _, name := range []string{"LOGIN_URL", "ORG_URL", "CLIENT_ID"} {
		if required[name] == "" {
			log.Fatalf("Missing required env var: %s. See .env.example.", name)
		}
	}
	return cfg
}

func getEnv(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

// broker holds everything a request handler needs: config, an HTTP client for the
// server-to-server calls to Salesforce, and the session store.
type broker struct {
	cfg      config
	sessions *sessionStore
	client   *http.Client
}

const sessionCookieName = "token-broker-session"

// sessionData is the state the broker stashes across requests: the PKCE verifier (between
// /login and /getAccessToken) and the access token + instance URL + refresh token (between
// /getAccessToken and /getFrontdoorURL). It lives only server-side -- never in the cookie.
type sessionData struct {
	codeVerifier string
	// oauthState is the random per-session CSRF token echoed back on the callback (set in
	// handleLogin, verified + cleared in handleGetAccessToken).
	oauthState  string
	accessToken string
	instanceUrl string
	// refreshToken is stored so we can silently re-mint after the ~2h access-token TTL
	// elapses. Present only if the ECA granted the refresh_token scope (requested in /login);
	// if the org withholds it, refreshAccessToken is a no-op and the user re-runs /login.
	refreshToken string
}

// sessionStore is a minimal, single-process, in-memory session store: state lives in this
// map, keyed by a crypto/rand opaque id, and the browser's cookie carries only that id --
// never the session payload. This is the Go analog of express-session's default
// MemoryStore. A real deployment (multi-process, or one that must survive restarts) should
// back this with a shared store instead (e.g. Redis); see the README.
type sessionStore struct {
	mu   sync.Mutex
	data map[string]*sessionData
}

func newSessionStore() *sessionStore {
	return &sessionStore{data: make(map[string]*sessionData)}
}

// newSessionID returns a high-entropy, unguessable id (32 random bytes, base64url-encoded)
// suitable for use as an opaque session-cookie value.
func newSessionID() (string, error) {
	raw := make([]byte, 32)
	if _, err := rand.Read(raw); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(raw), nil
}

// load returns the session named by the request's cookie, or nil if there isn't one --
// e.g. no cookie yet, or a stale id (this store doesn't survive restarts).
func (s *sessionStore) load(r *http.Request) *sessionData {
	cookie, err := r.Cookie(sessionCookieName)
	if err != nil {
		return nil
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.data[cookie.Value]
}

// create allocates a new session under a fresh opaque id, sets that id as an
// HttpOnly+SameSite=Lax cookie on the response, and returns the (empty) session for the
// caller to populate.
func (s *sessionStore) create(w http.ResponseWriter) (*sessionData, error) {
	id, err := newSessionID()
	if err != nil {
		return nil, err
	}
	sess := &sessionData{}
	s.mu.Lock()
	s.data[id] = sess
	s.mu.Unlock()

	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    id,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
	})
	return sess, nil
}

// getOrCreate returns the request's existing session, or creates (and cookies) a fresh one
// if the request has none. A tampered or stale cookie shouldn't block login, so this never
// errors on a bad incoming cookie -- it just starts over.
func (s *sessionStore) getOrCreate(r *http.Request, w http.ResponseWriter) (*sessionData, error) {
	if sess := s.load(r); sess != nil {
		return sess, nil
	}
	return s.create(w)
}

// generatePKCE returns a high-entropy code_verifier and its S256 code_challenge, per
// RFC 7636. The verifier and challenge are both base64url-encoded with no padding.
func generatePKCE() (verifier, challenge string, err error) {
	raw := make([]byte, 64)
	if _, err = rand.Read(raw); err != nil {
		return "", "", err
	}
	verifier = base64.RawURLEncoding.EncodeToString(raw)
	sum := sha256.Sum256([]byte(verifier))
	challenge = base64.RawURLEncoding.EncodeToString(sum[:])
	return verifier, challenge, nil
}

// handleLogin starts the OAuth flow: it generates a PKCE pair, stashes the verifier in
// the session, and redirects the browser to Salesforce's authorize endpoint.
func (b *broker) handleLogin(w http.ResponseWriter, r *http.Request) {
	verifier, challenge, err := generatePKCE()
	if err != nil {
		http.Error(w, "Failed to generate PKCE challenge", http.StatusInternalServerError)
		return
	}

	sess, err := b.sessions.getOrCreate(r, w)
	if err != nil {
		http.Error(w, "Failed to persist session", http.StatusInternalServerError)
		return
	}
	sess.codeVerifier = verifier

	// Random per-session state for CSRF / login-mix-up protection: echoed back on the callback
	// and must match. PKCE defends against code interception; state is the separate login-CSRF
	// defense -- you want both. (A fixed constant would leave the callback forgeable.)
	state, err := newSessionID()
	if err != nil {
		http.Error(w, "Failed to generate OAuth state", http.StatusInternalServerError)
		return
	}
	sess.oauthState = state

	params := url.Values{
		"response_type":         {"code"},
		"client_id":             {b.cfg.clientID},
		"redirect_uri":          {b.cfg.redirectURI},
		"scope":                 {"api refresh_token lightning web"},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
		"state":                 {state},
	}
	http.Redirect(w, r, b.cfg.loginURL+"/services/oauth2/authorize?"+params.Encode(), http.StatusFound)
}

// tokenResponse is the subset of Salesforce's /services/oauth2/token response we need.
type tokenResponse struct {
	AccessToken      string `json:"access_token"`
	InstanceURL      string `json:"instance_url"`
	RefreshToken     string `json:"refresh_token"`
	Error            string `json:"error"`
	ErrorDescription string `json:"error_description"`
}

// handleGetAccessToken is the OAuth callback: it exchanges the authorization code (plus
// the PKCE verifier stashed in the session by handleLogin) for an access token, then
// stores the token and instance URL in the session before sending the browser home.
func (b *broker) handleGetAccessToken(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	if oauthErr := q.Get("error"); oauthErr != "" {
		desc := q.Get("error_description")
		if desc == "" {
			desc = oauthErr
		}
		http.Error(w, "OAuth error: "+desc, http.StatusBadRequest)
		return
	}

	sess := b.sessions.load(r)
	// Reject the callback unless state matches the value stored at /login (CSRF defense).
	state := q.Get("state")
	expectedState := ""
	if sess != nil {
		expectedState = sess.oauthState
	}
	if state == "" || expectedState == "" || state != expectedState {
		http.Error(w, "Invalid or missing OAuth state", http.StatusBadRequest)
		return
	}
	sess.oauthState = "" // single-use

	code := q.Get("code")
	verifier := ""
	if sess != nil {
		verifier = sess.codeVerifier
	}
	if code == "" || verifier == "" {
		http.Error(w, "Missing code or PKCE verifier", http.StatusBadRequest)
		return
	}

	form := url.Values{
		"grant_type":    {"authorization_code"},
		"client_id":     {b.cfg.clientID},
		"redirect_uri":  {b.cfg.redirectURI},
		"code":          {code},
		"code_verifier": {verifier},
	}
	// Sent only when configured; the reference ECA requires it, a PKCE-only ECA omits it.
	if b.cfg.clientSecret != "" {
		form.Set("client_secret", b.cfg.clientSecret)
	}

	resp, err := b.client.Post(
		b.cfg.loginURL+"/services/oauth2/token",
		"application/x-www-form-urlencoded",
		strings.NewReader(form.Encode()),
	)
	if err != nil {
		http.Error(w, "Token exchange error: "+err.Error(), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	var data tokenResponse
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		http.Error(w, "Token exchange error: invalid response from Salesforce", http.StatusInternalServerError)
		return
	}
	if resp.StatusCode < 200 || resp.StatusCode >= 300 || data.Error != "" {
		status := resp.StatusCode
		if status < 400 {
			status = http.StatusInternalServerError
		}
		msg := data.ErrorDescription
		if msg == "" {
			msg = data.Error
		}
		if msg == "" {
			msg = "unknown"
		}
		http.Error(w, "Token exchange failed: "+msg, status)
		return
	}

	sess.accessToken = data.AccessToken
	sess.instanceUrl = data.InstanceURL
	sess.refreshToken = data.RefreshToken
	// The verifier is single-use; drop it now that the exchange succeeded.
	sess.codeVerifier = ""

	http.Redirect(w, r, b.cfg.appOrigin, http.StatusFound)
}

// refreshAccessToken exchanges the session's refresh token for a fresh access token,
// updating the session in place. Returns false if there's no refresh token or the refresh
// fails (e.g. the org withheld the scope, or the refresh token was revoked/expired).
func (b *broker) refreshAccessToken(sess *sessionData) bool {
	if sess.refreshToken == "" {
		return false
	}
	form := url.Values{
		"grant_type":    {"refresh_token"},
		"client_id":     {b.cfg.clientID},
		"refresh_token": {sess.refreshToken},
	}
	if b.cfg.clientSecret != "" {
		form.Set("client_secret", b.cfg.clientSecret)
	}
	resp, err := b.client.Post(
		b.cfg.loginURL+"/services/oauth2/token",
		"application/x-www-form-urlencoded",
		strings.NewReader(form.Encode()),
	)
	if err != nil {
		return false
	}
	defer resp.Body.Close()

	var data tokenResponse
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		return false
	}
	if resp.StatusCode/100 != 2 || data.Error != "" || data.AccessToken == "" {
		return false
	}
	sess.accessToken = data.AccessToken
	// A refresh grant returns instance_url but no new refresh_token; keep the stored one.
	if data.InstanceURL != "" {
		sess.instanceUrl = data.InstanceURL
	}
	return true
}

// mintFrontdoor POSTs the access token to the singleaccess endpoint to mint a FrontDoor URL.
func (b *broker) mintFrontdoor(instanceURL, accessToken string) (*http.Response, error) {
	req, err := http.NewRequest(http.MethodPost, instanceURL+"/services/oauth2/singleaccess", strings.NewReader(""))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Accept", "application/json")
	req.Header.Set("Authorization", "Bearer "+accessToken)
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	return b.client.Do(req)
}

// frontdoorResponse is the JSON shape returned to the SPA by handleGetFrontdoorURL.
type frontdoorResponse struct {
	FrontdoorURL string `json:"frontdoorUrl,omitempty"`
	OrgURL       string `json:"orgUrl,omitempty"`
	Error        string `json:"error,omitempty"`
}

// singleAccessResponse is the subset of /services/oauth2/singleaccess we need.
type singleAccessResponse struct {
	FrontdoorURI string `json:"frontdoor_uri"`
}

// handleGetFrontdoorURL exchanges the session's access token for a FrontDoor URL. This
// is the only endpoint the SPA needs to call after login; the raw access token never
// leaves the server.
func (b *broker) handleGetFrontdoorURL(w http.ResponseWriter, r *http.Request) {
	sess := b.sessions.load(r)
	accessToken, instanceURL := "", ""
	if sess != nil {
		accessToken, instanceURL = sess.accessToken, sess.instanceUrl
	}
	if accessToken == "" || instanceURL == "" {
		writeJSON(w, http.StatusUnauthorized, frontdoorResponse{Error: "Not authenticated. Visit /login first."})
		return
	}

	resp, err := b.mintFrontdoor(instanceURL, accessToken)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, frontdoorResponse{Error: "Failed to generate FrontDoor URL"})
		return
	}
	defer resp.Body.Close()

	// The access token expires (~2h). On 401/403, refresh once and retry so the SPA keeps
	// working without bouncing the user back through /login.
	if (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) && b.refreshAccessToken(sess) {
		resp.Body.Close()
		resp, err = b.mintFrontdoor(sess.instanceUrl, sess.accessToken)
		if err != nil {
			writeJSON(w, http.StatusInternalServerError, frontdoorResponse{Error: "Failed to generate FrontDoor URL"})
			return
		}
		defer resp.Body.Close()
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		// Still failing after the refresh attempt — the session is stale; the SPA should /login.
		// Surface Salesforce's own reason: the body carries {"error","error_description"} that
		// distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
		// it's safe to forward; truncate defensively.
		raw, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		detail := strings.TrimSpace(string(raw))
		if len(detail) > 500 {
			detail = detail[:500] + "…"
		}
		if detail != "" {
			detail = ": " + detail
		}
		writeJSON(w, resp.StatusCode, frontdoorResponse{Error: fmt.Sprintf("Salesforce responded %d%s. Re-authenticate at /login.", resp.StatusCode, detail)})
		return
	}

	var data singleAccessResponse
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		writeJSON(w, http.StatusInternalServerError, frontdoorResponse{Error: "Invalid response from Salesforce"})
		return
	}

	writeJSON(w, http.StatusOK, frontdoorResponse{FrontdoorURL: data.FrontdoorURI, OrgURL: b.cfg.orgURL})
}

func handleHealth(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(body)
}

// withCORS answers preflight requests and adds the CORS headers every response needs so
// the SPA on appOrigin can read a credentialed response. Access-Control-Allow-Origin must
// name the exact origin (never "*") because credentials are involved.
func withCORS(appOrigin string, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", appOrigin)
		w.Header().Set("Access-Control-Allow-Credentials", "true")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		w.Header().Set("Vary", "Origin")
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func main() {
	cfg := loadConfig()

	b := &broker{
		cfg:      cfg,
		sessions: newSessionStore(),
		client:   &http.Client{},
	}

	// Method-qualified patterns (Go 1.22+) so each path responds only to GET, matching the
	// contract and the golden Express reference's app.get(...). A non-GET request to a known
	// path gets a 405 from the mux rather than falling through to a handler.
	mux := http.NewServeMux()
	mux.HandleFunc("GET /login", b.handleLogin)
	mux.HandleFunc("GET /getAccessToken", b.handleGetAccessToken)
	mux.HandleFunc("GET /getFrontdoorURL", b.handleGetFrontdoorURL)
	mux.HandleFunc("GET /health", handleHealth)

	addr := ":" + cfg.port
	log.Printf("Token broker on http://localhost:%s", cfg.port)
	log.Printf("  Log in:  http://localhost:%s/login", cfg.port)
	if err := http.ListenAndServe(addr, withCORS(cfg.appOrigin, mux)); err != nil {
		log.Fatal(err)
	}
}
```

---

## C# (ASP.NET Core)

> *Execution-verified — built with `dotnet build` (0 warnings) and exercised end-to-end against a
> mock Salesforce server: `/login` → `/getAccessToken` → `/getFrontdoorURL`, including the PKCE
> round-trip, the `client_secret`-only-when-set assertion, and the empty-body Bearer call.* ASP.NET
> Core 8 minimal API, zero third-party NuGet packages. Session state uses distributed-memory-backed
> `HttpContext.Session` (server-side; the cookie is an opaque encrypted id).

**Run it.** From `token-broker/dotnet/`, load the shared `.env` (see the Node section for how
`.env` loading and the `source` path work), confirm it loaded, then run. The broker listens
on **http://localhost:8500**.

```bash
cd token-broker/dotnet
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
dotnet run
```

Requires the **.NET 8 SDK**. Env vars are the same set as the Node `.env.example` above.

**`token-broker/dotnet/broker.csproj`:**

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <RootNamespace>TokenBroker</RootNamespace>
    <AssemblyName>token-broker</AssemblyName>
    <InvariantGlobalization>true</InvariantGlobalization>
    <!-- No third-party NuGet packages: sessions, CORS primitives, HttpClientFactory, and
         WebUtilities (Base64Url / query-string helpers) all ship in the ASP.NET Core shared
         framework that Microsoft.NET.Sdk.Web already references. -->
  </PropertyGroup>

</Project>
```

**`token-broker/dotnet/Program.cs`:**

```csharp
// Tableau Next embedding — server-side token broker (C# / ASP.NET Core 8, minimal API).
//
// Runs the OAuth Authorization Code + PKCE flow and mints a FrontDoor URL that the
// frontend passes to the SDK as `authCredential`. Keep this on the server: it holds the
// client secret (if any) and the raw access token; the browser only ever sees the
// short-lived FrontDoor URL.
//
// Port of the Node/Express reference implementation (token-broker/server.js). Same
// contract, same endpoints, same env vars — pick whichever runtime fits your stack.
//
// Endpoints:
//   GET  /login            -> starts OAuth (redirects to Salesforce)
//   GET  /getAccessToken   -> OAuth callback; exchanges code -> access token (stored in session)
//   GET  /getFrontdoorURL  -> exchanges access token -> { frontdoorUrl, orgUrl } (call from the SPA)
//   GET  /health           -> { "ok": true }

using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Http;

// ---- Config (from environment) --------------------------------------------------------
// Read directly from the environment (not appsettings.json/IConfiguration) so the env var
// names and defaults line up exactly with the Node reference's `.env.example`. A missing
// required var fails fast — before we ever bind a port — mirroring the Node reference's
// `process.exit(1)`.
BrokerConfig config = BrokerConfig.LoadOrExit();

var builder = WebApplication.CreateBuilder(args);

// Server-side session: ASP.NET Core's session middleware stores the payload (codeVerifier,
// accessToken, instanceUrl) in the configured cache — here, an in-memory distributed cache,
// the .NET analog of express-session's default MemoryStore — and puts only an opaque,
// server-generated session id in the cookie. The cookie never carries the token itself.
builder.Services.AddDistributedMemoryCache();
builder.Services.AddSession(options =>
{
	options.Cookie.Name = ".TokenBroker.Session";
	options.Cookie.HttpOnly = true;
	options.Cookie.SameSite = SameSiteMode.Lax;
	// On localhost, :8700 -> :8500 is the same *site*, so the Lax cookie is still sent
	// cross-port even though CORS treats them as different origins.
	options.IdleTimeout = TimeSpan.FromMinutes(30);
});

// A single named HttpClient (via IHttpClientFactory) for the two server-to-server POSTs:
// the token exchange and the FrontDoor (single-access) mint.
builder.Services.AddHttpClient("salesforce", client =>
{
	client.Timeout = TimeSpan.FromSeconds(15);
});

var app = builder.Build();

// CORS: the SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this
// broker (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these
// headers or the browser drops the response. Allow-Origin must name the exact origin (never
// "*") because credentials are involved. On localhost, :8700 -> :8500 is the same *site*, so
// the SameSite=Lax session cookie is still sent — CORS just permits reading the response.
// Hand-rolled (rather than the AddCors/UseCors abstraction) so every response — CORS or
// not, preflight or not — carries these exact headers, mirroring the Node reference's
// middleware and the Go/Java ports' equivalent wrappers byte-for-byte.
app.Use(async (context, next) =>
{
	context.Response.Headers["Access-Control-Allow-Origin"] = config.AppOrigin;
	context.Response.Headers["Access-Control-Allow-Credentials"] = "true";
	context.Response.Headers["Access-Control-Allow-Headers"] = "Content-Type";
	context.Response.Headers["Vary"] = "Origin";
	if (HttpMethods.IsOptions(context.Request.Method))
	{
		context.Response.StatusCode = StatusCodes.Status204NoContent;
		return;
	}
	await next();
});

app.UseSession();

// 1) Kick off OAuth with PKCE.
app.MapGet("/login", (HttpContext context) =>
{
	var (verifier, challenge) = Pkce.Generate();
	context.Session.SetString("codeVerifier", verifier);
	// Random per-session state for CSRF / login-mix-up protection: echoed back on the callback
	// and must match. PKCE defends against code interception; state is the separate login-CSRF
	// defense — you want both. (A fixed constant would leave the callback forgeable.)
	var state = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32));
	context.Session.SetString("oauthState", state);

	var authorizeUrl = QueryHelpers.AddQueryString($"{config.LoginUrl}/services/oauth2/authorize", new Dictionary<string, string?>
	{
		["response_type"] = "code",
		["client_id"] = config.ClientId,
		["redirect_uri"] = config.RedirectUri,
		["scope"] = "api refresh_token lightning web",
		["code_challenge"] = challenge,
		["code_challenge_method"] = "S256",
		["state"] = state,
	});

	return Results.Redirect(authorizeUrl); // 302
});

// 2) OAuth callback -> exchange the code for an access token.
app.MapGet("/getAccessToken", async (HttpContext context, IHttpClientFactory httpClientFactory) =>
{
	var query = context.Request.Query;

	var error = query.TryGetValue("error", out var errorValues) ? errorValues.ToString() : null;
	if (!string.IsNullOrEmpty(error))
	{
		var description = query.TryGetValue("error_description", out var descValues) ? descValues.ToString() : error;
		return PlainText(StatusCodes.Status400BadRequest, $"OAuth error: {description}");
	}

	// Reject the callback unless state matches the value stored at /login (CSRF defense).
	var state = query.TryGetValue("state", out var stateValues) ? stateValues.ToString() : null;
	var expectedState = context.Session.GetString("oauthState");
	if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(expectedState) || state != expectedState)
	{
		return PlainText(StatusCodes.Status400BadRequest, "Invalid or missing OAuth state");
	}
	context.Session.Remove("oauthState"); // single-use

	var code = query.TryGetValue("code", out var codeValues) ? codeValues.ToString() : null;
	var codeVerifier = context.Session.GetString("codeVerifier");
	if (string.IsNullOrEmpty(code) || string.IsNullOrEmpty(codeVerifier))
	{
		return PlainText(StatusCodes.Status400BadRequest, "Missing code or PKCE verifier");
	}

	var form = new Dictionary<string, string>
	{
		["grant_type"] = "authorization_code",
		["client_id"] = config.ClientId,
		["redirect_uri"] = config.RedirectUri,
		["code"] = code,
		["code_verifier"] = codeVerifier,
	};
	// Sent only when configured; the reference ECA requires it, a PKCE-only ECA omits it.
	// It is only ever attached to this server-to-server request — never returned to the browser.
	if (!string.IsNullOrEmpty(config.ClientSecret))
	{
		form["client_secret"] = config.ClientSecret;
	}

	var client = httpClientFactory.CreateClient("salesforce");
	HttpResponseMessage response;
	try
	{
		response = await client.PostAsync($"{config.LoginUrl}/services/oauth2/token", FormUrlEncoded(form));
	}
	catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
	{
		return PlainText(StatusCodes.Status500InternalServerError, $"Token exchange error: {ex.Message}");
	}

	var body = await response.Content.ReadAsStringAsync();
	TokenResponse? token;
	try
	{
		// No custom JsonSerializerOptions needed: TokenResponse's [JsonPropertyName]
		// attributes already map each property to Salesforce's exact snake_case key.
		token = JsonSerializer.Deserialize<TokenResponse>(body);
	}
	catch (JsonException)
	{
		return PlainText(StatusCodes.Status500InternalServerError, "Token exchange error: invalid response from Salesforce");
	}

	if (!response.IsSuccessStatusCode || !string.IsNullOrEmpty(token?.Error))
	{
		// Normalize to 500 if Salesforce somehow returned a non-error status with an error
		// body — a non-2xx should always propagate, never a misleading 2xx.
		var status = (int)response.StatusCode;
		if (status < 400)
		{
			status = StatusCodes.Status500InternalServerError;
		}
		var message = token?.ErrorDescription ?? token?.Error ?? "unknown";
		return PlainText(status, $"Token exchange failed: {message}");
	}

	context.Session.SetString("accessToken", token!.AccessToken!);
	context.Session.SetString("instanceUrl", token!.InstanceUrl!);
	// Stored so we can silently re-mint after the ~2h access-token TTL elapses. Present only
	// if the ECA granted the refresh_token scope (requested in /login); if the org withholds
	// it, RefreshAccessTokenAsync is a no-op and the user re-runs /login by hand.
	if (!string.IsNullOrEmpty(token.RefreshToken))
	{
		context.Session.SetString("refreshToken", token.RefreshToken);
	}
	return Results.Redirect(config.AppOrigin); // back to the SPA; it will call /getFrontdoorURL
});

// 3) Exchange the stored access token for a FrontDoor URL. The SPA fetches this.
app.MapGet("/getFrontdoorURL", async (HttpContext context, IHttpClientFactory httpClientFactory) =>
{
	var accessToken = context.Session.GetString("accessToken");
	var instanceUrl = context.Session.GetString("instanceUrl");
	if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(instanceUrl))
	{
		return Results.Json(new { error = "Not authenticated. Visit /login first." }, statusCode: StatusCodes.Status401Unauthorized);
	}

	var client = httpClientFactory.CreateClient("salesforce");
	HttpResponseMessage response;
	try
	{
		response = await MintFrontdoorAsync(client, instanceUrl, accessToken);

		// The access token expires (~2h). On 401/403, refresh once and retry so the SPA keeps
		// working without bouncing the user back through /login.
		if ((response.StatusCode == System.Net.HttpStatusCode.Unauthorized
				|| response.StatusCode == System.Net.HttpStatusCode.Forbidden)
			&& await RefreshAccessTokenAsync(client, config, context.Session))
		{
			response.Dispose();
			accessToken = context.Session.GetString("accessToken")!;
			instanceUrl = context.Session.GetString("instanceUrl")!;
			response = await MintFrontdoorAsync(client, instanceUrl, accessToken);
		}
	}
	catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
	{
		return Results.Json(new { error = string.IsNullOrEmpty(ex.Message) ? "Failed to generate FrontDoor URL" : ex.Message }, statusCode: StatusCodes.Status500InternalServerError);
	}

	// Capture status + body, then dispose the (final) response so no HttpResponseMessage leaks on
	// any exit path below — the pre-retry response was already disposed in the retry branch above.
	var isSuccess = response.IsSuccessStatusCode;
	var statusCode = (int)response.StatusCode;
	var body = await response.Content.ReadAsStringAsync();
	response.Dispose();

	if (!isSuccess)
	{
		// Still failing after the refresh attempt — the session is stale; the SPA should /login.
		// Surface Salesforce's own reason: the body carries {"error","error_description"} that
		// distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
		// it's safe to forward; truncate defensively.
		var trimmed = (body ?? string.Empty).Trim();
		var detail = trimmed.Length == 0 ? "" : ": " + (trimmed.Length > 500 ? trimmed[..500] + "…" : trimmed);
		return Results.Json(new { error = $"Salesforce responded {statusCode}{detail}. Re-authenticate at /login." }, statusCode: statusCode);
	}

	SingleAccessResponse? data;
	try
	{
		data = JsonSerializer.Deserialize<SingleAccessResponse>(body);
	}
	catch (JsonException)
	{
		return Results.Json(new { error = "Invalid response from Salesforce" }, statusCode: StatusCodes.Status500InternalServerError);
	}

	return Results.Json(new { frontdoorUrl = data?.FrontdoorUri, orgUrl = config.OrgUrl });
});

app.MapGet("/health", () => Results.Json(new { ok = true }));

app.Urls.Add($"http://localhost:{config.Port}");

app.Lifetime.ApplicationStarted.Register(() =>
{
	Console.WriteLine($"Token broker on http://localhost:{config.Port}");
	Console.WriteLine($"  Log in:  http://localhost:{config.Port}/login");
});

app.Run();

// ---- Helpers ---------------------------------------------------------------------------

static IResult PlainText(int statusCode, string body) => Results.Text(body, "text/plain", statusCode: statusCode);

// POST the access token to the singleaccess endpoint to mint a FrontDoor URL.
static async Task<HttpResponseMessage> MintFrontdoorAsync(HttpClient client, string instanceUrl, string accessToken)
{
	using var request = new HttpRequestMessage(HttpMethod.Post, $"{instanceUrl}/services/oauth2/singleaccess")
	{
		Content = FormUrlEncoded(new Dictionary<string, string>()), // empty body
	};
	request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
	request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
	return await client.SendAsync(request);
}

// Exchange the session's refresh token for a fresh access token, updating the session in
// place. Returns false if there's no refresh token or the refresh fails.
static async Task<bool> RefreshAccessTokenAsync(HttpClient client, BrokerConfig config, ISession session)
{
	var refreshToken = session.GetString("refreshToken");
	if (string.IsNullOrEmpty(refreshToken))
	{
		return false;
	}

	var form = new Dictionary<string, string>
	{
		["grant_type"] = "refresh_token",
		["client_id"] = config.ClientId,
		["refresh_token"] = refreshToken,
	};
	if (!string.IsNullOrEmpty(config.ClientSecret))
	{
		form["client_secret"] = config.ClientSecret;
	}

	HttpResponseMessage response;
	try
	{
		response = await client.PostAsync($"{config.LoginUrl}/services/oauth2/token", FormUrlEncoded(form));
	}
	catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
	{
		return false;
	}

	if (!response.IsSuccessStatusCode)
	{
		return false;
	}

	TokenResponse? token;
	try
	{
		token = JsonSerializer.Deserialize<TokenResponse>(await response.Content.ReadAsStringAsync());
	}
	catch (JsonException)
	{
		return false;
	}

	if (token is null || string.IsNullOrEmpty(token.AccessToken) || !string.IsNullOrEmpty(token.Error))
	{
		return false;
	}

	session.SetString("accessToken", token.AccessToken);
	// A refresh grant returns instance_url but no new refresh_token; keep the stored one.
	if (!string.IsNullOrEmpty(token.InstanceUrl))
	{
		session.SetString("instanceUrl", token.InstanceUrl);
	}
	return true;
}

// Encodes a form body as application/x-www-form-urlencoded ourselves (rather than
// FormUrlEncodedContent, which appends "; charset=utf-8") so the outbound Content-Type
// header matches the broker contract exactly, and so an empty dictionary produces a
// genuinely empty body for the FrontDoor (single-access) POST.
static HttpContent FormUrlEncoded(IEnumerable<KeyValuePair<string, string>> data)
{
	var encoded = string.Join("&", data.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"));
	var content = new StringContent(encoded, Encoding.ASCII);
	content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
	return content;
}

// ---- PKCE (RFC 7636) --------------------------------------------------------------------

/// <summary>
/// A high-entropy random code_verifier and its S256 code_challenge — matching the Node
/// reference exactly: 64 random bytes, base64url with no padding, SHA-256 (never "plain").
/// </summary>
static class Pkce
{
	public static (string Verifier, string Challenge) Generate()
	{
		var verifierBytes = RandomNumberGenerator.GetBytes(64);
		var verifier = WebEncoders.Base64UrlEncode(verifierBytes);

		var challengeBytes = SHA256.HashData(Encoding.ASCII.GetBytes(verifier));
		var challenge = WebEncoders.Base64UrlEncode(challengeBytes);

		return (verifier, challenge);
	}
}

// ---- Config -----------------------------------------------------------------------------

/// <summary>
/// Broker configuration, read straight from the environment. <see cref="LoginUrl"/>,
/// <see cref="OrgUrl"/>, and <see cref="ClientId"/> are required; a missing one fails
/// startup immediately (mirrors the Node reference's <c>process.exit(1)</c>).
/// <see cref="ClientSecret"/> is optional: when set, it's appended to the token exchange
/// (confidential client) and is never returned to the browser.
/// </summary>
sealed record BrokerConfig(
	string Port,
	string LoginUrl,
	string OrgUrl,
	string ClientId,
	string? ClientSecret,
	string RedirectUri,
	string AppOrigin,
	string SessionSecret)
{
	public static BrokerConfig LoadOrExit()
	{
		var port = GetEnv("PORT", "8500");
		var loginUrl = RequireEnv("LOGIN_URL");
		var orgUrl = RequireEnv("ORG_URL");
		var clientId = RequireEnv("CLIENT_ID");
		var clientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET");
		var redirectUri = GetEnv("REDIRECT_URI", $"http://localhost:{port}/getAccessToken");
		var appOrigin = GetEnv("APP_ORIGIN", "http://localhost:8700");
		// Kept for parity with the other brokers' env var surface (SESSION_SECRET is
		// documented everywhere). ASP.NET Core's session middleware identifies sessions by
		// an opaque, server-generated id backed by the distributed cache rather than a value
		// signed with this secret, so it isn't wired into the session mechanism itself.
		var sessionSecret = GetEnv("SESSION_SECRET", "change-me-in-production");

		return new BrokerConfig(port, loginUrl, orgUrl, clientId, clientSecret, redirectUri, appOrigin, sessionSecret);
	}

	private static string GetEnv(string name, string fallback)
	{
		var value = Environment.GetEnvironmentVariable(name);
		return string.IsNullOrEmpty(value) ? fallback : value;
	}

	private static string RequireEnv(string name)
	{
		var value = Environment.GetEnvironmentVariable(name);
		if (string.IsNullOrEmpty(value))
		{
			Console.Error.WriteLine($"Missing required env var: {name}. See .env.example.");
			Environment.Exit(1);
		}
		return value!;
	}
}

// ---- Salesforce response shapes ---------------------------------------------------------

/// <summary>Subset of the Salesforce <c>/services/oauth2/token</c> response we need.</summary>
sealed record TokenResponse(
	[property: JsonPropertyName("access_token")] string? AccessToken,
	[property: JsonPropertyName("instance_url")] string? InstanceUrl,
	[property: JsonPropertyName("refresh_token")] string? RefreshToken,
	[property: JsonPropertyName("error")] string? Error,
	[property: JsonPropertyName("error_description")] string? ErrorDescription);

/// <summary>Subset of the Salesforce <c>/services/oauth2/singleaccess</c> response we need.</summary>
sealed record SingleAccessResponse(
	[property: JsonPropertyName("frontdoor_uri")] string? FrontdoorUri);
```

---

## Ruby (Sinatra)

> *Reviewed against the contract, not executed here.* Sinatra with `Rack::Session::Pool` — an
> in-memory **server-side** store, so the cookie carries only an opaque id (`HttpOnly` + `SameSite=Lax`).

**Run it.** From `token-broker/ruby/`, install gems, load the shared `.env` (see the Node
section for how `.env` loading and the `source` path work), confirm it loaded, then run. The
broker listens on **http://localhost:8500**.

```bash
cd token-broker/ruby
bundle install
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
ruby app.rb
```

Requires **Ruby 3.x**. Env vars are the same set as the Node `.env.example` above.

> **macOS ships a too-old system Ruby — `bundle install` will fail out of the box.** The
> built-in `/usr/bin/ruby` is **2.6.10** (Apple deprecated it and stopped updating it), so the
> Gemfile's `ruby '>= 3.0'` floor rejects it immediately:
> `Your Ruby version is 2.6.10, but your Gemfile specified >= 3.0`. This hits essentially every
> Mac user. Install a current Ruby with Homebrew and put it ahead of the system one on `PATH`:
> ```bash
> brew install ruby
> # brew's ruby is keg-only, so add it to PATH (Apple Silicon path shown; Intel is /usr/local):
> echo 'export PATH="/opt/homebrew/opt/ruby/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc
> ruby -v   # confirm 3.x before bundle install
> ```
> Do **not** `sudo gem install` into the system Ruby to work around it — that fights SIP and
> leaves a broken toolchain. Use the Homebrew Ruby (or a version manager like `rbenv`/`asdf`).

**`token-broker/ruby/Gemfile`:**

```ruby
source 'https://rubygems.org'

ruby '>= 3.0' # matches the "Ruby 3.x" prereq; Sinatra 3.2 / Puma 6.6 run on Ruby 3

gem 'sinatra', '~> 3.2'
gem 'puma', '~> 6.6'
```

**`token-broker/ruby/app.rb`:**

```ruby
# frozen_string_literal: true

# Tableau Next embedding — server-side token broker (Ruby/Sinatra port).
#
# Runs the OAuth Authorization Code + PKCE flow and mints a FrontDoor URL that the
# frontend passes to the SDK as `authCredential`. Keep this on the server: it holds the
# client secret (if any) and the raw access token; the browser only ever sees the
# short-lived FrontDoor URL.
#
# Port of the reference Node/Express implementation (see ../server.js) — same env vars,
# same endpoints, same session/CORS contract.
#
# Endpoints:
#   GET  /login            → starts OAuth (redirects to Salesforce)
#   GET  /getAccessToken   → OAuth callback; exchanges code → access token (stored in session)
#   GET  /getFrontdoorURL  → exchanges access token → { frontdoorUrl, orgUrl } (call this from the SPA)
#   GET  /health           → { ok: true }

require 'sinatra'
require 'rack/session/pool'
require 'securerandom'
require 'digest'
require 'base64'
require 'net/http'
require 'uri'
require 'json'

# ---- Config (from environment) ---------------------------------------------------------
PORT = (ENV['PORT'] || 8500).to_i

# Your org's login host, e.g. https://yourorg.my.salesforce.com or https://login.salesforce.com
LOGIN_URL = ENV['LOGIN_URL']
# Your org's Lightning My Domain host, e.g. https://yourorg.lightning.force.com
ORG_URL = ENV['ORG_URL']
# ECA Consumer Key
CLIENT_ID = ENV['CLIENT_ID']
# ECA Consumer Secret. The reference ECA requires it (isSecretRequiredForTokenExchange: true);
# set it. Leave unset only for an ECA provisioned PKCE-only. invalid_client => it's required.
CLIENT_SECRET = ENV['CLIENT_SECRET']
# Must exactly match a Callback URL registered on the ECA.
REDIRECT_URI = ENV['REDIRECT_URI'] || "http://localhost:#{PORT}/getAccessToken"
# Where to send the user back after a successful login; also the CORS-allowed origin.
APP_ORIGIN = ENV['APP_ORIGIN'] || 'http://localhost:8700'
SESSION_SECRET = ENV['SESSION_SECRET'] || 'change-me-in-production'

{ 'LOGIN_URL' => LOGIN_URL, 'ORG_URL' => ORG_URL, 'CLIENT_ID' => CLIENT_ID }.each do |name, value|
  next unless value.nil? || value.empty?

  warn "Missing required env var: #{name}. See .env.example."
  exit 1
end

configure do
  set :port, PORT
  set :bind, '0.0.0.0'
  set :session_secret, SESSION_SECRET
  # Session state (code_verifier, access_token, instance_url) must stay server-side — the
  # cookie may only carry an opaque session id, never the payload itself. Rack::Session::Pool
  # keeps sessions in an in-memory server-side store and puts just a signed id in the cookie.
  # (Sinatra 3.x's own session default, Rack::Protection::EncryptedCookie, is a *cookie* store
  # too — same problem, plus it demands a 32-byte AES key and would reject the short/default
  # SESSION_SECRET — so it's not used here either.) A single-process in-memory Pool is fine for
  # this demo; a real deployment should back sessions with Redis or another shared store so
  # they survive restarts and work across multiple processes.
  set :session_store, Rack::Session::Pool
  set :sessions, httponly: true, same_site: :lax
end

# ---- PKCE helpers ------------------------------------------------------------------------
def base64url(bytes)
  Base64.urlsafe_encode64(bytes).delete('=')
end

def generate_pkce
  verifier = base64url(SecureRandom.random_bytes(64))
  challenge = base64url(Digest::SHA256.digest(verifier))
  { verifier: verifier, challenge: challenge }
end

# ---- CORS ----------------------------------------------------------------------------------
# The SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this broker
# (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these headers
# or the browser drops the response. Allow-Origin must name the exact origin (not '*') when
# credentials are involved. On localhost, :8700 and :8500 are the same *site*, so the
# SameSite=Lax session cookie is still sent — CORS just permits reading the response.
before do
  headers 'Access-Control-Allow-Origin' => APP_ORIGIN,
          'Access-Control-Allow-Credentials' => 'true',
          'Access-Control-Allow-Headers' => 'Content-Type',
          'Vary' => 'Origin'
end

options '*' do
  204
end

# 1) Kick off OAuth with PKCE.
get '/login' do
  pkce = generate_pkce
  session[:code_verifier] = pkce[:verifier]
  # Random per-session state for CSRF / login-mix-up protection: echoed back on the callback
  # and must match. PKCE defends against code interception; state is the separate login-CSRF
  # defense — you want both. (A fixed constant would leave the callback forgeable.)
  state = base64url(SecureRandom.random_bytes(32))
  session[:oauth_state] = state

  params = {
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'api refresh_token lightning web',
    code_challenge: pkce[:challenge],
    code_challenge_method: 'S256',
    state: state
  }
  redirect "#{LOGIN_URL}/services/oauth2/authorize?#{URI.encode_www_form(params)}"
end

# 2) OAuth callback → exchange the code for an access token.
get '/getAccessToken' do
  content_type :text
  if params[:error]
    halt 400, "OAuth error: #{params[:error_description] || params[:error]}"
  end

  # Reject the callback unless state matches the value stored at /login (CSRF defense).
  expected_state = session[:oauth_state]
  if params[:state].nil? || expected_state.nil? || params[:state] != expected_state
    halt 400, 'Invalid or missing OAuth state'
  end
  session.delete(:oauth_state) # single-use

  code = params[:code]
  code_verifier = session[:code_verifier]
  halt 400, 'Missing code or PKCE verifier' if code.nil? || code_verifier.nil?

  body = {
    grant_type: 'authorization_code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    code: code,
    code_verifier: code_verifier
  }
  # Sent when configured. The reference ECA requires it; a PKCE-only ECA omits it.
  body[:client_secret] = CLIENT_SECRET if CLIENT_SECRET && !CLIENT_SECRET.empty?

  begin
    uri = URI("#{LOGIN_URL}/services/oauth2/token")
    response = Net::HTTP.post_form(uri, body)
    data = JSON.parse(response.body)

    if !response.is_a?(Net::HTTPSuccess) || data['error']
      status_code = response.code.to_i
      status_code = 500 if status_code.zero?
      halt status_code, "Token exchange failed: #{data['error_description'] || data['error'] || 'unknown'}"
    end

    session[:access_token] = data['access_token']
    session[:instance_url] = data['instance_url']
    # Stored so we can silently re-mint after the ~2h access-token TTL elapses. Present only
    # if the ECA granted the refresh_token scope (requested in /login); if the org withholds
    # it, refresh_access_token is a no-op and the user re-runs /login by hand.
    session[:refresh_token] = data['refresh_token']
    redirect APP_ORIGIN # back to the SPA; it will call /getFrontdoorURL
  rescue StandardError => e
    halt 500, "Token exchange error: #{e.message}"
  end
end

# POST the access token to the singleaccess endpoint to mint a FrontDoor URL.
def mint_frontdoor(instance_url, access_token)
  uri = URI("#{instance_url}/services/oauth2/singleaccess")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = uri.scheme == 'https'

  request = Net::HTTP::Post.new(uri)
  request['Accept'] = 'application/json'
  request['Authorization'] = "Bearer #{access_token}"
  request['Content-Type'] = 'application/x-www-form-urlencoded'
  request.body = ''

  http.request(request)
end

# Exchange the session's refresh token for a fresh access token, updating the session in
# place. Returns true on success; false if there's no refresh token or the refresh fails.
def refresh_access_token(session)
  refresh_token = session[:refresh_token]
  return false if refresh_token.nil? || refresh_token.empty?

  body = { grant_type: 'refresh_token', client_id: CLIENT_ID, refresh_token: refresh_token }
  body[:client_secret] = CLIENT_SECRET if CLIENT_SECRET && !CLIENT_SECRET.empty?

  response = Net::HTTP.post_form(URI("#{LOGIN_URL}/services/oauth2/token"), body)
  data = JSON.parse(response.body)
  return false unless response.is_a?(Net::HTTPSuccess) && data['access_token'] && data['error'].nil?

  session[:access_token] = data['access_token']
  # A refresh grant returns instance_url but no new refresh_token; keep the stored one.
  session[:instance_url] = data['instance_url'] if data['instance_url']
  true
rescue StandardError
  false
end

# 3) Exchange the stored access token for a FrontDoor URL. The SPA fetches this.
get '/getFrontdoorURL' do
  content_type :json

  access_token = session[:access_token]
  instance_url = session[:instance_url]

  if access_token.nil? || instance_url.nil?
    halt 401, { error: 'Not authenticated. Visit /login first.' }.to_json
  end

  begin
    response = mint_frontdoor(instance_url, access_token)

    # The access token expires (~2h). On 401/403, refresh once and retry so the SPA keeps
    # working without bouncing the user back through /login.
    if [401, 403].include?(response.code.to_i) && refresh_access_token(session)
      response = mint_frontdoor(session[:instance_url], session[:access_token])
    end

    unless response.is_a?(Net::HTTPSuccess)
      # Still failing after the refresh attempt — the session is stale; the SPA should /login.
      # Surface Salesforce's own reason: the body carries {"error","error_description"} that
      # distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
      # it's safe to forward; truncate defensively.
      body = response.body.to_s.strip
      detail = body.empty? ? '' : ": #{body.length > 500 ? "#{body[0, 500]}…" : body}"
      halt response.code.to_i, { error: "Salesforce responded #{response.code}#{detail}. Re-authenticate at /login." }.to_json
    end

    data = JSON.parse(response.body)
    { frontdoorUrl: data['frontdoor_uri'], orgUrl: ORG_URL }.to_json
  rescue StandardError => e
    halt 500, { error: e.message || 'Failed to generate FrontDoor URL' }.to_json
  end
end

get '/health' do
  content_type :json
  { ok: true }.to_json
end
```

---

## PHP (built-in server)

> *Reviewed against the contract, not executed here.* A single-file front controller, no framework.
> PHP's native `session_start()` keeps session data server-side (the cookie is only `PHPSESSID`,
> `HttpOnly` + `SameSite=Lax`). Config is validated per-request with a 500 fail-fast, the idiomatic
> equivalent of the Node reference's startup `process.exit(1)` under PHP's per-request model.

**Run it.** From `token-broker/php/`, load the shared `.env` (see the Node section for how
`.env` loading and the `source` path work), confirm it loaded, then start PHP's built-in
server. The broker listens on **http://localhost:8500**.

```bash
cd token-broker/php
set -a; source ../.env; set +a
echo "ORG_URL=$ORG_URL"
php -S localhost:8500 broker.php
```

Requires **PHP 8.x** with the cURL extension. Env vars are the same set as the Node `.env.example` above.

**`token-broker/php/broker.php`:**

```php
<?php

/**
 * Tableau Next embedding — server-side token broker (PHP).
 *
 * Runs the OAuth Authorization Code + PKCE flow and mints a FrontDoor URL that the
 * frontend passes to the SDK as `authCredential`. Keep this on the server: it holds the
 * client secret (if any) and the raw access token; the browser only ever sees the
 * short-lived FrontDoor URL.
 *
 * Single-file front controller — run with the PHP built-in server:
 *   php -S localhost:8500 broker.php
 *
 * Endpoints:
 *   GET  /login            -> starts OAuth (redirects to Salesforce)
 *   GET  /getAccessToken   -> OAuth callback; exchanges code -> access token (stored in session)
 *   GET  /getFrontdoorURL  -> exchanges access token -> { frontdoorUrl, orgUrl } (call this from the SPA)
 *   GET  /health           -> { ok: true }
 */

declare(strict_types=1);

// -----------------------------------------------------------------------------------------
// Config (from environment)
// -----------------------------------------------------------------------------------------

/** Read an env var, falling back to $default when unset or empty. */
function env(string $name, ?string $default = null): ?string
{
    $value = getenv($name);

    return $value === false || $value === '' ? $default : $value;
}

/** Fail fast (matching the Node reference's process.exit(1) at startup) when config is missing. */
function requireEnv(array $required): void
{
    foreach ($required as $name => $value) {
        if (!$value) {
            error_log("Missing required env var: {$name}. See .env.example.");
            http_response_code(500);
            header('Content-Type: text/plain; charset=utf-8');
            echo "Server misconfigured: missing required env var {$name}. See .env.example.";
            exit;
        }
    }
}

$port = env('PORT', '8500');

// Your org's login host, e.g. https://yourorg.my.salesforce.com or https://login.salesforce.com
$loginUrl = env('LOGIN_URL');
// Your org's Lightning My Domain host, e.g. https://yourorg.lightning.force.com
$orgUrl = env('ORG_URL');
// ECA Consumer Key
$clientId = env('CLIENT_ID');
// ECA Consumer Secret. The reference ECA requires it (isSecretRequiredForTokenExchange: true);
// set it. Leave unset only for an ECA provisioned PKCE-only. invalid_client => it's required.
// Optional — appended to the token exchange only when set. Never sent to the browser.
$clientSecret = env('CLIENT_SECRET');
// Must exactly match a Callback URL registered on the ECA.
$redirectUri = env('REDIRECT_URI', "http://localhost:{$port}/getAccessToken");
// Where to send the user back after a successful login; also the sole allowed CORS origin.
$appOrigin = env('APP_ORIGIN', 'http://localhost:8700');
// PHP's native session module keys sessions by an unguessable server-side ID rather than a
// signed cookie, so this isn't consumed directly today — read anyway so the env surface stays
// identical across every broker implementation.
$sessionSecret = env('SESSION_SECRET', 'change-me-in-production');

requireEnv(['LOGIN_URL' => $loginUrl, 'ORG_URL' => $orgUrl, 'CLIENT_ID' => $clientId]);

// -----------------------------------------------------------------------------------------
// PKCE helpers
// -----------------------------------------------------------------------------------------

function base64UrlEncode(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

/** @return array{0: string, 1: string} [$verifier, $challenge] */
function generatePkce(): array
{
    $verifier = base64UrlEncode(random_bytes(64));
    $challenge = base64UrlEncode(hash('sha256', $verifier, true));

    return [$verifier, $challenge];
}

// -----------------------------------------------------------------------------------------
// HTTP client helper (cURL)
// -----------------------------------------------------------------------------------------

/**
 * POST application/x-www-form-urlencoded to $url and return [statusCode, body].
 * $fields may be an empty array to send an empty body (used by /getFrontdoorURL).
 *
 * @throws RuntimeException on a transport-level failure (DNS, connect, timeout, ...).
 */
function httpPostForm(string $url, array $fields, array $extraHeaders = []): array
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($fields),
        CURLOPT_HTTPHEADER => array_merge(
            ['Content-Type: application/x-www-form-urlencoded'],
            $extraHeaders,
        ),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException($error ?: 'cURL request failed');
    }

    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    return [$status, (string) $body];
}

// -----------------------------------------------------------------------------------------
// Response helpers
// -----------------------------------------------------------------------------------------

function sendJson(int $status, array $payload): void
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($payload);
}

function sendText(int $status, string $message): void
{
    http_response_code($status);
    header('Content-Type: text/plain; charset=utf-8');
    echo $message;
}

// -----------------------------------------------------------------------------------------
// CORS
//
// The SPA (APP_ORIGIN, e.g. http://localhost:8700) is a *different origin* than this broker
// (http://localhost:8500), so its credentialed fetch to /getFrontdoorURL needs these headers
// or the browser drops the response. Allow-Origin must name the exact origin (not '*') when
// credentials are involved. On localhost, :8700 and :8500 are the same *site*, so the
// SameSite=Lax session cookie is still sent — CORS just permits reading the response.
// -----------------------------------------------------------------------------------------

header('Access-Control-Allow-Origin: ' . $appOrigin);
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Content-Type');
header('Vary: Origin');

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

if ($method === 'OPTIONS') {
    http_response_code(204);
    exit;
}

// -----------------------------------------------------------------------------------------
// Session
//
// Server-side session holding codeVerifier, accessToken, instanceUrl. HttpOnly + SameSite=Lax
// so the token never touches JS and the cookie still rides along on the /login redirect chain.
// -----------------------------------------------------------------------------------------

session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'secure' => false, // set true once the broker is served over HTTPS
    'httponly' => true,
    'samesite' => 'Lax',
]);
session_start();

// -----------------------------------------------------------------------------------------
// Route handlers
// -----------------------------------------------------------------------------------------

/** 1) Kick off OAuth with PKCE. */
function handleLogin(string $loginUrl, string $clientId, string $redirectUri): void
{
    [$verifier, $challenge] = generatePkce();
    $_SESSION['codeVerifier'] = $verifier;
    // Random per-session state for CSRF / login-mix-up protection: echoed back on the callback
    // and must match. PKCE defends against code interception; state is the separate login-CSRF
    // defense — you want both. (A fixed constant would leave the callback forgeable.)
    $state = base64UrlEncode(random_bytes(32));
    $_SESSION['oauthState'] = $state;

    $query = http_build_query([
        'response_type' => 'code',
        'client_id' => $clientId,
        'redirect_uri' => $redirectUri,
        'scope' => 'api refresh_token lightning web',
        'code_challenge' => $challenge,
        'code_challenge_method' => 'S256',
        'state' => $state,
    ]);

    header("Location: {$loginUrl}/services/oauth2/authorize?{$query}");
}

/** 2) OAuth callback -> exchange the code for an access token. */
function handleGetAccessToken(
    string $loginUrl,
    string $clientId,
    ?string $clientSecret,
    string $redirectUri,
    string $appOrigin,
): void {
    $error = $_GET['error'] ?? null;
    if ($error) {
        $description = $_GET['error_description'] ?? $error;
        sendText(400, "OAuth error: {$description}");
        return;
    }

    // Reject the callback unless state matches the value stored at /login (CSRF defense).
    $state = $_GET['state'] ?? null;
    $expectedState = $_SESSION['oauthState'] ?? null;
    if (!$state || !$expectedState || $state !== $expectedState) {
        sendText(400, 'Invalid or missing OAuth state');
        return;
    }
    unset($_SESSION['oauthState']); // single-use

    $code = $_GET['code'] ?? null;
    $codeVerifier = $_SESSION['codeVerifier'] ?? null;
    if (!$code || !$codeVerifier) {
        sendText(400, 'Missing code or PKCE verifier');
        return;
    }

    $params = [
        'grant_type' => 'authorization_code',
        'client_id' => $clientId,
        'redirect_uri' => $redirectUri,
        'code' => $code,
        'code_verifier' => $codeVerifier,
    ];
    // Sent when configured. The reference ECA requires it; a PKCE-only ECA omits it.
    if ($clientSecret) {
        $params['client_secret'] = $clientSecret;
    }

    try {
        [$status, $body] = httpPostForm("{$loginUrl}/services/oauth2/token", $params);
    } catch (Throwable $e) {
        sendText(500, "Token exchange error: {$e->getMessage()}");
        return;
    }

    $data = json_decode($body, true) ?: [];
    if ($status < 200 || $status >= 300 || isset($data['error'])) {
        $message = $data['error_description'] ?? $data['error'] ?? 'unknown';
        sendText($status ?: 500, "Token exchange failed: {$message}");
        return;
    }

    $_SESSION['accessToken'] = $data['access_token'] ?? null;
    $_SESSION['instanceUrl'] = $data['instance_url'] ?? null;
    // Stored so we can silently re-mint after the ~2h access-token TTL elapses. Present only
    // if the ECA granted the refresh_token scope (requested in /login); if the org withholds
    // it, refreshAccessToken() is a no-op and the user re-runs /login by hand.
    $_SESSION['refreshToken'] = $data['refresh_token'] ?? null;
    header("Location: {$appOrigin}"); // back to the SPA; it will call /getFrontdoorURL
}

/** POST the access token to the singleaccess endpoint to mint a FrontDoor URL. */
function mintFrontdoor(string $instanceUrl, string $accessToken): array
{
    return httpPostForm("{$instanceUrl}/services/oauth2/singleaccess", [], [
        'Accept: application/json',
        "Authorization: Bearer {$accessToken}",
    ]);
}

/**
 * Exchange the session's refresh token for a fresh access token, updating $_SESSION in place.
 * Returns true on success; false if there's no refresh token or the refresh fails.
 */
function refreshAccessToken(string $loginUrl, string $clientId, ?string $clientSecret): bool
{
    $refreshToken = $_SESSION['refreshToken'] ?? null;
    if (!$refreshToken) {
        return false;
    }

    $params = ['grant_type' => 'refresh_token', 'client_id' => $clientId, 'refresh_token' => $refreshToken];
    if ($clientSecret) {
        $params['client_secret'] = $clientSecret;
    }

    try {
        [$status, $body] = httpPostForm("{$loginUrl}/services/oauth2/token", $params);
    } catch (Throwable $e) {
        return false;
    }

    $data = json_decode($body, true) ?: [];
    if ($status < 200 || $status >= 300 || isset($data['error']) || empty($data['access_token'])) {
        return false;
    }

    $_SESSION['accessToken'] = $data['access_token'];
    // A refresh grant returns instance_url but no new refresh_token; keep the stored one.
    if (!empty($data['instance_url'])) {
        $_SESSION['instanceUrl'] = $data['instance_url'];
    }
    return true;
}

/** 3) Exchange the stored access token for a FrontDoor URL. The SPA fetches this. */
function handleGetFrontdoorUrl(string $orgUrl, string $loginUrl, string $clientId, ?string $clientSecret): void
{
    $accessToken = $_SESSION['accessToken'] ?? null;
    $instanceUrl = $_SESSION['instanceUrl'] ?? null;
    if (!$accessToken || !$instanceUrl) {
        sendJson(401, ['error' => 'Not authenticated. Visit /login first.']);
        return;
    }

    try {
        [$status, $body] = mintFrontdoor($instanceUrl, $accessToken);

        // The access token expires (~2h). On 401/403, refresh once and retry so the SPA keeps
        // working without bouncing the user back through /login.
        if (($status === 401 || $status === 403) && refreshAccessToken($loginUrl, $clientId, $clientSecret)) {
            [$status, $body] = mintFrontdoor($_SESSION['instanceUrl'], $_SESSION['accessToken']);
        }
    } catch (Throwable $e) {
        sendJson(500, ['error' => $e->getMessage() ?: 'Failed to generate FrontDoor URL']);
        return;
    }

    if ($status < 200 || $status >= 300) {
        // Still failing after the refresh attempt — the session is stale; the SPA should /login.
        // Surface Salesforce's own reason: the body carries {"error","error_description"} that
        // distinguishes e.g. an expired token from a missing scope. No token/secret is in it, so
        // it's safe to forward; truncate defensively.
        $trimmed = trim((string) $body);
        $detail = $trimmed === '' ? '' : ': ' . (strlen($trimmed) > 500 ? substr($trimmed, 0, 500) . '…' : $trimmed);
        sendJson($status, ['error' => "Salesforce responded {$status}{$detail}. Re-authenticate at /login."]);
        return;
    }

    $data = json_decode($body, true) ?: [];
    sendJson(200, ['frontdoorUrl' => $data['frontdoor_uri'] ?? null, 'orgUrl' => $orgUrl]);
}

function handleHealth(): void
{
    sendJson(200, ['ok' => true]);
}

// -----------------------------------------------------------------------------------------
// Router
// -----------------------------------------------------------------------------------------

$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';

if ($method === 'GET' && $path === '/login') {
    handleLogin($loginUrl, $clientId, $redirectUri);
} elseif ($method === 'GET' && $path === '/getAccessToken') {
    handleGetAccessToken($loginUrl, $clientId, $clientSecret, $redirectUri, $appOrigin);
} elseif ($method === 'GET' && $path === '/getFrontdoorURL') {
    handleGetFrontdoorUrl($orgUrl, $loginUrl, $clientId, $clientSecret);
} elseif ($method === 'GET' && $path === '/health') {
    handleHealth();
} else {
    sendText(404, 'Not Found');
}
```

---

# Integrating into an existing app

The samples above are greenfield: a broker on `:8500`, a fresh SPA on `:8700`. The more
common case is grafting the embed into an app you **already have** — an existing frontend
plus its own backend (its own server, session, and CORS). The auth flow is identical; what
changes is that you **mount the broker into your server** instead of running a second one,
and you fit the frontend piece into your existing routing/render model. Four adaptations
cover it. (This section is static-reviewed against a representative Express + React app, not
separately run on an org — the flow it describes is the same one the samples exercise.)

**1. Mount the broker as a router — don't run a second server.** The
[Node reference](#nodejs-express) is a standalone `app` with its own `express-session` and
`cors`. Your app already has both, so strip them from the broker and expose it as a
`Router` you mount under a path prefix. The endpoint logic (the three handlers, PKCE, the
401/403 refresh-and-retry) is copied verbatim — only the wrapper changes:

```js
// tableauBroker.js — the reference broker's handlers, as a mountable router.
// No app.listen, no session(), no cors() here: the host app already owns those.
import { Router } from 'express';
const router = Router();

router.get('/login', (req, res) => {
	/* …identical to the reference /login… */
});
router.get('/getAccessToken', async (req, res) => {
	/* …identical… */
});
router.get('/getFrontdoorURL', async (req, res) => {
	/* …identical… */
});
export default router;
```

```js
// In your existing server, mount it once:
import tableauBroker from './tableauBroker.js';
app.use('/api/tableau', tableauBroker); // now /api/tableau/login, /api/tableau/getFrontdoorURL, …
```

**2. Namespace the session keys.** The broker stashes `codeVerifier`, `oauthState`,
`accessToken`, `instanceUrl`, `refreshToken` on the session. Mounted into your app, that
session is **shared** with your own code — a bare `req.session.accessToken` can collide with
a field your app already uses. Prefix the broker's keys (e.g. `tnCodeVerifier`,
`tnAccessToken`, `tnInstanceUrl`) so the two never overlap. Same server-side-session rule
still holds: the token lives in the session, never in the cookie or the browser.

**3. Your ports and paths differ from the samples — three values must agree.** The samples
hardcode `:8500`/`:8700` and root-level endpoints. In your app the broker lives behind your
server's origin and your mount prefix, so re-point all three references to it:

- **`REDIRECT_URI`** → your callback's real URL (e.g.
  `https://app.example.com/api/tableau/getAccessToken`), and register that **exact** URL as
  a Callback URL on the ECA. A mismatch is a `redirect_uri_mismatch` at login.
- **`APP_ORIGIN`** → your frontend's real origin. If the frontend and the mounted broker are
  the **same** origin (common — one server serves both), the cross-origin CORS block is moot;
  if they're separate origins, `APP_ORIGIN` must name the frontend exactly (never `*`), as in
  the reference.
- **The frontend fetch path** → `/api/tableau/getFrontdoorURL` (your mount prefix), not the
  sample's bare `http://localhost:8500/getFrontdoorURL`. Keep `credentials: 'include'`.

**4. The React sample assumes a mount-once page; an existing app usually re-renders.** The
[React sample](#sample--react) calls `initializeAnalyticsSdk` at module load, which is safe
only because that page mounts exactly once. Inside an app with client-side routing (or React
`StrictMode`'s double-invoke in dev), the embedding component can mount, unmount, and remount
— and `initializeAnalyticsSdk` throws if called twice. Guard it with a module-scope sentinel
so init runs at most once per page load regardless of how often the component mounts:

```tsx
// AnalyticsEmbed.tsx — safe to mount/unmount/remount under routing or StrictMode.
let initialized = false; // module scope: survives component remounts, resets on full reload

export function AnalyticsEmbed({ dashboardId }: { dashboardId: string }) {
	useEffect(() => {
		if (initialized) return;
		initialized = true;
		(async () => {
			const res = await fetch('/api/tableau/getFrontdoorURL', { credentials: 'include' });
			if (!res.ok) return; // 401 → send the user through /api/tableau/login first
			const { frontdoorUrl, orgUrl } = await res.json();
			await initializeAnalyticsSdk({ orgUrl, authCredential: { token: frontdoorUrl } });
			/* …create + append the component as in the sample… */
		})();
	}, [dashboardId]);
	return <div id="tableau-container" style={{ width: '100%', height: 600 }} />;
}
```

Everything else — the ECA setup, the FrontDoor exchange, the SDK API, the component
lifecycle — is unchanged from the greenfield path above.

---

# Runnable samples

Each sample runs on **port 8700** and calls the broker on **8500**. Full projects are on
disk under [`samples/`](samples/); the source of each is inlined here. For every sample:

1. Start the [token broker](#the-runnable-token-broker) and log in at `http://localhost:8500/login`.
2. Set your component id (and type) in the sample's config.
3. `npm install && npm run dev` (Angular: `npm start`) → `http://localhost:8700`.
4. Add `http://localhost:8700` to **Trusted Domains for Inline Frames** (IFrame Type =
   Lightning Out).

## Sample — Vanilla JS

A single `index.html`, no build step. Serve on port 8700 (e.g. `npx serve . -l 8700` or
`python3 -m http.server 8700`) — don't open via `file://`, module imports need http.

**`samples/vanilla-js/index.html`:**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Tableau Next embed — vanilla JS</title>
    <style>
      body { font-family: system-ui, sans-serif; margin: 2rem; }
      #container { width: 100%; height: 600px; border: 1px solid #ddd; }
      #error { color: #b00; white-space: pre-wrap; }
    </style>
  </head>
  <body>
    <h1>Tableau Next — vanilla JS</h1>
    <p id="status">Loading…</p>
    <p id="error"></p>
    <div id="container"></div>

    <!--
      Two ways to load the SDK. This sample uses ESM (recommended). The UMD path is shown
      in the framework guide (SKILL.md § Vanilla JavaScript) for no-bundler setups.
    -->
    <script type="module">
      // Loads from a CDN so this file runs with no build step. Pin the version.
      import {
        initializeAnalyticsSdk,
        AnalyticsDashboard,
        EventName,
        Status,
      } from 'https://esm.sh/@salesforce/analytics-embedding-sdk@2.0.0';

      // ---- Configure these for your org / component -------------------------------
      // The component's API name (e.g. 'Knowledge_Analytics_Dash'), from its URL or details panel; not a record ID
      const DASHBOARD_ID = 'Your_Dashboard_API_Name';
      // The broker returns orgUrl too, so we don't hardcode it here.
      const BROKER = 'http://localhost:8500';

      const statusEl = document.getElementById('status');
      const errorEl = document.getElementById('error');
      const container = document.getElementById('container');

      async function main() {
        try {
          // 1. Get the FrontDoor URL from the token broker (server-side auth).
          const resp = await fetch(`${BROKER}/getFrontdoorURL`, { credentials: 'include' });
          if (!resp.ok) {
            throw new Error(
              `Broker returned ${resp.status}. Log in first at ${BROKER}/login`,
            );
          }
          const { frontdoorUrl, orgUrl } = await resp.json();

          // 2. Initialize the SDK for the org. init resolves with a status; it does NOT throw
          //    on auth/framing failure — so check the status, don't assume the await succeeding
          //    means init worked. This is the fatal path: nothing renders if init fails.
          const res = await initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl });
          if (res.status !== Status.SUCCESS) {
            throw new Error(
              `SDK init failed (${res.status}). Check Trusted Domains for Inline Frames and your FrontDoor URL.`,
            );
          }

          // 3. Construct the component pointing at our container.
          const dashboard = new AnalyticsDashboard({
            parentIdOrElement: container,
            idOrApiName: DASHBOARD_ID,
            orgUrl,
          });

          // 4. Listen for component errors before rendering. These are often PARTIAL (a tile
          //    fails while the dashboard renders) — log them; don't paint the whole page as
          //    broken. Reserve the red #error banner for the fatal init failure above.
          dashboard.addEventListener(EventName.ERROR, (ev) => {
            console.warn('Analytics component reported an error (may be partial):', ev.detail);
          });
          dashboard.addEventListener(EventName.COMPONENT_LOADED, () => {
            statusEl.textContent = 'Loaded.';
          });

          // 5. Render (async). render() rejects if ANY asset fails to load, even when the
          //    dashboard itself renders — so treat a rejection as non-fatal too.
          statusEl.textContent = '';
          await dashboard.render().catch((e) => {
            console.warn('render() rejected (may be partial):', e?.message ?? e);
          });
        } catch (e) {
          // Fatal: broker/auth/init failed, so nothing rendered.
          statusEl.textContent = '';
          errorEl.textContent = String(e.message || e);
        }
      }

      main();
    </script>
  </body>
</html>
```

## Sample — TypeScript

Minimal Vite + TypeScript project. `package.json` → `{ "scripts": { "dev": "vite --port
8700", "build": "tsc && vite build" }, "dependencies": { "@salesforce/analytics-embedding-sdk":
"^2.0.0" }, "devDependencies": { "typescript": "^5.4.0", "vite": "^5.2.0" } }`. `tsconfig.json`
uses `"moduleResolution": "bundler"` (see the [TypeScript guide](#typescript)). `index.html`
loads `/src/main.ts` and has `<div id="container" style="width:100%;height:600px">`.

**`samples/typescript/src/main.ts`:**

```ts
import {
  initializeAnalyticsSdk,
  AnalyticsDashboard,
  EventName,
  Status,
} from '@salesforce/analytics-embedding-sdk';
import type {
  AnalyticsSdkConfig,
  DashboardProps,
} from '@salesforce/analytics-embedding-sdk';

// ---- Configure for your org / component ------------------------------------------
// The component's API name (e.g. 'Knowledge_Analytics_Dash'), from its URL or details panel; not a record ID
const DASHBOARD_ID = 'Your_Dashboard_API_Name';
const BROKER = 'http://localhost:8500';

const statusEl = document.getElementById('status')!;
const errorEl = document.getElementById('error')!;
const container = document.getElementById('container') as HTMLElement;

interface FrontdoorResponse {
  frontdoorUrl: string;
  orgUrl: string;
}

async function main(): Promise<void> {
  try {
    // 1. Get the FrontDoor URL from the token broker.
    const resp = await fetch(`${BROKER}/getFrontdoorURL`, { credentials: 'include' });
    if (!resp.ok) {
      throw new Error(`Broker returned ${resp.status}. Log in first at ${BROKER}/login`);
    }
    const { frontdoorUrl, orgUrl }: FrontdoorResponse = await resp.json();

    // 2. Initialize the SDK — fully typed config. init resolves with a status; it does NOT
    //    throw on auth/framing failure — so check the status. This is the fatal path.
    const config: AnalyticsSdkConfig = { orgUrl, authCredential: frontdoorUrl };
    const res = await initializeAnalyticsSdk(config);
    if (res.status !== Status.SUCCESS) {
      throw new Error(
        `SDK init failed (${res.status}). Check Trusted Domains for Inline Frames and your FrontDoor URL.`,
      );
    }

    // 3. Construct the component — typed props.
    const props: DashboardProps = {
      parentIdOrElement: container,
      idOrApiName: DASHBOARD_ID,
      orgUrl,
    };
    const dashboard = new AnalyticsDashboard(props);

    // 4. Events. Component errors are often PARTIAL (a tile fails while the dashboard
    //    renders) — log them; don't paint the whole page as broken via the red #error banner.
    dashboard.addEventListener(EventName.ERROR, (ev: Event) => {
      const detail = (ev as CustomEvent<{ message?: string }>).detail;
      console.warn('Analytics component reported an error (may be partial):', detail);
    });
    dashboard.addEventListener(EventName.COMPONENT_LOADED, () => {
      statusEl.textContent = 'Loaded.';
    });

    // 5. Render. render() rejects if ANY asset fails to load, even when the dashboard itself
    //    renders — so treat a rejection as non-fatal too.
    statusEl.textContent = '';
    await dashboard.render().catch((e) => {
      console.warn('render() rejected (may be partial):', e instanceof Error ? e.message : e);
    });
  } catch (e) {
    // Fatal: broker/auth/init failed, so nothing rendered.
    statusEl.textContent = '';
    errorEl.textContent = e instanceof Error ? e.message : String(e);
  }
}

void main();
```

## Sample — React

Vite + React + TS. `App.tsx` inits the SDK once (after fetching the FrontDoor URL) and
renders the reusable `AnalyticsEmbed` wrapper. All seven files of the project are inlined below
(`App.tsx`, `AnalyticsEmbed.tsx`, `main.tsx`, `index.html`, `vite.config.ts`, `tsconfig.json`,
`package.json`) — create them under `samples/react/` and you have a complete, runnable app.

**`samples/react/src/App.tsx`:**

```tsx
import { useEffect, useRef, useState } from 'react';
import { initializeAnalyticsSdk, Status } from '@salesforce/analytics-embedding-sdk';
import AnalyticsEmbed, { type ComponentType } from './components/AnalyticsEmbed';

// ---- Configure for your org / component ------------------------------------------
// The component's API name (e.g. 'Knowledge_Analytics_Dash'), from its URL or details panel; not a record ID
const COMPONENT_ID = 'Your_Component_API_Name';
const COMPONENT_TYPE: ComponentType = 'dashboard';
const BROKER = 'http://localhost:8500';

export default function App() {
  const [orgUrl, setOrgUrl] = useState<string | null>(null);
  const [status, setStatus] = useState('Authenticating…');
  const [isError, setIsError] = useState(false);
  // Run the init exactly once. React StrictMode double-invokes effects in dev; the ref
  // (which survives the remount) makes the second invocation a clean no-op, so the broker
  // fetch + initializeAnalyticsSdk (which consumes the single-use FrontDoor URL) run once.
  // NOTE: do NOT also add a per-run `cancelled` flag whose cleanup flips it true — with the
  // ref already blocking the remount, that flag would only fire on the one legitimate run's
  // cleanup and discard its successful result, hanging the app on "Authenticating…" forever.
  const didInit = useRef(false);

  // Initialize the SDK once, after fetching the FrontDoor URL from the broker.
  useEffect(() => {
    if (didInit.current) return;
    didInit.current = true;

    (async () => {
      try {
        const resp = await fetch(`${BROKER}/getFrontdoorURL`, { credentials: 'include' });
        if (!resp.ok) {
          throw new Error(`Broker returned ${resp.status}. Log in first at ${BROKER}/login`);
        }
        const { frontdoorUrl, orgUrl: org } = await resp.json();
        // init resolves with a status; it does NOT throw on auth/framing failure — so check
        // the status rather than assuming success once the await completes.
        const res = await initializeAnalyticsSdk({ orgUrl: org, authCredential: frontdoorUrl });
        if (res.status !== Status.SUCCESS) {
          throw new Error(`SDK init failed (${res.status}). Check Trusted Domains for Inline Frames and your FrontDoor URL.`);
        }
        setOrgUrl(org);
        setStatus('');
      } catch (e) {
        setIsError(true);
        setStatus(e instanceof Error ? e.message : String(e));
      }
    })();
  }, []);

  return (
    <main style={{ fontFamily: 'system-ui, sans-serif', margin: '2rem' }}>
      <h1>Tableau Next — React</h1>
      {status && <p style={{ color: isError ? '#b00' : '#333' }}>{status}</p>}
      {orgUrl && (
        <AnalyticsEmbed
          type={COMPONENT_TYPE}
          idOrApiName={COMPONENT_ID}
          orgUrl={orgUrl}
          // render() rejects if ANY child asset fails to load, even when the dashboard itself
          // renders — so a component error is often partial, not fatal. Surface it without
          // tearing down the (working) embed: log it and show a non-blocking notice.
          onError={(m) => console.warn('Analytics component reported an error (may be partial):', m)}
        />
      )}
    </main>
  );
}
```

**`samples/react/src/components/AnalyticsEmbed.tsx`** (reusable wrapper for all four types):

```tsx
import {
  AnalyticsDashboard,
  AnalyticsMetric,
  AnalyticsVisualization,
  AnalyticsAgent,
  EventName,
} from '@salesforce/analytics-embedding-sdk';
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react';

/**
 * Reusable React wrapper for any Tableau Next component.
 *
 * Pattern mirrors the repo's verified wrapper
 * (packages/playground/sdk_multiorg_react/src/components/Dashboard.tsx):
 *   - a plain <div> ref passed to the SDK via `parentIdOrElement`
 *   - construct + render inside useEffect, guarded against re-entrancy
 *   - clear the container on cleanup
 *   - expose the underlying instance via a forwarded ref
 *
 * Assumes `initializeAnalyticsSdk(...)` has already run (see App.tsx).
 */

const COMPONENT_CLASSES = {
  dashboard: AnalyticsDashboard,
  metric: AnalyticsMetric,
  visualization: AnalyticsVisualization,
  agent: AnalyticsAgent,
} as const;

export type ComponentType = keyof typeof COMPONENT_CLASSES;

export interface AnalyticsEmbedProps {
  type: ComponentType;
  idOrApiName: string;
  orgUrl: string;
  onReady?: () => void;
  onError?: (message: string) => void;
}

const AnalyticsEmbed = forwardRef<unknown, AnalyticsEmbedProps>((props, ref) => {
  const containerRef = useRef<HTMLDivElement>(null);
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const instanceRef = useRef<any>(undefined);

  useImperativeHandle(ref, () => ({
    getInstance: () => instanceRef.current,
    reload: () => instanceRef.current?.reload?.(),
  }));

  useEffect(() => {
    let isCancelled = false;
    const container = containerRef.current;

    const init = async () => {
      // Small delay lets any previous cleanup finish before we re-mount.
      await new Promise((r) => setTimeout(r, 100));
      if (isCancelled) return;

      try {
        const Ctor = COMPONENT_CLASSES[props.type];
        // Agent takes no `filters`; all four accept parentIdOrElement/idOrApiName/orgUrl.
        const instance = new Ctor({
          parentIdOrElement: container as HTMLElement,
          idOrApiName: props.idOrApiName,
          orgUrl: props.orgUrl,
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
        } as any);
        instanceRef.current = instance;
        if (isCancelled) return;

        instance.addEventListener(EventName.ERROR, (ev: Event) => {
          const detail = (ev as CustomEvent<{ message?: string }>).detail;
          props.onError?.(detail?.message ?? 'Component error');
        });
        instance.addEventListener(EventName.COMPONENT_LOADED, () => {
          if (!isCancelled) props.onReady?.();
        });

        await instance.render();
      } catch (e) {
        if (!isCancelled) {
          props.onError?.(e instanceof Error ? e.message : 'Failed to render');
        }
      }
    };

    void init();

    return () => {
      isCancelled = true;
      // Remove the SDK-rendered iframe on unmount.
      container?.replaceChildren();
      instanceRef.current = undefined;
    };
  }, [props.type, props.idOrApiName, props.orgUrl]);

  return <div ref={containerRef} style={{ width: '100%', height: 600 }} />;
});

AnalyticsEmbed.displayName = 'AnalyticsEmbed';
export default AnalyticsEmbed;
```

**`samples/react/src/main.tsx`** (entry point):

```tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

// NOTE on StrictMode: the repo's multi-org sample leaves React.StrictMode OFF because its
// intentional double-invoke of effects can double-mount the SDK component in dev. This
// wrapper guards mount/unmount with an `isCancelled` flag and clears the container on
// cleanup, so StrictMode is safe to keep here. If you see a flash of a double-rendered
// component in dev, remove <React.StrictMode>. (See SKILL.md § React.)
ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
```

**`samples/react/index.html`:**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Tableau Next embed — React</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
```

**`samples/react/vite.config.ts`** (serves on port 8700 to match `APP_ORIGIN`):

```ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  server: { port: 8700 },
});
```

**`samples/react/tsconfig.json`** (`moduleResolution: bundler` so the SDK's `exports` map resolves):

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "skipLibCheck": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}
```

**`samples/react/package.json`:**

```json
{
  "name": "tableau-next-sample-react",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@salesforce/analytics-embedding-sdk": "^2.0.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.1",
    "typescript": "^5.4.0",
    "vite": "^5.2.0"
  }
}
```

Run: `npm install`, then `npm run dev` → open `http://localhost:8700` (after logging in once
at `http://localhost:8500/login`).

## Sample — Vue 3

Vite + Vue 3 (`<script setup>`). All seven project files are inlined below (`App.vue`,
`AnalyticsEmbed.vue`, `main.ts`, `index.html`, `vite.config.ts`, `tsconfig.json`,
`package.json`) — create them under `samples/vue/` for a complete, runnable app.

**`samples/vue/src/App.vue`:**

```vue
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { initializeAnalyticsSdk, Status } from '@salesforce/analytics-embedding-sdk';
import AnalyticsEmbed from './components/AnalyticsEmbed.vue';

// ---- Configure for your org / component ------------------------------------------
// The component's API name (e.g. 'Knowledge_Analytics_Dash'), from its URL or details panel; not a record ID
const COMPONENT_ID = 'Your_Component_API_Name';
const COMPONENT_TYPE = 'dashboard' as const;
const BROKER = 'http://localhost:8500';

const orgUrl = ref<string | null>(null);
const status = ref('Authenticating…');
const isError = ref(false);

// No re-entry guard is needed here: unlike React StrictMode, Vue fires onMounted exactly once
// per component instance, and each /getFrontdoorURL call mints a fresh single-use FrontDoor URL,
// so there's no credential to burn twice. (A full Vite HMR reload remounts the whole app and
// re-runs auth from scratch — also fine, since it fetches a new URL.)
onMounted(async () => {
  try {
    const resp = await fetch(`${BROKER}/getFrontdoorURL`, { credentials: 'include' });
    if (!resp.ok) {
      throw new Error(`Broker returned ${resp.status}. Log in first at ${BROKER}/login`);
    }
    const { frontdoorUrl, orgUrl: org } = await resp.json();
    // init resolves with a status; it does NOT throw on auth/framing failure — so check the
    // status rather than assuming success once the await completes.
    const res = await initializeAnalyticsSdk({ orgUrl: org, authCredential: frontdoorUrl });
    if (res.status !== Status.SUCCESS) {
      throw new Error(
        `SDK init failed (${res.status}). Check Trusted Domains for Inline Frames and your FrontDoor URL.`,
      );
    }
    orgUrl.value = org;
    status.value = '';
  } catch (e) {
    isError.value = true;
    status.value = e instanceof Error ? e.message : String(e);
  }
});

// A component/render error is often PARTIAL (render() rejects if any asset fails, even when
// the dashboard itself renders) — so surface it without tearing down the working embed.
function onComponentError(m: string): void {
  console.warn('Analytics component reported an error (may be partial):', m);
}
</script>

<template>
  <main style="font-family: system-ui, sans-serif; margin: 2rem">
    <h1>Tableau Next — Vue</h1>
    <p v-if="status" :style="{ color: isError ? '#b00' : '#333' }">{{ status }}</p>
    <AnalyticsEmbed
      v-if="orgUrl"
      :type="COMPONENT_TYPE"
      :id-or-api-name="COMPONENT_ID"
      :org-url="orgUrl"
      @error="onComponentError"
    />
  </main>
</template>
```

**`samples/vue/src/components/AnalyticsEmbed.vue`** (reusable wrapper):

```vue
<script setup lang="ts">
import {
  AnalyticsDashboard,
  AnalyticsMetric,
  AnalyticsVisualization,
  AnalyticsAgent,
  EventName,
} from '@salesforce/analytics-embedding-sdk';
import { onMounted, onBeforeUnmount, ref } from 'vue';

/**
 * Reusable Vue 3 wrapper for any Tableau Next component. Assumes initializeAnalyticsSdk()
 * has already run (see App.vue). Lifecycle maps 1:1 onto the framework-neutral pattern:
 *   onMounted        -> construct + render
 *   onBeforeUnmount  -> clear the container
 */
const props = defineProps<{
  type: 'dashboard' | 'metric' | 'visualization' | 'agent';
  idOrApiName: string;
  orgUrl: string;
}>();
const emit = defineEmits<{ ready: []; error: [message: string] }>();

const COMPONENT_CLASSES = {
  dashboard: AnalyticsDashboard,
  metric: AnalyticsMetric,
  visualization: AnalyticsVisualization,
  agent: AnalyticsAgent,
} as const;

const container = ref<HTMLDivElement | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let instance: any;

onMounted(async () => {
  try {
    const Ctor = COMPONENT_CLASSES[props.type];
    instance = new Ctor({
      parentIdOrElement: container.value as HTMLElement,
      idOrApiName: props.idOrApiName,
      orgUrl: props.orgUrl,
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
    } as any);

    instance.addEventListener(EventName.ERROR, (ev: Event) => {
      const detail = (ev as CustomEvent<{ message?: string }>).detail;
      emit('error', detail?.message ?? 'Component error');
    });
    instance.addEventListener(EventName.COMPONENT_LOADED, () => emit('ready'));

    await instance.render();
  } catch (e) {
    emit('error', e instanceof Error ? e.message : 'Failed to render');
  }
});

onBeforeUnmount(() => {
  // Remove the SDK-rendered iframe.
  container.value?.replaceChildren();
  instance = undefined;
});
</script>

<template>
  <div ref="container" style="width: 100%; height: 600px" />
</template>
```

**`samples/vue/src/main.ts`** (entry point):

```ts
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');
```

**`samples/vue/index.html`:**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Tableau Next embed — Vue</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

**`samples/vue/vite.config.ts`** (serves on port 8700 to match `APP_ORIGIN`):

```ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: { port: 8700 },
});
```

**`samples/vue/tsconfig.json`:**

```json
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "jsx": "preserve",
    "lib": ["ES2020", "DOM", "DOM.Iterable"]
  },
  "include": ["src"]
}
```

**`samples/vue/package.json`:**

```json
{
  "name": "tableau-next-sample-vue",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@salesforce/analytics-embedding-sdk": "^2.0.0",
    "vue": "^3.4.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.0.0",
    "typescript": "^5.4.0",
    "vite": "^5.2.0",
    "vue-tsc": "^2.0.0"
  }
}
```

Run: `npm install`, then `npm run dev` → open `http://localhost:8700`.

## Sample — Angular

Angular 18 standalone. All seven project files are inlined below (`app.component.ts`,
`analytics-embed.component.ts`, `main.ts`, `src/index.html`, `angular.json`, `tsconfig.json`,
`package.json`) — create them under `samples/angular/` for a complete, runnable app.

**`samples/angular/src/app/app.component.ts`:**

```ts
import { Component, NgZone, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { initializeAnalyticsSdk, Status } from '@salesforce/analytics-embedding-sdk';
import { AnalyticsEmbedComponent } from './analytics-embed.component';

// ---- Configure for your org / component ------------------------------------------
// The component's API name (e.g. 'Knowledge_Analytics_Dash'), from its URL or details panel; not a record ID
const COMPONENT_ID = 'Your_Component_API_Name';
const COMPONENT_TYPE = 'dashboard' as const;
const BROKER = 'http://localhost:8500';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, AnalyticsEmbedComponent],
  template: `
    <main style="font-family: system-ui, sans-serif; margin: 2rem">
      <h1>Tableau Next — Angular</h1>
      <p *ngIf="status" [style.color]="isError ? '#b00' : '#333'">{{ status }}</p>
      <analytics-embed
        *ngIf="orgUrl"
        [type]="componentType"
        [idOrApiName]="componentId"
        [orgUrl]="orgUrl"
        (error)="onComponentError($event)"
      ></analytics-embed>
    </main>
  `,
})
export class AppComponent implements OnInit {
  readonly componentId = COMPONENT_ID;
  readonly componentType = COMPONENT_TYPE;
  orgUrl: string | null = null;
  status = 'Authenticating…';
  isError = false;

  constructor(private readonly zone: NgZone) {}

  async ngOnInit(): Promise<void> {
    try {
      const resp = await fetch(`${BROKER}/getFrontdoorURL`, { credentials: 'include' });
      if (!resp.ok) {
        throw new Error(`Broker returned ${resp.status}. Log in first at ${BROKER}/login`);
      }
      const { frontdoorUrl, orgUrl } = await resp.json();
      // init resolves with a status; it does NOT throw on auth/framing failure — so check the
      // status rather than assuming success once the await completes.
      const res = await initializeAnalyticsSdk({ orgUrl, authCredential: frontdoorUrl });
      if (res.status !== Status.SUCCESS) {
        throw new Error(
          `SDK init failed (${res.status}). Check Trusted Domains for Inline Frames and your FrontDoor URL.`,
        );
      }
      // initializeAnalyticsSdk resolves from a cross-iframe postMessage callback that zone.js
      // does NOT patch — so this continuation runs OUTSIDE Angular's zone and template bindings
      // (*ngIf="orgUrl") never refresh: init succeeds but the view stays frozen on
      // "Authenticating…". Re-enter the zone for every state mutation after an SDK boundary.
      // (React setState / Vue ref re-render on their own, so only Angular needs this.)
      this.zone.run(() => {
        this.orgUrl = orgUrl;
        this.status = '';
      });
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      this.zone.run(() => {
        this.isError = true;
        this.status = msg;
      });
    }
  }

  // A component/render error is often PARTIAL (render() rejects if any asset fails, even when
  // the dashboard itself renders) — so surface it without tearing down the working embed.
  onComponentError(m: string): void {
    console.warn('Analytics component reported an error (may be partial):', m);
  }
}
```

**`samples/angular/src/app/analytics-embed.component.ts`** (reusable wrapper):

```ts
import {
  AfterViewInit,
  Component,
  ElementRef,
  EventEmitter,
  Input,
  NgZone,
  OnDestroy,
  Output,
  ViewChild,
} from '@angular/core';
import {
  AnalyticsDashboard,
  AnalyticsMetric,
  AnalyticsVisualization,
  AnalyticsAgent,
  EventName,
} from '@salesforce/analytics-embedding-sdk';

type ComponentType = 'dashboard' | 'metric' | 'visualization' | 'agent';

const COMPONENT_CLASSES = {
  dashboard: AnalyticsDashboard,
  metric: AnalyticsMetric,
  visualization: AnalyticsVisualization,
  agent: AnalyticsAgent,
} as const;

/**
 * Reusable Angular wrapper for any Tableau Next component. Assumes initializeAnalyticsSdk()
 * has already run (see AppComponent). Lifecycle maps 1:1 onto the framework-neutral pattern:
 *   ngAfterViewInit -> construct + render (container exists by now)
 *   ngOnDestroy     -> clear the container
 */
@Component({
  selector: 'analytics-embed',
  standalone: true,
  template: '<div #container style="width: 100%; height: 600px"></div>',
})
export class AnalyticsEmbedComponent implements AfterViewInit, OnDestroy {
  @Input({ required: true }) type!: ComponentType;
  @Input({ required: true }) idOrApiName!: string;
  @Input({ required: true }) orgUrl!: string;
  @Output() ready = new EventEmitter<void>();
  @Output() error = new EventEmitter<string>();

  @ViewChild('container', { static: true }) container!: ElementRef<HTMLDivElement>;

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  private instance: any;

  constructor(private readonly zone: NgZone) {}

  async ngAfterViewInit(): Promise<void> {
    try {
      const Ctor = COMPONENT_CLASSES[this.type];
      this.instance = new Ctor({
        parentIdOrElement: this.container.nativeElement,
        idOrApiName: this.idOrApiName,
        orgUrl: this.orgUrl,
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
      } as any);

      // These SDK events fire from cross-iframe postMessage callbacks that land OUTSIDE
      // Angular's zone. This sample's parent only console.warns, so it wouldn't notice — but a
      // consumer that binds (ready)/(error) to template state would hit the frozen-view bug
      // (see AppComponent). Re-enter the zone so the emit drives change detection either way.
      this.instance.addEventListener(EventName.ERROR, (ev: Event) => {
        const detail = (ev as CustomEvent<{ message?: string }>).detail;
        this.zone.run(() => this.error.emit(detail?.message ?? 'Component error'));
      });
      this.instance.addEventListener(EventName.COMPONENT_LOADED, () =>
        this.zone.run(() => this.ready.emit()),
      );

      await this.instance.render();
    } catch (e) {
      const msg = e instanceof Error ? e.message : 'Failed to render';
      this.zone.run(() => this.error.emit(msg));
    }
  }

  ngOnDestroy(): void {
    // Remove the SDK-rendered iframe.
    this.container?.nativeElement.replaceChildren();
    this.instance = undefined;
  }
}
```

**`samples/angular/src/main.ts`** (entry point):

```ts
import 'zone.js';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent).catch((err) => console.error(err));
```

**`samples/angular/src/index.html`:**

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Tableau Next — Angular</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <app-root></app-root>
  </body>
</html>
```

**`samples/angular/angular.json`** (serves on 8700; `ng serve --port 8700` in `package.json`):

```json
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "projects": {
    "sample": {
      "projectType": "application",
      "root": "",
      "sourceRoot": "src",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:application",
          "options": {
            "outputPath": "dist",
            "index": "src/index.html",
            "browser": "src/main.ts",
            "tsConfig": "tsconfig.json"
          }
        },
        "serve": {
          "builder": "@angular-devkit/build-angular:dev-server",
          "options": {
            "buildTarget": "sample:build"
          }
        }
      }
    }
  }
}
```

> **The `buildTarget` is required, not optional.** Angular 18's `dev-server` builder has no
> default build to serve — omit `options.buildTarget` and `ng serve` dies immediately with
> `Schema validation failed … must have required property 'buildTarget'`. It points at the
> `build` target of this same project (`<project>:build`, here `sample:build`).

**`samples/angular/tsconfig.json`:**

```json
{
  "compileOnSave": false,
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"]
  },
  "angularCompilerOptions": {
    "strictTemplates": true
  },
  "include": ["src"]
}
```

**`samples/angular/package.json`:**

```json
{
  "name": "tableau-next-embedding-angular-sample",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "ng serve --port 8700",
    "build": "ng build"
  },
  "dependencies": {
    "@angular/animations": "^18.2.0",
    "@angular/common": "^18.2.0",
    "@angular/compiler": "^18.2.0",
    "@angular/core": "^18.2.0",
    "@angular/forms": "^18.2.0",
    "@angular/platform-browser": "^18.2.0",
    "@angular/platform-browser-dynamic": "^18.2.0",
    "@angular/router": "^18.2.0",
    "@salesforce/analytics-embedding-sdk": "^2.0.0",
    "rxjs": "^7.8.0",
    "tslib": "^2.6.0",
    "zone.js": "^0.14.10"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "^18.2.0",
    "@angular/cli": "^18.2.0",
    "@angular/compiler-cli": "^18.2.0",
    "typescript": "~5.5.0"
  }
}
```

Run: `npm install`, then `npm start` → open `http://localhost:8700`.

> **HMR gotcha when you edit the init code.** Angular's dev-server hot-swaps the component
> class **without re-running `ngOnInit`**, so an edit to your init logic appears to do nothing —
> the already-pending `initializeAnalyticsSdk` from the pre-edit class keeps running. After
> changing anything in the auth/init path, **hard-reload** (Cmd+Shift+R) so the whole app
> re-bootstraps and your new code actually runs.

---

# Security notes

- **Never mint tokens in the browser.** The client secret and raw access token stay in the
  broker; the browser only ever receives the short-lived FrontDoor URL.
- **PKCE + `state` are both enforced.** Every port uses `S256` PKCE and validates a random
  per-session `state` on the callback; OAuth-error pages are `text/plain` (no reflected HTML).
- The scripted ECA-creation path (in the Salesforce repo's `metadata-utils`) uses SOAP
  username/password — use it only against dev/scratch orgs, never production.
- `.env` holds credentials — keep it git-ignored.
- For production auth, prefer the manual ECA (Option A) and a confidential client with the
  secret held server-side; use HTTPS and `SameSite=None; Secure` cookies if the broker and
  app are on different sites, rotate the session id after login, and back sessions with a
  shared store (see [Security invariants](#security-invariants-non-negotiable)).

Licensed Apache-2.0.
