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?
| Goal | Use |
|---|---|
| A SPA (React, Vue, Svelte, ...) that handles the whole login | op.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 HTML | op.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
- OpenID Connect Core 1.0 — §3.1 (authorization endpoint), §3.1.2.4 (consent)
- OpenID Connect RP-Initiated Logout 1.0 —
/end_session - RFC 7636 — PKCE (Proof Key for Code Exchange)
- RFC 8252 — OAuth 2.0 for Native Apps, §8.1 (browser-side public clients)
- RFC 6749 — §5.2 (error response JSON envelope)
Vocabulary refresher
- Interaction layer: everything between the RP's
/authredirect 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 hostileerror_descriptioncannot become XSS.
Sources:
examples/10-react-loginis the OP-mounted SPA wiring throughop.SPAUI.examples/16-custom-interactionwrapsJSONDriverin a custom driver.examples/17-spa-composite-storeis 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 want | Pass to op.WithUI |
|---|---|
| The OP's own pages as they are | nothing (the built-in HTML pages are the default), or op.HTMLUI{} |
| The OP's pages in your markup, one screen or several | op.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 screen | op.SPAUI{LoginMount: "/login", StaticDir: ...} |
| No HTML from the OP, or a front end of your own | op.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 templates | op.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}:
| Method | Path | Role |
|---|---|---|
GET | LoginMount/{uid} | SPA shell (index.html), served by the OP when StaticDir is set, otherwise by your own route |
GET | LoginMount/assets/{path...} | bundled assets, served when StaticDir is set |
GET | LoginMount/state/{uid} | current prompt as JSON |
POST | LoginMount/state/{uid} | the user's submission for the current prompt |
DELETE | LoginMount/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).
Code
Mount the SPA
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:
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 endpointsWithout 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:
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
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>
);
}<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 fieldslocale,ui_locales_hint, andlocales_available(see i18n / locale negotiation). The built-in types areauth.password,auth.totp,auth.email_otp.send,auth.email_otp.verify,auth.passkey,auth.recovery_code,auth.choose_factor,captcha,consent.scope, andinteraction.chooser; your own prompts use an organisation prefix. FieldSpecanddata: every member is snake_case too (name,kind,label,label_text,required,max_len,min_len,pattern;scopes,client,accounts,add_account_url).labelis an i18n key andlabel_textis that key resolved inlocale; a factor-choice prompt (auth.choose_factor) carries the same pair on each entry ofdata.options. An initialism stays one word:allow_credentials,site_key,session_id,client_id,logo_url,tos_uri.kindis one of the strings shown above. Byte fields (challenge, a credential'sid) are standard base64.- Status first:
200carries a prompt or a terminal envelope;204means the interaction was cancelled and there was noredirect_urito notify; anything else is an error whose body is normally{"error", "error_description"}but may betext/plain. Checkr.okbefore callingr.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.
type | Shape | The SPA does | Response modes |
|---|---|---|---|
redirect | {"type":"redirect","location":"<url>"} | window.location.href = location | query, and JARM query.jwt / fragment.jwt |
form_post | {"type":"form_post","action":"<redirect_uri>","fields":{...}} | build a hidden form and submit it | form_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:
{
"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.codecomes from a closed set, andcredentials_rejectedis 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.messageis the notice in the prompt'slocale. It is absent when the OP was built without a locale resolver; then show a message of your own keyed oncode.- The notice is not stored. Reloading with
GETruns the step again and returns a prompt withouterror, 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.
| Status | Meaning | The SPA does |
|---|---|---|
200 | The next prompt (with error after a rejection) or a terminal envelope | Render it, or deliver the envelope |
204 | DELETE cancelled the interaction and there is no redirect_uri to notify | Show a "cancelled" screen |
400 | The body could not be parsed, or the factor cannot continue: the attempt that locked it, an exhausted captcha, a locked factor | Stop. Show a generic failure and restart from the application |
403 | Origin not allowed, CSRF token missing or wrong, or state_ref rejected | GET 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 |
404 | The interaction cookie is missing or does not match the uid, the interaction expired, or the stored interaction state cannot be decoded | Restart authorization. The OP answers the same for "unknown uid" and "not yours" |
409 | The interaction changed since the prompt you hold, for example the user acted in another tab | GET the prompt again |
410 | The interaction already completed | Restart 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.
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:
<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.
Custom consent UI without a full SPA
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.