Skip to content

Use case — SPA / custom interaction ​

What is the "interaction" layer? ​

Between the RP's /auth redirect and the OP's redirect back with a code, the OP runs an interaction: login, optional MFA step-up, optional consent, optional account chooser. OIDC Core 1.0 §3.1 specifies what crosses the wire (the request parameters and the final response) but says nothing about how the OP renders these intermediate pages. Each OP chooses its own UX.

The library models that UX as a pluggable interaction.Driver. The default driver renders server-side HTML. The JSON driver returns the same prompts as JSON so a SPA can render them. A custom driver can talk to whatever front end you ship. The state machine stays in the OP in every case: the front end fetches the next prompt, posts the user's answer, and the OP decides what comes next.

Which option do I use?

GoalUse
A SPA (React, Vue, Svelte, ...) that handles the whole loginop.WithUI(op.SPAUI{LoginMount: "/login"}). The OP mounts the state endpoint /login/state/{uid}; with StaticDir set it also serves the shell and assets. The OP converts the final redirect into a JSON envelope the SPA can follow, and renders the prompts as JSON (interaction.JSONDriver).
A non-browser client, or a driver that renders something other than HTMLop.WithUI(op.DriverUI{Driver: ...}), for example interaction.JSONDriver{}. The state endpoint is /interaction/{uid}, and the final response is the plain 302 to the RP, which fetch() cannot follow.

This page covers the SPA path. examples/10-react-login shows it end to end; despite the name, its bundle is plain HTML, CSS, and JS.

Specs referenced on this page
Vocabulary refresher
  • Interaction layer: everything between the RP's /auth redirect and the OP's redirect back with the code. The wire parameters are fixed by the spec; how the intermediate pages look is not, and that is the seam this page exposes.
  • JSON driver: the driver that returns prompts as JSON instead of HTML. The front end fetches { type: "auth.password" | "consent.scope" | ... }, renders it, and posts the answer back.
  • CSP (Content Security Policy): a response header (Content-Security-Policy: default-src 'none'; ...) that tells the browser which resources a page may load. The OP's own HTML pages carry a strict policy, so a hostile error_description cannot become XSS.

Sources: examples/10-react-login is the OP-mounted SPA wiring through op.SPAUI. examples/16-custom-interaction wraps JSONDriver in a custom driver. examples/17-spa-composite-store is the production-shaped combination: SPA interaction, MySQL for durable state, and Redis for sessions, interactions, and consumed JTIs. The bundle there is hand-rolled so it runs without a build step; the seam is framework-neutral.

Choosing a UI path ​

op.WithUI takes exactly one mode, so you pick the row that matches what you want and pass its value. The modes do not combine, and there is nothing to reconcile.

You wantPass to op.WithUI
The OP's own pages as they arenothing (the built-in HTML pages are the default), or op.HTMLUI{}
The OP's pages in your markup, one screen or severalop.HTMLUI{Login: ..., Consent: ..., Chooser: ...}; each field is an op.PageTemplate, and a screen you leave out keeps the built-in markup
A SPA that owns every screenop.SPAUI{LoginMount: "/login", StaticDir: ...}
No HTML from the OP, or a front end of your ownop.DriverUI{Driver: ...}, for example interaction.JSONDriver{}. A browser SPA should use op.SPAUI instead, because the driver's terminal response is a 302 that fetch() cannot follow
Your own driver plus page templatesop.DriverUI{Driver: interaction.TemplateOverlayDriver{Inner: yourDriver, ConsentTemplate: ...}}

op.WithLogoutUI is a separate option that applies in every mode: the logout pages stay server-rendered, so it composes with all of the rows above. Locale overlays through op.WithLocaleBundle apply to the built-in pages.

The built-in HTML driver renders no client-side script, so a PrimaryPasskey step needs op.SPAUI or an op.DriverUI whose driver runs the ceremony.

Architecture ​

With op.SPAUI, /auth redirects the browser to LoginMount/{uid}. The SPA then talks to the OP at LoginMount/state/{uid}:

MethodPathRole
GETLoginMount/{uid}SPA shell (index.html), served by the OP when StaticDir is set, otherwise by your own route
GETLoginMount/assets/{path...}bundled assets, served when StaticDir is set
GETLoginMount/state/{uid}current prompt as JSON
POSTLoginMount/state/{uid}the user's submission for the current prompt
DELETELoginMount/state/{uid}cancel the in-flight interaction

LoginMount is the only routing field of op.SPAUI. Consent goes through the same state route, and the logout confirmation stays on the normal /end_session flow, rendered by the server. To restyle the logout pages, use op.WithLogoutUI, which applies in every mode, op.SPAUI included (see Brand the logout pages).

The SPA asks for each screen as JSON
The authorize endpoint redirects the browser to the SPA shell at the login mount. From there the SPA fetches each interaction state from the OP as JSON, posts the answers back, and finally follows a redirect envelope to the RP.User's browserfollows redirectsSPA bundleyour code · your UIOPgo-oidc-providerGET /auth?…302 → /login/{uid}uid created · cookie setGET /login/{uid}200 index.htmlGET /login/state/{uid}Accept: application/json200 { type:"auth.password" }inputs · state_ref · …renders the form, your wayPOST /login/state/{uid}{ state_ref, values }200 { type:"consent.scope" }or a redirect envelopePOST /login/state/{uid}approved_scopes200 { type:"redirect", … }location=https://rp/cb?code=…send the browser to location
After the shell loads, the OP only ever answers in JSON. It describes what each screen has to ask for; how it looks, and which framework draws it, is entirely on your side of the boundary.

Code ​

Mount the SPA ​

go
provider, err := op.New(
  /* required options */
  op.WithUI(op.SPAUI{
    LoginMount: "/login",
    StaticDir:  "./web/dist", // optional: the OP serves the shell and assets
  }),
)
if err != nil {
  log.Fatal(err)
}
http.ListenAndServe(":8080", provider)

When StaticDir is empty, the OP serves only the state route and redirects to LoginMount/{uid}; you serve that shell yourself:

go
mux := http.NewServeMux()
mux.Handle("GET /login/{uid}", spaShell) // returns your index.html
mux.Handle("/", provider)                // the OP owns /login/state/{uid} and the protocol endpoints

Without op.WithUI the default is the built-in HTML pages. Passing op.WithUI(op.DriverUI{Driver: interaction.JSONDriver{}}) moves the same JSON contract to /interaction/{uid}, but the terminal response there is a 302 that a fetch() call cannot read, so a browser SPA should use op.SPAUI.

Content-Security-Policy of the shell ​

When the OP serves the shell (StaticDir set), the response carries a default Content-Security-Policy; asset responses carry none:

text
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'

The bundle must therefore load its script from the OP's own origin, with no inline script and no <base> element (base-uri 'none' forbids it; use paths that resolve against LoginMount/{uid}). A bundle that needs inline scripts, other origins, or a <base> sets op.SPAUI.ContentSecurityPolicy. A non-empty value replaces the default and is checked by the same rules as op.PageTemplate.ContentSecurityPolicy, so op.New fails on a value those rules reject, such as one that drops frame-ancestors or sets form-action.

Frontend snippet ​

jsx
import { useEffect, useState } from "react";

// FieldKind values: "text", "password", "otp_code", "email", "hidden".
const inputTypeFor = (kind) =>
  ({ password: "password", email: "email", hidden: "hidden" })[kind] ?? "text";

// The final authorization response arrives as an envelope, not a redirect.
function deliver(next) {
  if (next.type === "redirect") {
    window.location.href = next.location;
    return true;
  }
  if (next.type === "form_post") {
    const form = document.createElement("form");
    form.method = "post";
    form.action = next.action;
    for (const [name, value] of Object.entries(next.fields)) {
      const input = document.createElement("input");
      Object.assign(input, { type: "hidden", name, value });
      form.appendChild(input);
    }
    document.body.appendChild(form);
    form.submit();
    return true;
  }
  return false;
}

const FALLBACK_ERROR = "The details you entered are incorrect. Try again.";

export function Interaction({ uid }) {
  const stateURL = `/login/state/${uid}`;
  const [prompt, setPrompt] = useState(null);
  const [values, setValues] = useState({});
  const [failed, setFailed] = useState(false);

  useEffect(() => {
    fetch(stateURL, {
      headers: { Accept: "application/json" },
      credentials: "same-origin",
    })
      .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
      .then(setPrompt);
  }, [uid]);

  async function onSubmit(e) {
    e.preventDefault();
    const r = await fetch(stateURL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": prompt.csrf_token ?? "",
        Accept: "application/json",
      },
      credentials: "same-origin",
      body: JSON.stringify({ state_ref: prompt.state_ref, values }),
    });
    if (!r.ok) {
      // check the status before parsing: the body may not be JSON
      setFailed(true);
      return;
    }
    const next = await r.json();
    if (!deliver(next)) {
      setPrompt(next); // after a rejected submission, next.error is set
      setValues({});
    }
  }

  if (failed) return <p role="alert">Sign-in cannot continue. Start again from the application.</p>;
  if (!prompt) return null;
  return (
    <form onSubmit={onSubmit}>
      {prompt.error && (
        <p role="alert">{prompt.error.message || FALLBACK_ERROR}</p>
      )}
      {prompt.inputs?.map((f) => (
        <label key={f.name}>
          <span>{f.label_text || f.label || f.name}</span>
          <input
            name={f.name}
            type={inputTypeFor(f.kind)}
            required={f.required}
            onChange={(e) =>
              setValues((v) => ({ ...v, [f.name]: e.target.value }))
            }
          />
        </label>
      ))}
      <button type="submit">Continue</button>
    </form>
  );
}
vue
<script setup>
import { ref, reactive, onMounted } from "vue";

const props = defineProps({ uid: String });
const stateURL = `/login/state/${props.uid}`;
const prompt = ref(null);
const values = reactive({});
const failed = ref(false);

const FALLBACK_ERROR = "The details you entered are incorrect. Try again.";

// FieldKind values: "text", "password", "otp_code", "email", "hidden".
const inputTypeFor = (kind) =>
  ({ password: "password", email: "email", hidden: "hidden" })[kind] ?? "text";

// The final authorization response arrives as an envelope, not a redirect.
function deliver(next) {
  if (next.type === "redirect") {
    window.location.href = next.location;
    return true;
  }
  if (next.type === "form_post") {
    const form = document.createElement("form");
    form.method = "post";
    form.action = next.action;
    for (const [name, value] of Object.entries(next.fields)) {
      const input = document.createElement("input");
      Object.assign(input, { type: "hidden", name, value });
      form.appendChild(input);
    }
    document.body.appendChild(form);
    form.submit();
    return true;
  }
  return false;
}

onMounted(async () => {
  const r = await fetch(stateURL, {
    headers: { Accept: "application/json" },
    credentials: "same-origin",
  });
  if (r.ok) prompt.value = await r.json();
});

async function onSubmit() {
  const r = await fetch(stateURL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-CSRF-Token": prompt.value.csrf_token ?? "",
      Accept: "application/json",
    },
    credentials: "same-origin",
    body: JSON.stringify({
      state_ref: prompt.value.state_ref,
      values,
    }),
  });
  if (!r.ok) {
    // check the status before parsing: the body may not be JSON
    failed.value = true;
    return;
  }
  const next = await r.json();
  if (!deliver(next)) {
    prompt.value = next; // after a rejected submission, next.error is set
    for (const k of Object.keys(values)) delete values[k];
  }
}
</script>

<template>
  <p v-if="failed" role="alert">Sign-in cannot continue. Start again from the application.</p>
  <form v-else-if="prompt" @submit.prevent="onSubmit">
    <p v-if="prompt.error" role="alert">{{ prompt.error.message || FALLBACK_ERROR }}</p>
    <label v-for="f in prompt.inputs" :key="f.name">
      <span>{{ f.label_text || f.label || f.name }}</span>
      <input
        :name="f.name"
        :type="inputTypeFor(f.kind)"
        :required="f.required"
        v-model="values[f.name]"
      />
    </label>
    <button type="submit">Continue</button>
  </form>
</template>

Both tabs follow the same loop: GET the prompt, render its inputs, POST { state_ref, values }, and repeat until the response is a terminal envelope. They render only generic inputs, show the label from label_text (falling back on the label key), and show error when a submission was rejected. A real front end also branches on prompt.type, because consent, the chooser, captcha, and passkey prompts carry their content in data.

The wire shapes come straight from op/interaction:

  • Prompt: type, data, inputs, state_ref, csrf_token, error (only after a rejected submission), plus the locale fields locale, ui_locales_hint, and locales_available (see i18n / locale negotiation). The built-in types are auth.password, auth.totp, auth.email_otp.send, auth.email_otp.verify, auth.passkey, auth.recovery_code, auth.choose_factor, captcha, consent.scope, and interaction.chooser; your own prompts use an organisation prefix.
  • FieldSpec and data: every member is snake_case too (name, kind, label, label_text, required, max_len, min_len, pattern; scopes, client, accounts, add_account_url). label is an i18n key and label_text is that key resolved in locale; a factor-choice prompt (auth.choose_factor) carries the same pair on each entry of data.options. An initialism stays one word: allow_credentials, site_key, session_id, client_id, logo_url, tos_uri. kind is one of the strings shown above. Byte fields (challenge, a credential's id) are standard base64.
  • Status first: 200 carries a prompt or a terminal envelope; 204 means the interaction was cancelled and there was no redirect_uri to notify; anything else is an error whose body is normally {"error", "error_description"} but may be text/plain. Check r.ok before calling r.json(). The table under Status codes says what to do for each.
  • Terminal envelopes: the final authorization response cannot be a redirect, because fetch() cannot follow a cross-origin redirect or run an auto-submitting HTML page. The OP sends one of two envelopes for the SPA to deliver at document level.
typeShapeThe SPA doesResponse modes
redirect{"type":"redirect","location":"<url>"}window.location.href = locationquery, and JARM query.jwt / fragment.jwt
form_post{"type":"form_post","action":"<redirect_uri>","fields":{...}}build a hidden form and submit itform_post, and JARM form_post.jwt

A front end that skips the form_post case strands the flow: the RP never receives a response.

Consent step

For prompt.type === "consent.scope", read the scopes from prompt.data.scopes (each has name, description, required) and the client from prompt.data.client. The prompt also declares one text input, approved_scopes; the SPA submits { approved_scopes: "openid profile" }, the approved scope names joined by spaces. Approving a subset is allowed; omitting a required scope is refused with a 4xx. To decline the whole request, send DELETE to the state URL (see Cancel). See Custom consent UI.

Why send X-CSRF-Token?

The OP sets an HttpOnly __Host-oidc_csrf cookie and copies the same value into each prompt as csrf_token. Because script cannot read the cookie, the prompt is the only way the SPA gets the value. The SPA's job is to copy prompt.csrf_token into the X-CSRF-Token header on each POST; the OP compares header and cookie (double-submit pattern). The SPA never generates or stores the token.

Show a rejected submission ​

A wrong password, a wrong TOTP code or a wrong email code does not fail the request. The OP answers 200 with the same factor's prompt again, and that prompt carries a one-shot notice:

json
{
  "type": "auth.password",
  "inputs": [{ "name": "password", "kind": "password", "label": "login.password.label", "label_text": "Password", "required": true }],
  "state_ref": "...",
  "csrf_token": "...",
  "locale": "en",
  "error": {
    "code": "credentials_rejected",
    "message": "The details you entered are incorrect. Try again."
  }
}
  • error.code comes from a closed set, and credentials_rejected is the only value today. It is the same whether the account exists or not, so the notice does not reveal which usernames are registered. Treat an unknown code as a generic failure.
  • error.message is the notice in the prompt's locale. It is absent when the OP was built without a locale resolver; then show a message of your own keyed on code.
  • The notice is not stored. Reloading with GET runs the step again and returns a prompt without error, and so does the next prompt after a correct answer. It can also arrive on a multi-step factor's second screen (a wrong email code stays on the verify screen) and on a prompt that interposes a captcha first.
  • The OP's own HTML driver renders the same notice as a <p role="alert"> above the form, with the same fallback text.

The attempt that engages the lockout is different. Earlier wrong attempts answered 200 with error; the one that trips the limit answers 400 with the plain {"error", "error_description"} envelope and no prompt, and further attempts against the locked factor answer 400 too. The envelope never says "locked", because that would confirm the account exists. Show a generic "sign-in cannot continue" screen and send the user back to the application to start again.

Status codes ​

Every method of the state route answers with one of these. Branch on the status before reading the body.

StatusMeaningThe SPA does
200The next prompt (with error after a rejection) or a terminal envelopeRender it, or deliver the envelope
204DELETE cancelled the interaction and there is no redirect_uri to notifyShow a "cancelled" screen
400The body could not be parsed, or the factor cannot continue: the attempt that locked it, an exhausted captcha, a locked factorStop. Show a generic failure and restart from the application
403Origin not allowed, CSRF token missing or wrong, or state_ref rejectedGET the prompt again for a fresh csrf_token and state_ref, then resubmit once. If it persists, check CORS and cookies and the Referrer-Policy note below
404The interaction cookie is missing or does not match the uid, the interaction expired, or the stored interaction state cannot be decodedRestart authorization. The OP answers the same for "unknown uid" and "not yours"
409The interaction changed since the prompt you hold, for example the user acted in another tabGET the prompt again
410The interaction already completedRestart from the application

Cancel ​

DELETE takes the same X-CSRF-Token header and credentials as POST, with no body. A request without the header is refused with 403.

js
await fetch(stateURL, {
  method: "DELETE",
  headers: { "X-CSRF-Token": prompt.csrf_token ?? "", Accept: "application/json" },
  credentials: "same-origin",
});

The answer is a terminal envelope carrying access_denied for the client when the request has a redirect_uri, and 204 when it has none.

Referrer-Policy and form posts ​

fetch() in the default cors mode sends the real Origin, so the snippets above are unaffected by a Referrer-Policy. A plain HTML form post from a page served under Referrer-Policy: no-referrer is different: the browser sends Origin: null, and the state route answers 403. The same applies to the logout confirmation form. The OP logs one WARN per provider when it sees that combination. See Compose with your own middleware.

Error rendering ​

How the OP reports a failure before it can trust a redirect target (an unregistered client_id, a malformed request) depends on the driver. The JSON driver always answers with the RFC 6749 §5.2 envelope, {"error": "...", "error_description": "...", "state": "..."}. The HTML driver does too, unless the request prefers Accept: text/html; then it answers with a page that exposes the same fields as data-* attributes on one element:

html
<div id="op-error"
     data-code="invalid_request_uri"
     data-description="request_uri has expired"
     data-state="abc">
  <h1>Authorization error</h1>
  ...
</div>

The page contains no script, style, or image, and every value is HTML-escaped, including the attacker-controlled state. It is served under the HTML driver's default policy, default-src 'none'; style-src 'none'; frame-ancestors 'none'; base-uri 'none'.

CORS and cookies ​

When the SPA shell is served from the OP's origin, as in the snippets above, no CORS setup is needed and credentials: "same-origin" is correct.

When the SPA lives on another origin, add that origin with op.WithCORSOrigins. The ceremony (state route, /end_session confirmation) accepts only the issuer origin and origins you pass to op.WithCORSOrigins; a client's redirect_uri origin does not count for it. The origin must also be same-site with the issuer. A genuinely cross-site origin may pass the preflight, but the browser withholds the __Host- ceremony cookies (__Host-oidc_interaction is SameSite=Lax; __Host-oidc_csrf and __Host-oidc_logout_csrf are SameSite=Strict), and the state route answers 404. Put the login UI on an issuer-sibling host such as login.example.com beside op.example.com, call the OP by its absolute URL, and use credentials: "include". CORS for SPA covers the API side.

To change only the consent wording, overlay keys with op.WithLocaleBundle; the bundled HTML driver picks them up and you keep its CSP and CSRF handling. To own the consent markup while the OP keeps the transport, pass op.HTMLUI{Consent: ...} to op.WithUI; it is an alternative to op.SPAUI, since one mode is active at a time. See Custom consent UI and i18n / locale negotiation.