Calendar

GitHub

Talking to a different service

Most islands talk to your Phoenix backend over live or api. This one doesn't: the agenda comes from a separate service (a stand-in for Microsoft Graph). The pattern works whenever your app and that service trust the same identity provider (here, imagine Entra) — so a token minted for the signed-in user is accepted by both. Phoenix mints it; the island spends it directly.

The token's journey

  1. Mint (server). On each page render the root layout signs a short-lived, per-user token and drops it into the page-wide runtime context under tokens.graphcontext, not payload: a page-wide credential, minted once, the same for every island on the page.
  2. Embed. <.runtime> serializes the context into one JSON <script> in the page. It rides in the HTML the user already received — so keep it short-lived and narrowly scoped, never a long-lived secret.
  3. Read (client). The island receives that context at its boundary and reads context.tokens.graph — no round-trip, it's already there.
  4. Spend. The island calls the third party with its own fetch and an Authorization: Bearer header. Phoenix is never in that request's path — no proxy, no api helper, no channel.
  5. Verify (the other service). The service checks the bearer and answers. No session or CSRF is involved — that's the whole reason this can't go through api.
  6. Expire → re-auth. The embedded token is a render-time snapshot, so it eventually expires. A 401 is the island's signal to get a fresh one (re-mint via a small session-protected endpoint, or a LiveView push) — flip the "simulate expired token" checkbox in the demo to see the 401 → re-auth path.

The three sides, in code

1 · Phoenix mints the token into context

# root.html.heex — minted once, per user, into the page-wide runtime context
<KeenPhoenixSvelte.runtime context={%{
  user: %{id: @current_user.id, name: @current_user.name, email: @current_user.email},
  # A short-lived, per-user bearer for a *different* service. In a real app this
  # is an on-behalf-of token for graph.microsoft.com; here it's a Phoenix.Token.
  tokens: %{graph: Phoenix.Token.sign(ExampleWeb.Endpoint, "graph token", @current_user.id)}
}} />

2 · The island spends it with its own fetch

// calendar/js/App.svelte — the island calls the third party with its OWN fetch,
// using the token from context. No `api`, no `live` — nothing Phoenix-specific.
let { context } = $props();

$effect(() => {
  fetch("/mock-graph/v1.0/me/calendarView", {
    headers: { Authorization: `Bearer ${context.tokens.graph}` },
  })
    .then((r) => (r.status === 401 ? reauth() : r.json()))
    .then((data) => (events = data.value));
});

3 · The other service verifies the bearer

# RequireGraphToken — what the "third-party" service does with the bearer.
# No session, no CSRF: it's a different service reached with a token, which is
# exactly why the island uses its own fetch instead of the `api` helper.
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
     {:ok, user_id} <- Phoenix.Token.verify(Endpoint, "graph token", token, max_age: 3600) do
  assign(conn, :graph_user_id, user_id)
else
  _ -> conn |> put_status(:unauthorized) |> json(%{error: %{code: "InvalidAuthenticationToken"}}) |> halt()
end

Why deliver it this way

Direct, not proxied

The island hits the service straight from the browser — your backend isn't a relay in the hot path, and the same mount works on the plain /calendar-plain page with no live at all.

Small blast radius

Because it lives in the DOM, the token is short-lived, per-user, and scoped to just this service. A downstream secret would never go here — that stays server-side, behind api.

Full write-up: the Runtime context guide covers relaying user identity and shared tokens, including token freshness.