Skip to content

使い方 — 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 側で描画できるようにします。独自ドライバを実装すれば、任意のフロントエンドと接続できます。

このページで触れる仕様
用語の補足
  • 対話画面レイヤ — 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-loginop.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...} が静的アセットです。

MethodPath役割
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 上で完結します。

JSON ドライバ構成の SPA interaction のシーケンス: ブラウザが SPA の入口を読み込み、各画面状態を OP から JSON で取得して回答を送り返し、終端の redirect 応答に従う。ユーザのブラウザSPA バンドル自前のコード・自前のルーターOP1GET /authorize?...2interaction uid + cookie 生成3302 → /login/{uid}(自前のルート)4GET /login/{uid}5200 index.html (SPA 入口)6GET /interaction/{uid} · Accept: application/json7200 { type:"login", inputs, state_ref, csrf_token }8SPA がログインフォームを描画9POST /interaction/{uid} · { state_ref, values }10200 { type:"consent.scope", … } または { type:"redirect", … }11POST /interaction/{uid}(consent の値)12200 { type:"redirect", location:"/auth?…&code=…" }13window.location.href = location

状態遷移は OP が所有します。SPA は次の画面状態を取得し、ユーザの回答を送り返すだけです。OP が次に何を出すかを決めます。

コード

JSON ドライバへの差し替え(最小変更)

go
import "github.com/libraz/go-oidc-provider/op/interaction"

provider, err := op.New(
  /* 必須オプション */
  op.WithInteractionDriver(interaction.JSONDriver{}),
)

これですべての interaction ページが JSON を返すようになり、SPA は画面状態を取得して回答を POST で送り返します。

SPA の組み立て(フレームワーク非依存)

go
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.SPAUILoginMount / ConsentMount / LogoutMount / StaticDir を取り、SPA の入口・静的アセット一式・画面状態 JSON を 1 オプションで公開します。JSON の状態取得エンドポイントは LoginMount/state/{uid} です。自前ルータで SPA を配信し、/interaction/{uid} から fetch したい場合だけ interaction.JSONDriver を直接使ってください。

フロントエンドスニペット

jsx
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>
  );
}
vue
<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 をそのまま反映します。

  • Prompttype / 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-loginweb/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 で読めます:

html
<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 で配信される場合は明示許可:

go
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 もそのまま維持できます。詳細は カスタム同意 UIi18n / ロケール解決 を参照してください。

op.WithConsentUI は、interaction transport 全体を SPA に寄せずに同意画面 template だけを差し替えるサーバ描画の経路です。markup を完全にクライアント側で持ちたい場合は、上記の JSON ドライバ経路を使います。