使い方 — SPA / 対話画面のカスタマイズ
op.WithSPAUI と低レベル JSON ドライバ
op.WithSPAUI は、SPA の入口、静的アセット、画面状態 JSON を OP 側でまとめて公開します。自前のルーターで SPA を配信し、画面状態 JSON だけ OP に任せたい場合は op.WithInteractionDriver(interaction.JSONDriver{}) を直接使います。
「対話画面」レイヤとは何か
RP の /authorize リダイレクトと、OP からのコード付きリダイレクトバックの間で、OP は interaction(ログイン / 任意の MFA ステップアップ / 任意の同意画面 / 任意のアカウント選択)を実行します。OIDC Core 1.0 §3.1 は通信路上のデータ(要求パラメータと最終応答)を規定しますが、この中間ページをどう描画するか には踏み込みません。各 OP がそれぞれ UX を選びます。
本ライブラリでは、この中間ページ群を差し替え可能な interaction.Driver として扱います。既定のドライバはサーバ側で HTML を描画します。JSON ドライバは同じ画面状態を JSON で返し、SPA 側で描画できるようにします。独自ドライバを実装すれば、任意のフロントエンドと接続できます。
このページで触れる仕様
- OpenID Connect Core 1.0 — §3.1(authorization endpoint)、§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 応答)
用語の補足
- 対話画面レイヤ — RP の
/authorizeリダイレクトと OP からのコード付きリダイレクトバックの間に挟まる一連の処理(ログイン / 任意の MFA ステップアップ / 任意の同意 / 任意のアカウント選択)。通信路上のパラメータは仕様で定まっていますが、この中間ページをどう描画するか は定まっていません。ここが差し替え口です。 - JSON ドライバ — 画面を HTML ではなく JSON で返す差し替え口です。状態遷移は OP 側に残ります。SPA は
{ type: "login" | "consent.scope" | ... }を取得して回答を送り返し、OP が次を決めます。 - CSP(Content Security Policy) — ページが読み込んでよいリソース種別をブラウザに伝えるレスポンスヘッダ(
Content-Security-Policy: default-src 'none'; ...)。OP のエラーページは<script>、inline イベントハンドラ、任意 URL スキームを禁じる厳格なポリシーで描画されるので、悪意あるerror_descriptionが XSS に化けることはありません。
ソース:
examples/16-custom-interaction— JSON driver への最小差し替え。examples/10-react-login—op.WithSPAUIで OP 側に SPA を公開する構成。同梱バンドルはビルド手順なしで動かすための素の HTML/CSS/JS ですが、接続点はフレームワーク非依存で、React / Vue / Svelte / Angular いずれも同じ形で使えます。examples/17-spa-composite-store— 本番に近い組み合わせ。SPA interaction、永続状態用 MySQL、session / interaction / consumed JTI 用 Redis をまとめて動かします。
アーキテクチャ
低レベル JSON ドライバでは、OP は interaction の状態機械を /interaction/{uid} に出し、各画面を JSON で返します。SPA の入口や静的アセットは、自前のルーターで好きなパスから配信します。op.WithSPAUI では、OP が SPA 向けの一式を公開します。LoginMount/{uid} が SPA の入口、LoginMount/state/{uid} が画面状態 JSON、StaticDir 設定時は LoginMount/assets/{path...} が静的アセットです。
| Method | Path | 役割 |
|---|---|---|
GET | /interaction/{uid}(JSON ドライバ) | 現在の画面状態を JSON で返す |
POST | /interaction/{uid}(JSON ドライバ) | ユーザのフォーム送信を受ける |
DELETE | /interaction/{uid}(JSON ドライバ) | 進行中の interaction をキャンセル |
GET | 自前のルート | SPA の入口(自前バンドルの index.html) |
GET | 自前のルート | 静的アセット(自前バンドル) |
op.WithSPAUI(op.SPAUI{LoginMount: "/login", StaticDir: "./web/static"}) の場合、同じ状態取得 API は /login/state/{uid} に移動し、/login/{uid} が SPA の入口を返します。
低レベル JSON ドライバ構成では、/authorize は /interaction/{uid} へリダイレクトします。/authorize のリダイレクトから RP のコールバックに戻る code 付きリダイレクトまでの間は、すべて SPA 上で完結します。
状態遷移は OP が所有します。SPA は次の画面状態を取得し、ユーザの回答を送り返すだけです。OP が次に何を出すかを決めます。
コード
JSON ドライバへの差し替え(最小変更)
import "github.com/libraz/go-oidc-provider/op/interaction"
provider, err := op.New(
/* 必須オプション */
op.WithInteractionDriver(interaction.JSONDriver{}),
)これですべての interaction ページが JSON を返すようになり、SPA は画面状態を取得して回答を POST で送り返します。
SPA の組み立て(フレームワーク非依存)
import (
"net/http"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/interaction"
)
provider, err := op.New(
/* 必須オプション */
op.WithInteractionDriver(interaction.JSONDriver{}),
op.WithCORSOrigins("https://app.example.com"),
)
mux := http.NewServeMux()
// SPA の入口 + 静的アセットは自前のルートに配置。
mux.Handle("GET /login/", http.StripPrefix("/login/", http.FileServer(http.Dir("./web/dist"))))
// `/interaction/{uid}` を含むプロトコル面は OP が所有。
mux.Handle("/", provider)OP が /interaction/{uid} に画面状態 JSON を返し、/login/... に置いた SPA バンドルが fetch でそれを取得します。フレームワークは構成に合うものを選んでください。Go 側の書き方はどれでも同じです。
SPA の入口へのリダイレクト先
低レベル JSON ドライバ構成では、/authorize は /interaction/{uid} へリダイレクトします。先に SPA を読み込ませてから、SPA が Accept: application/json で /interaction/{uid} を呼ぶ構成にしたい場合は、SPA が想定するパス(例: /login/{uid})で SPA の入口を配信し、そこから /interaction/{uid} を直接 fetch させる構成にしてください。op.WithSPAUI では OP がこのリダイレクトを行い、状態取得エンドポイントは LoginMount/state/{uid} になります。
op.WithSPAUI
op.SPAUI は LoginMount / ConsentMount / LogoutMount / StaticDir を取り、SPA の入口・静的アセット一式・画面状態 JSON を 1 オプションで公開します。JSON の状態取得エンドポイントは LoginMount/state/{uid} です。自前ルータで SPA を配信し、/interaction/{uid} から fetch したい場合だけ interaction.JSONDriver を直接使ってください。
フロントエンドスニペット
import { useEffect, useState } from "react";
// op/interaction の FieldKind iota:
// 0=text, 1=password, 2=otp, 3=email, 4=hidden。
const inputTypeFor = (kind) =>
({ 1: "password", 3: "email", 4: "hidden" })[kind] ?? "text";
export function Interaction({ uid }) {
const stateURL = `/interaction/${uid}`;
const [prompt, setPrompt] = useState(null);
const [values, setValues] = useState({});
useEffect(() => {
fetch(stateURL, {
headers: { Accept: "application/json" },
credentials: "same-origin",
})
.then((r) => r.json())
.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 }),
});
const next = await r.json();
if (next.type === "redirect" && next.location) {
window.location.href = next.location;
} else {
setPrompt(next);
setValues({});
}
}
if (!prompt) return null;
return (
<form onSubmit={onSubmit}>
{prompt.inputs?.map((f) => (
<label key={f.Name}>
<span>{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 = `/interaction/${props.uid}`;
const prompt = ref(null);
const values = reactive({});
// op/interaction の FieldKind iota:
// 0=text, 1=password, 2=otp, 3=email, 4=hidden。
const inputTypeFor = (kind) =>
({ 1: "password", 3: "email", 4: "hidden" })[kind] ?? "text";
onMounted(async () => {
const r = await fetch(stateURL, {
headers: { Accept: "application/json" },
credentials: "same-origin",
});
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,
}),
});
const next = await r.json();
if (next.type === "redirect" && next.location) {
window.location.href = next.location;
} else {
prompt.value = next;
for (const k of Object.keys(values)) delete values[k];
}
}
</script>
<template>
<form v-if="prompt" @submit.prevent="onSubmit">
<label v-for="f in prompt.inputs" :key="f.Name">
<span>{{ 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>どちらのタブも流れは同じです。/interaction/{uid} から画面状態を GET し、宣言された inputs を描画し、{state_ref, values} を POST で送り返します。OP は次の画面状態か、終端の {type: "redirect", location: "..."} 応答を返し、SPA は window.location.href でそこへ遷移します。通信路上の形は op/interaction をそのまま反映します。
Prompt—type/data/inputs/state_ref/csrf_tokenに加えてロケール情報(locale/ui_locales_hint/locales_available— 詳細は i18n / ロケール解決)。すべて lower_snake_case の JSON タグ付き。FieldSpec— JSON タグが無いため Go の field 名がそのまま出力されます(Name/Kind/Label/Required/MaxLen/MinLen/Pattern)。Kindは上記の整数 enum。- 終端 redirect 応答 —
{"type":"redirect","location":"<URL>"}。OP 側で orchestrator の終端 302 をこの形に書き換えて返します(クロスオリジンfetchは RP コールバックの redirect を辿れないため、SPA がページ全体を遷移させられるように)。
通信路上の契約はフレームワーク間で同一です。違うのは描画方法だけです。
同意ステップ
prompt.type === "consent.scope" のときは inputs が空で、scope カタログは prompt.data.scopes に入ります。SPA はそのリストを描画して(s.required のものはトグル不可で表示)、{ approved_scopes: "openid profile" }(空白区切りのサブセット)として送信します。prompt.type 分岐の実装例は examples/10-react-login の web/static/assets/main.js を参照。
X-CSRF-Token を送る理由
OP がセッション開始時に __Host-oidc_csrf cookie を発行し、各プロンプト応答にその cookie 値を csrf_token として echo します。SPA の責務は、prompt.csrf_token を読んで送信時の X-CSRF-Token ヘッダーに乗せるだけ — OP がヘッダー値と cookie 値を照合します(double-submit cookie パターン)。SPA は token を生成・検証・保存しません。cookie は HttpOnly のままで構いません。
SPA-safe エラー描画
OP のエラーページは data-* 属性付きの安定アンカーを出力するので、SPA host は 1 回の document.querySelector で読めます:
<div id="op-error"
data-code="invalid_request_uri"
data-description="request_uri has expired"
data-state="abc">
<h1>Authorization error</h1>
...
</div>CSP-safe な構造
エラーページは default-src 'none'; style-src 'unsafe-inline' で描画されます。<script> 無し、inline イベントハンドラ無し、inline 画像無し、javascript: URL 無し。error_description / state に攻撃的な値が乗っていても、反映前に HTML エスケープされます。
OP は Accept ヘッダで形式をネゴシエーションします。
Accept: text/html(ブラウザナビゲーション) →data-*付き HTML ページ。Accept: application/json(XHR / fetch) → RFC 6749 §5.2 の JSON 応答。- ヘッダ未指定または
*/*→ JSON 応答(XHR / curl 向けの安全な既定)。
これにより、SPA の fetch() 呼び出しには引き続き JSON が返り、URL を直接踏んでしまったユーザには、SPA がロードした時点で拾える機械可読属性付きのエラーページが届きます。
CORS
SPA が OP と異なる origin で配信される場合は明示許可:
op.WithCORSOrigins(
"https://app.example.com",
"https://staging-app.example.com",
)ライブラリは登録済み redirect_uri の origin を RP ごとの許可リストへ自動で追加します(static クライアント設定なら CORS 設定の重複不要)。詳細は SPA 向け CORS。
フル SPA 化せず、文言だけ差し替えたい
同意画面の文言だけを差し替えたい(翻訳コピー、ブランドトーン)場合は、op.WithLocale で seed bundle にキー単位で重ねるのが最短です。同梱 HTML ドライバはそれをそのまま描画するので、CSP / CSRF scheme もそのまま維持できます。詳細は カスタム同意 UI と i18n / ロケール解決 を参照してください。
op.WithConsentUI は、interaction transport 全体を SPA に寄せずに同意画面 template だけを差し替えるサーバ描画の経路です。markup を完全にクライアント側で持ちたい場合は、上記の JSON ドライバ経路を使います。