Skip to content

ユースケース — 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 です。

このページで触れる仕様
用語の補足
  • 対話画面(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 と通信します。

メソッドパス役割
GETLoginMount/{uid}SPA の入口(index.html)。StaticDir を設定すると OP が配信し、未設定なら自前のルートで配信
GETLoginMount/assets/{path...}バンドルのアセット。StaticDir を設定したときに配信
GETLoginMount/state/{uid}現在の prompt を JSON で返す
POSTLoginMount/state/{uid}現在の prompt に対するユーザの送信を受ける
DELETELoginMount/state/{uid}進行中の interaction をキャンセルする

op.SPAUI のうちルーティングに影響するフィールドは LoginMount だけです。同意は同じ状態ルートを通り、ログアウトの確認は通常の /end_session のフローに残り、サーバが描画します。ログアウト画面の見た目を変えるには op.WithLogoutUI を使います。op.SPAUI を含むどのモードでも有効です(ログアウト画面をブランド化するを参照)。

SPA が各画面を JSON で取りにいく
authorize エンドポイントは、ブラウザをログイン用マウントの SPA 入口へリダイレクトします。以降 SPA は各 interaction の状態を OP から JSON で取得し、回答を送り返し、最後に redirect エンベロープに従って RP へ戻ります。ユーザのブラウザリダイレクトを辿るSPA バンドル自前のコード · 自前の UIOPgo-oidc-providerGET /auth?…302 → /login/{uid}uid を作成 · cookie を設定GET /login/{uid}200 index.htmlGET /login/state/{uid}Accept: application/json200 { type:"auth.password" }inputs · state_ref · …フォームを自前で描画するPOST /login/state/{uid}{ state_ref, values }200 { type:"consent.scope" }または redirect 応答POST /login/state/{uid}approved_scopes200 { type:"redirect", … }location=https://rp/cb?code=…ブラウザを location へ遷移させる
SPA の入口を返したあと、OP が返すのは JSON だけです。OP は各画面が何を尋ねるべきかを記述するだけで、見た目も描画に使うフレームワークも組み込み側にあります。

コード ​

SPA をマウントする ​

go
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} へリダイレクトします。その入口は自前で配信してください。

go
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 が付きます。アセットの応答には付きません。

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'

そのため、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 が失敗します。

フロントエンドのコード ​

jsx
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>
  );
}
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 の値: "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 = locationquery、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 回限りの通知を持ちます。

json
{
  "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 付き)、または終端のエンベロープ描画する。エンベロープなら届ける
204DELETE で interaction がキャンセルされ、通知すべき redirect_uri がない「キャンセルしました」の画面を出す
400本文を parse できない、または factor が続行できない(ロックを引き起こした試行、使い切った captcha、ロック中の factor)止める。汎用の失敗画面を出し、アプリケーションからやり直す
403origin が許可されない、CSRF token が無いか誤り、または state_ref が拒否されたGET で prompt を取り直して新しい csrf_token と state_ref を得て、1 回だけ再送する。続くなら CORS と cookie と、下の Referrer-Policy の注意を確認する
404interaction の cookie が無いか uid と一致しない、または interaction の期限切れ、または保存済みの interaction の状態を復号できない認可をやり直す。OP は「uid が存在しない」場合と「自分のものではない」場合を同じ応答にする
409手元の prompt のあとで interaction が変化した(別のタブで操作したなど)GET で prompt を取り直す
410interaction がすでに完了しているアプリケーションからやり直す

キャンセル ​

DELETE には、POST と同じ X-CSRF-Token ヘッダと credentials が必要です。本文は不要です。ヘッダのないリクエストは 403 で拒否されます。

js
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-* 属性として持つページを返します。

html
<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' のもとで配信されます。

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 / ロケール解決 を参照してください。