ユースケース — SPA / 対話画面のカスタマイズ
「対話画面(interaction)」レイヤとは
RP の /auth へのリダイレクトから、OP が code を付けて RP へ戻すまでの間に、OP は interaction を実行します。ログイン、任意の MFA ステップアップ、任意の同意画面、任意のアカウント選択です。OIDC Core 1.0 §3.1 は通信路上のデータ(要求パラメータと最終応答)を定めていますが、この中間のページをどう描画するかは定めていません。UX は OP ごとに選びます。
ライブラリはこの UX を、差し替え可能な interaction.Driver として扱います。既定のドライバはサーバ側で HTML を描画します。JSON ドライバは同じ画面を JSON で返すので、SPA が描画できます。独自のドライバを書けば、任意のフロントエンドと接続できます。どの場合も状態機械は OP にあります。フロントエンドは次の prompt を取得してユーザの回答を送り返し、その次を OP が決めます。
どのオプションを使うか
| 目的 | 使うもの |
|---|---|
| ログイン全体を扱う SPA(React、Vue、Svelte など) | op.WithUI(op.SPAUI{LoginMount: "/login"})。OP が状態エンドポイント /login/state/{uid} を公開し、StaticDir を設定すれば入口とアセットも配信します。最後のリダイレクトは、SPA が辿れる JSON のエンベロープに変換され、プロンプトは JSON(interaction.JSONDriver)で描画されます。 |
| ブラウザ以外のクライアント、または HTML 以外を描画するドライバ | op.WithUI(op.DriverUI{Driver: ...})(たとえば interaction.JSONDriver{})。状態エンドポイントは /interaction/{uid} で、最後の応答は RP への素の 302 です。fetch() ではこれを辿れません。 |
このページは SPA の経路を扱います。通しで動く例は examples/10-react-login です。名前に反して、同梱のバンドルは素の HTML、CSS、JS です。
このページで触れる仕様
- OpenID Connect Core 1.0 — §3.1(認可エンドポイント)、§3.1.2.4(同意)
- 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(ブラウザ上の public client)
- RFC 6749 — §5.2(エラーの JSON 応答)
用語の補足
- 対話画面(interaction)レイヤ: RP の
/authへのリダイレクトから、OP が code を付けて戻すまでの間の処理全体です(ログイン、任意の MFA ステップアップ、任意の同意、任意のアカウント選択)。通信路上のパラメータは仕様で決まっていますが、中間ページの見せ方は決まっていません。ここが差し替え口です。 - JSON ドライバ: 画面を HTML ではなく JSON で返すドライバです。フロントエンドは
{ type: "auth.password" | "consent.scope" | ... }を取得して描画し、回答を送り返します。 - CSP(Content Security Policy): ページが読み込んでよいリソースの種類をブラウザに伝えるレスポンスヘッダ(
Content-Security-Policy: default-src 'none'; ...)です。OP 自身の HTML ページは厳格なポリシーを持つので、悪意あるerror_descriptionが XSS に化けることはありません。
ソース:
examples/10-react-loginはop.SPAUIで OP 側に SPA をマウントする構成です。examples/16-custom-interactionはJSONDriverを独自ドライバで包む例です。examples/17-spa-composite-storeは本番に近い組み合わせで、SPA interaction、永続状態用の MySQL、セッション・interaction・消費済み JTI 用の Redis を一緒に動かします。バンドルはビルド手順なしで動かすために手書きですが、接続点はフレームワークに依存しません。
UI の方式を選ぶ
op.WithUI が受け取るモードは 1 つだけです。やりたいことに合う行を選び、その値を渡してください。モードどうしを組み合わせることはなく、衝突を調整する必要もありません。
| やりたいこと | op.WithUI に渡すもの |
|---|---|
| OP 標準のページをそのまま使う | 何も渡さない(組み込みの HTML ページが既定)、または op.HTMLUI{} |
| OP のページを自前のマークアップにする(1 画面でも複数画面でも) | op.HTMLUI{Login: ..., Consent: ..., Chooser: ...}。各フィールドは op.PageTemplate で、指定しない画面は組み込みのマークアップのままです |
| すべての画面を SPA が担う | op.SPAUI{LoginMount: "/login", StaticDir: ...} |
| OP から HTML を出さない、または独自のフロントエンドを使う | op.DriverUI{Driver: ...}(例: interaction.JSONDriver{})。ブラウザの SPA には代わりに op.SPAUI を使ってください。このドライバの終端応答は fetch() が追えない 302 です |
| 独自のドライバにページテンプレートを重ねる | op.DriverUI{Driver: interaction.TemplateOverlayDriver{Inner: 独自のドライバ, ConsentTemplate: ...}} |
op.WithLogoutUI は独立したオプションで、どのモードでも有効です。ログアウトのページは常にサーバ描画なので、上のどの行とも併用できます。op.WithLocaleBundle による文言の上書きは、組み込みのページに効きます。
組み込みの HTML ドライバはクライアント側スクリプトを描画しないため、PrimaryPasskey ステップには op.SPAUI か、ceremony を実行するドライバを持つ op.DriverUI が必要です。
アーキテクチャ
op.SPAUI を使うと、/auth はブラウザを LoginMount/{uid} にリダイレクトします。SPA は LoginMount/state/{uid} で OP と通信します。
| メソッド | パス | 役割 |
|---|---|---|
GET | LoginMount/{uid} | SPA の入口(index.html)。StaticDir を設定すると OP が配信し、未設定なら自前のルートで配信 |
GET | LoginMount/assets/{path...} | バンドルのアセット。StaticDir を設定したときに配信 |
GET | LoginMount/state/{uid} | 現在の prompt を JSON で返す |
POST | LoginMount/state/{uid} | 現在の prompt に対するユーザの送信を受ける |
DELETE | LoginMount/state/{uid} | 進行中の interaction をキャンセルする |
op.SPAUI のうちルーティングに影響するフィールドは LoginMount だけです。同意は同じ状態ルートを通り、ログアウトの確認は通常の /end_session のフローに残り、サーバが描画します。ログアウト画面の見た目を変えるには op.WithLogoutUI を使います。op.SPAUI を含むどのモードでも有効です(ログアウト画面をブランド化するを参照)。
コード
SPA をマウントする
provider, err := op.New(
/* 必須オプション */
op.WithUI(op.SPAUI{
LoginMount: "/login",
StaticDir: "./web/dist", // 省略可。OP が入口とアセットを配信する
}),
)
if err != nil {
log.Fatal(err)
}
http.ListenAndServe(":8080", provider)StaticDir が空なら、OP は状態ルートだけを公開し、LoginMount/{uid} へリダイレクトします。その入口は自前で配信してください。
mux := http.NewServeMux()
mux.Handle("GET /login/{uid}", spaShell) // 自前の index.html を返す
mux.Handle("/", provider) // /login/state/{uid} とプロトコルのエンドポイントは OP が持つop.WithUI を使わない場合、既定は組み込みの HTML ページです。op.WithUI(op.DriverUI{Driver: interaction.JSONDriver{}}) を渡すと、同じ JSON の契約が /interaction/{uid} に移りますが、そこでの終端の応答は fetch() で読めない 302 です。ブラウザの SPA には op.SPAUI を使ってください。
入口の Content-Security-Policy
OP が入口を配信する場合(StaticDir を指定した場合)、その応答には既定の Content-Security-Policy が付きます。アセットの応答には付きません。
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'そのため、bundle の script は OP と同じ origin から読み込み、インライン script と <base> 要素は使えません(base-uri 'none' が禁止します。パスは LoginMount/{uid} を基準に解決される形で書いてください)。インライン script、他の origin、<base> が必要な bundle は、op.SPAUI.ContentSecurityPolicy を指定します。空でない値は既定を置き換え、op.PageTemplate.ContentSecurityPolicy と同じ規則で検証されます。frame-ancestors を外した値や form-action を含む値のように、規則に反する値は op.New が失敗します。
フロントエンドのコード
import { useEffect, useState } from "react";
// FieldKind の値: "text", "password", "otp_code", "email", "hidden"。
const inputTypeFor = (kind) =>
({ password: "password", email: "email", hidden: "hidden" })[kind] ?? "text";
// 最終的な認可応答は、リダイレクトではなくエンベロープで届く。
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) {
// 本文を parse する前にステータスを確認する。JSON とは限らない
setFailed(true);
return;
}
const next = await r.json();
if (!deliver(next)) {
setPrompt(next); // 送信が拒否された直後は next.error が入っている
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 の値: "text", "password", "otp_code", "email", "hidden"。
const inputTypeFor = (kind) =>
({ password: "password", email: "email", hidden: "hidden" })[kind] ?? "text";
// 最終的な認可応答は、リダイレクトではなくエンベロープで届く。
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) {
// 本文を parse する前にステータスを確認する。JSON とは限らない
failed.value = true;
return;
}
const next = await r.json();
if (!deliver(next)) {
prompt.value = next; // 送信が拒否された直後は next.error が入っている
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>どちらのタブも同じループです。prompt を GET し、その inputs を描画し、{ state_ref, values } を POST して、応答が終端のエンベロープになるまで繰り返します。ここでは汎用の inputs だけを描画し、ラベルは label_text から(なければ label のキーで代用して)表示し、送信が拒否されたときは error を表示しています。実際のフロントエンドは prompt.type でも分岐させてください。同意、アカウント選択、captcha、パスキーの prompt は、内容を data に載せるためです。
通信路上の形は、op/interaction の定義そのままです。
- Prompt:
type、data、inputs、state_ref、csrf_token、error(送信が拒否された直後のみ)に加えて、ロケール情報のlocale、ui_locales_hint、locales_available(i18n / ロケール解決 を参照)を持ちます。組み込みの種別はauth.password、auth.totp、auth.email_otp.send、auth.email_otp.verify、auth.passkey、auth.recovery_code、auth.choose_factor、captcha、consent.scope、interaction.chooserです。独自の prompt には組織のプリフィックスを付けます。 FieldSpecとdata: メンバー名もすべて snake_case です(name、kind、label、label_text、required、max_len、min_len、pattern、そしてscopes、client、accounts、add_account_url)。labelは i18n キーで、label_textはそのキーをlocaleで解決した文言です。factor 選択の prompt(auth.choose_factor)では、data.optionsの各要素が同じ組を持ちます。頭字語は 1 語として扱います(allow_credentials、site_key、session_id、client_id、logo_url、tos_uri)。kindは上のコードにある文字列のいずれかです。バイト列のフィールド(challenge、credential のid)は標準の base64 です。- まずステータス:
200は prompt か終端のエンベロープを運びます。204は interaction がキャンセルされ、通知すべきredirect_uriがなかったことを表します。それ以外はエラーで、本文は通常{"error", "error_description"}ですが、text/plainのこともあります。r.json()を呼ぶ前にr.okを確認してください。各ステータスでの対応は、ステータスコードの表にあります。 - 終端のエンベロープ: 最終的な認可応答はリダイレクトにできません。
fetch()は cross-origin のリダイレクトを辿れず、自動送信の HTML ページも実行できないためです。そこで OP は 2 種類のエンベロープのどちらかを返し、SPA がそれをドキュメントレベルで届けます。
type | 形 | SPA がすること | 対応するレスポンスモード |
|---|---|---|---|
redirect | {"type":"redirect","location":"<url>"} | window.location.href = location | query、JARM の query.jwt / fragment.jwt |
form_post | {"type":"form_post","action":"<redirect_uri>","fields":{...}} | 隠しフォームを作って送信する | form_post、JARM の form_post.jwt |
form_post の分岐を省くと、フローが止まります。RP に応答が届きません。
同意のステップ
prompt.type === "consent.scope" のときは、scope を prompt.data.scopes(各要素は name、description、required)から、クライアントを prompt.data.client から読みます。prompt は、approved_scopes という text の入力を 1 つ宣言します。SPA は承認した scope 名をスペースで連結して、{ approved_scopes: "openid profile" } として送ります。一部だけの承認もできますが、必須の scope を外した送信は 4xx で拒否されます。リクエスト全体を拒否するときは、状態の URL に DELETE を送ります(キャンセルを参照)。カスタム同意 UI も参照してください。
なぜ X-CSRF-Token を送るのか
OP は HttpOnly の __Host-oidc_csrf cookie を設定し、同じ値を各 prompt の csrf_token にも入れます。script から cookie は読めないため、SPA が値を得る手段は prompt だけです。SPA の仕事は、POST のたびに prompt.csrf_token を X-CSRF-Token ヘッダにコピーすることです。OP はヘッダと cookie を照合します(double-submit パターン)。SPA がトークンを生成したり保存したりすることはありません。
拒否された送信を表示する
パスワード、TOTP コード、メールのコードを間違えても、リクエスト自体は失敗しません。OP は 200 で同じ factor の prompt をもう一度返し、その prompt が 1 回限りの通知を持ちます。
{
"type": "auth.password",
"inputs": [{ "name": "password", "kind": "password", "label": "login.password.label", "label_text": "パスワード", "required": true }],
"state_ref": "...",
"csrf_token": "...",
"locale": "ja",
"error": {
"code": "credentials_rejected",
"message": "入力内容が正しくありません。もう一度お試しください。"
}
}error.codeは閉じた集合から選ばれ、現時点の値はcredentials_rejectedだけです。アカウントが存在してもしなくても同じ値なので、通知から登録済みのユーザ名は分かりません。未知の code は、汎用の失敗として扱ってください。error.messageは、prompt のlocaleで解決した通知の文言です。OP をロケールリゾルバなしで構築した場合は含まれません。その場合はcodeをキーにして、自前の文言を表示してください。- 通知は保存されません。
GETで読み直すと step が再実行され、errorのない prompt が返ります。正しく答えたあとの次の prompt も同じです。通知は、複数画面にわたる factor の 2 画面目(メールのコードを間違えても確認画面にとどまります)や、先に captcha が割り込む prompt にも付きます。 - OP 自身の HTML ドライバも、同じ通知をフォームの上に
<p role="alert">で描画します。フォールバックの文言も同じです。
ロックアウトを引き起こした試行だけは異なります。それより前の誤入力は error 付きの 200 で返りますが、上限に達した試行は、prompt を伴わない素の {"error", "error_description"} のエンベロープで 400 が返ります。ロック中の factor への以降の試行も 400 です。エンベロープには「ロック」と書かれません。書くと、アカウントの存在を確認させてしまうためです。汎用の「サインインを続行できません」という画面を出し、アプリケーションから最初からやり直すよう案内してください。
ステータスコード
状態ルートはどのメソッドでも、次のいずれかで応答します。本文を読む前に、ステータスで分岐してください。
| ステータス | 意味 | SPA の対応 |
|---|---|---|
200 | 次の prompt(拒否の直後は error 付き)、または終端のエンベロープ | 描画する。エンベロープなら届ける |
204 | DELETE で interaction がキャンセルされ、通知すべき redirect_uri がない | 「キャンセルしました」の画面を出す |
400 | 本文を parse できない、または factor が続行できない(ロックを引き起こした試行、使い切った captcha、ロック中の factor) | 止める。汎用の失敗画面を出し、アプリケーションからやり直す |
403 | origin が許可されない、CSRF token が無いか誤り、または state_ref が拒否された | GET で prompt を取り直して新しい csrf_token と state_ref を得て、1 回だけ再送する。続くなら CORS と cookie と、下の Referrer-Policy の注意を確認する |
404 | interaction の cookie が無いか uid と一致しない、または interaction の期限切れ、または保存済みの interaction の状態を復号できない | 認可をやり直す。OP は「uid が存在しない」場合と「自分のものではない」場合を同じ応答にする |
409 | 手元の prompt のあとで interaction が変化した(別のタブで操作したなど) | GET で prompt を取り直す |
410 | interaction がすでに完了している | アプリケーションからやり直す |
キャンセル
DELETE には、POST と同じ X-CSRF-Token ヘッダと credentials が必要です。本文は不要です。ヘッダのないリクエストは 403 で拒否されます。
await fetch(stateURL, {
method: "DELETE",
headers: { "X-CSRF-Token": prompt.csrf_token ?? "", Accept: "application/json" },
credentials: "same-origin",
});リクエストに redirect_uri があれば、クライアント向けの access_denied を載せた終端のエンベロープが返り、なければ 204 が返ります。
Referrer-Policy とフォームの POST
既定の cors mode の fetch() は実際の Origin を送るので、上のコードは Referrer-Policy の影響を受けません。Referrer-Policy: no-referrer で配信されたページからの通常の HTML form の POST は異なります。ブラウザが Origin: null を送り、状態ルートは 403 を返します。ログアウトの確認 form も同じです。OP は、この組み合わせを検知すると、provider ごとに 1 回だけ WARN を出します。自前のミドルウェアと重ねるを参照してください。
エラーの描画
リダイレクト先を信頼できる前に失敗した場合(未登録の client_id、壊れたリクエストなど)の返し方は、ドライバによって変わります。JSON ドライバは常に RFC 6749 §5.2 のエンベロープ {"error": "...", "error_description": "...", "state": "..."} を返します。HTML ドライバも同じですが、リクエストが Accept: text/html を優先する場合は、同じ項目を 1 つの要素の data-* 属性として持つページを返します。
<div id="op-error"
data-code="invalid_request_uri"
data-description="request_uri has expired"
data-state="abc">
<h1>Authorization error</h1>
...
</div>このページには script、style、画像が含まれず、攻撃者が制御できる state を含むすべての値が HTML エスケープされています。HTML ドライバの既定のポリシー default-src 'none'; style-src 'none'; frame-ancestors 'none'; base-uri 'none' のもとで配信されます。
CORS と cookie
SPA の入口を OP と同じ origin から配信する場合(上のコードの構成)は、CORS の設定は不要で、credentials: "same-origin" が正しい指定です。
SPA が別の origin にあるときは、その origin を op.WithCORSOrigins に追加します。ceremony(状態ルートと /end_session の確認)が受け付けるのは、issuer の origin と op.WithCORSOrigins に渡した origin だけで、クライアントの redirect_uri の origin は数えません。さらに、その origin は issuer と same-site でなければなりません。本当に cross-site の origin は preflight を通過することはありますが、ブラウザが __Host- の ceremony cookie を送らないため(__Host-oidc_interaction は SameSite=Lax、__Host-oidc_csrf と __Host-oidc_logout_csrf は SameSite=Strict)、状態ルートは 404 を返します。ログイン UI は、op.example.com と並べた login.example.com のような issuer の兄弟ホストに置き、OP を絶対 URL で呼び、credentials: "include" を指定してください。API 側の話は SPA 向け CORS にあります。
SPA 全体を作らずに同意画面を変える
同意画面の文言だけを変えるなら、op.WithLocaleBundle でキーを重ねます。同梱の HTML ドライバがそれを使い、CSP と CSRF の扱いもそのまま残ります。トランスポートは OP に任せたままマークアップを自前にしたいなら、op.WithUI に op.HTMLUI{Consent: ...} を渡します。有効なモードは 1 つなので、op.SPAUI とは択一です。カスタム同意 UI と i18n / ロケール解決 を参照してください。