Iframe
Frame our page inside yours. Members signed in to your app go straight in, and we ship updates without any change on your side.
The iframe is a page we serve, framed by yours. Your backend mints a member token, and your page hands it over when the frame asks. Members already signed in to your app are never asked to sign in again.
What you build: one endpoint on your backend, and a message listener on your page.
Members need a supported browser.
1. Set up in your console
Section titled “1. Set up in your console”Your backend vouches for a member by signing a short-lived JWT with a key only it holds. Your Doshi console makes that key for you.
- Sign in to your console, and switch it to Test in its header. Getting access has its address.
- In Settings, on the Integration tab, press Add key and choose Generate a key.
- Copy the four
DOSHI_*variables the console shows you into your backend’s secret store. They are shown this once:DOSHI_ISS, the issuer Doshi assigns you;DOSHI_AUD, our exchange endpoint;DOSHI_KID, the key’skid; andDOSHI_PRIVATE_KEY.
To keep the private key in your HSM or KMS instead, add its public key or a JWKS URL; see Adding a key.
No access yet? See Access and invites.
2. Allow your pages
Section titled “2. Allow your pages”Allow every origin whose pages frame Doshi. The frame refuses to load anywhere else.
Add each as a bare origin, such as https://app.acme.example, at the console’s setup step
Allow the pages that show Doshi, or after your first exchange on its Allowed
origins tab. What the console accepts is in Allowed origins.
3. Add a token endpoint
Section titled “3. Add a token endpoint”Add one endpoint to your backend, such as POST /api/doshi-token. For the customer your
own session has already authenticated, it signs an assertion and exchanges it for a
member token. Copy sign-assertion.ts from Sign an assertion: it is tested
against the rules the exchange applies. With it, the endpoint’s core is:
import { configFromEnv, fetchMemberToken } from "./sign-assertion";
// DOSHI_ISS, DOSHI_AUD, DOSHI_KID and DOSHI_PRIVATE_KEY, from your secret store.const config = configFromEnv(process.env);
// In your "give my page a coach token" endpoint, for the customer that endpoint// has already authenticated:const token = await fetchMemberToken(config, customer.id, { display: { name: customer.name, customer_since: customer.since },});// -> { access_token: "...", token_type: "Bearer", expires_in: 900 }Set the four DOSHI_* variables to the values your console showed you in step 1. Return
access_token and expires_in to your page, and nothing else.
The endpoint must authenticate the caller as your own session holder, and sign for that customer only. It is where your session becomes a Doshi session, so nothing else may reach it.
Call it with your Test values. A 400 names its cause in the error table.
4. Frame Doshi
Section titled “4. Frame Doshi”Point an <iframe> at:
https://surface.doshi.app/embed.html?client=<slug>&env=<live|test>It takes these parameters:
| Parameter | Value |
|---|---|
client |
Your client slug, from your console’s Setup values, never a UUID. Required: a URL without a known slug cannot be framed. |
env |
test while you integrate, live once you go live. Required: the frame loads only on the origins that environment allows, and accepts only its member tokens. |
edition |
The key of the edition to brand the frame as before the token lands. See Parameters. |
start |
Where Doshi opens: learning, course or lesson. See Parameters. |
id |
The course or lesson id, with start= or start=. |
locale |
A BCP 47 tag such as nl-. See Parameters. |
https://surface.doshi.app/embed.html?client=acme&env=test&start=lesson&id=0f8e…&locale=nl-NL5. Answer its token requests
Section titled “5. Answer its token requests”The frame asks your page for a token by posting a message to its parent:
{ "type": "doshi:token-request", "id": "5b0c9d1e-…" }Your page answers with a doshi:token message, posted back to the frame, quoting the
request’s id:
{ "type": "doshi:token", "id": "5b0c9d1e-…", "token": "eyJhbGciOiJFUzI1NiIs…" }This is the whole page side, including the two events of the next step. It is the file this page imports, typechecked and unit-tested on every build against the rules below, so copy it as it is:
// Frame Doshi in your page and answer its token requests. Browser only: your page runs this,// and `getToken` asks your backend, which holds the private key, for a member token.//// const doshi = frameDoshi(document.querySelector("#doshi")!, {// client: "acme-bank",// env: "test",// getToken: () => fetch("/doshi/token").then((r) => r.json()).then((t) => t.access_token),// onError: ({ code }) => console.log(code),// });//// doshi.unmount(); // on logout//// `getToken` rejects when the member has no session; the frame then hears `token: null`.
/** Where the frame is served from, and the only origin this page talks to. */export const DOSHI_ORIGIN = "https://surface.doshi.app";
/** The frame asks for a token: before its first screen, then before each one expires. */export type DoshiTokenRequest = { type: "doshi:token-request"; id: string };/** Your answer, quoting the request's `id`. `null` means the member has no session. */export type DoshiToken = { type: "doshi:token"; id: string; token: string | null };/** The first screen has rendered. Posted once. */export type DoshiReady = { type: "doshi:ready" };/** The member cannot use Doshi now. Posted once per `code`. */export type DoshiError = { type: "doshi:error"; code: "unavailable" | "auth_expired"; during: "load" | "session";};
export type FrameOptions = { /** Your client slug, from your console's Setup values. */ client: string; /** `test` while you integrate, `live` once you go live. */ env: "live" | "test"; /** A fresh member token from your backend. Reject when the member has no session. */ getToken: () => Promise<string>; /** Where Doshi opens, with `id` naming the course or lesson. */ start?: "learning" | "course" | "lesson"; id?: string; /** A BCP 47 tag such as `nl-NL`. */ locale?: string; onReady?: () => void; onError?: (error: Pick<DoshiError, "code" | "during">) => void; /** Our origin. Change it only when we tell you to. */ origin?: string;};
const isMessage = <T extends { type: string }>(data: unknown, type: T["type"]): data is T => typeof data === "object" && data !== null && "type" in data && data.type === type;
/** Draw the frame inside `element`. `unmount` removes it, and the member's session with it. */export function frameDoshi(element: HTMLElement, options: FrameOptions): { unmount: () => void } { const origin = options.origin ?? DOSHI_ORIGIN; const query = new URLSearchParams({ client: options.client, env: options.env }); if (options.start) query.set("start", options.start); if (options.id) query.set("id", options.id); if (options.locale) query.set("locale", options.locale);
const frame = document.createElement("iframe"); frame.src = `${origin}/embed.html?${query}`; frame.title = "Doshi"; const errored = new Set<string>();
const onMessage = async (event: MessageEvent) => { // Only our frame. The token is a secret, so check who asked before you answer. if (event.origin !== origin || event.source !== frame.contentWindow) return; const message: unknown = event.data;
if (isMessage<DoshiTokenRequest>(message, "doshi:token-request")) { // A fresh token for every ask, and none kept here: the frame holds its own. const token = await options.getToken().catch(() => null); const answer: DoshiToken = { type: "doshi:token", id: message.id, token }; // To our origin, never "*": the browser then delivers it only if the frame still holds // our page. frame.contentWindow?.postMessage(answer, origin); } else if (isMessage<DoshiReady>(message, "doshi:ready")) { options.onReady?.(); } else if (isMessage<DoshiError>(message, "doshi:error") && !errored.has(message.code)) { errored.add(message.code); options.onError?.({ code: message.code, during: message.during }); } };
window.addEventListener("message", onMessage); element.append(frame); return { unmount: () => { window.removeEventListener("message", onMessage); frame.remove(); }, };}Its first lines show the call: the element to draw in, your slug and environment, and
getToken. It takes the parameters of step 4 as options too, start, id and locale, and
unmount is your logout. Your console’s
Setup values tab shows this file with your slug and environment filled in.
To write your own instead, keep its check before every answer: the message’s origin is
ours and its source is the frame’s window. The rules below say the rest.
getToken calls the endpoint from step 3:
async function getToken() { const res = await fetch("/api/doshi-token", { method: "POST" }); if (!res.ok) throw new Error(`doshi-token: ${res.status}`); const { access_token } = await res.json(); return access_token;}- Post to our origin, never
"*". The browser then delivers the token only if the frame still holds our page. - Answer every ask with a fresh token. The frame asks before its first screen, then about 20 seconds before each token expires. It caches the token itself, so you should not. See the contract.
- Answer
token: nullwhen the member has no session. Silence means the same: the frame waits 10 seconds for an answer.
The frame ignores any answer that does not come from its parent window or that quotes
another id.
6. Handle its two events
Section titled “6. Handle its two events”The frame posts doshi:ready once, when its first screen has rendered, and
doshi:error once per code:
{ "type": "doshi:error", "code": "auth_expired", "during": "session" }code |
What to do |
|---|---|
unavailable |
Offer “try again later”. |
auth_expired |
Your page stopped handing over tokens. Sign the member in again and reload the frame, or remove it. |
during is load if the member never saw a screen, and session if they had been
using Doshi. See Events.
7. Allow the frame in your Content Security Policy
Section titled “7. Allow the frame in your Content Security Policy”If your page sends a Content-Security-Policy, merge these sources into it, as
Content Security Policy explains:
frame-src https://surface.doshi.app;The frame’s own requests are its page’s business, so nothing else is needed. See Content Security Policy.
8. Check it in Test
Section titled “8. Check it in Test”9. Go live
Section titled “9. Go live”Switch your console to Live in its header, and repeat steps 1 and 2 there: a Live key, and its four
DOSHI_* variables in your backend’s secret store, then the origins of your live pages. Live has its own issuer, and should
have its own key; see Yours. For Live we recommend
your own public key over a generated one.
Then deploy your backend with the Live variables, and load the embed URL with env=live. No host changes: the exchange
URL, and with it aud, is the same in both environments.
Logout
Section titled “Logout”Remove the <iframe> element. The frame holds the member token in memory only, so
it dies with the frame. There is no logout endpoint to call. See
Hand over the token.