Skip to content

ユースケース — i18n / ロケール解決 ​

ui_locales とは ​

OIDC Core 1.0 §3.1.2.1 は、/auth リクエストに ui_locales パラメータを定めています。RP がスペース区切りの BCP 47 言語タグ(RFC 5646。例: ja en-US)を送ると、OP は、並んだ言語のうち翻訳を持っている最初のものでログインと同意の UI を描画します。

RP が ui_locales を送らない場合は、ユーザが以前に選んだロケール(保存された設定、または言語ピッカが設定した cookie)、ブラウザの Accept-Language ヘッダ(RFC 9110)の順に調べ、最後に登録済みの既定ロケールを使います。

このページで触れる仕様
  • OpenID Connect Core 1.0 — §3.1.2.1(ui_locales リクエストパラメータ)
  • RFC 5646 — Tags for Identifying Languages(BCP 47)
  • RFC 9110 — HTTP Semantics, §12.5.4(Accept-Language)
用語の補足
  • BCP 47: IETF の言語タグの規格(RFC 5646)です。en、en-US、ja、zh-Hant-TW のように、言語、任意のスクリプト、任意の地域で表します。ui_locales は、優先順に並べたタグをスペースで区切って渡します。
  • Accept-Language: ブラウザがユーザの希望言語を q= の重み付きで伝える HTTP リクエストヘッダです(ja-JP, ja;q=0.9, en;q=0.5)。RP が ui_locales で言語を固定しないとき、OP はこれを使います。
  • message bundle: 文字列のキーと値だけを持つ flat なマップです。値には単純な {name} プレースホルダを使えます。複数形や性別の整形は、UI 側の責務です。

優先順序 ​

OP は自分の画面の言語を、5 層の優先順序で決めます。

5 層を上から辿り、最初に一致したものを採用する
ロケールは 5 つの層を順に辿って決まります。ユーザ単位の保存済み設定から、起動時に登録した既定ロケールまでです。最初に一致した層が採用され、最後の層は必ず一致します。最初に一致したものを採用 — 上から順に辿る1PreferredLocaleStoreこのユーザが保存した設定 — 他に優先する2ui_localesこのリクエストで RP が要求した値3__Host-oidc_localeSetLocaleCookie で保存した、明示的な選択4Accept-Languageブラウザ自身の一覧。q 値も考慮する5WithDefaultLocale起動時に登録 — ここは必ず一致する
5 層目があるため「ロケールが見つからない」経路を扱う必要がありません。チェーンは必ず終端するので、他の 4 層を見る前の時点で、どの画面にも言語が決まっています。

登録済みのロケールに最初に一致したシグナルが採用されます。チェーンは既定ロケールで終わるので、resolver は必ず登録済みのロケールを返します。

明示的な locale 選択を保存する

OP は __Host-oidc_locale cookie を読みますが、自分では書き込みません。ユーザの選択を OP は目にしないためです。言語ピッカは、自前で持つ UI の一部です。ピッカが選択を受け取ったら、Provider.SetLocaleCookie(w, locale) を呼びます。大文字小文字と区切り文字を正規化し、登録済みタグとの完全一致か、言語部分だけの一致(ja-JP は登録済みの ja に一致)を受け付けます。一致するものがなければ、cookie を書き込まずに op.ErrLocaleNotRegistered を返します。Provider.ClearLocaleCookie(w) は cookie を削除するので、resolver は ui_locales、Accept-Language、既定ロケールへ進みます。cookie は Secure、HttpOnly、SameSite=Lax で、意図的にログインセッションより長く残ります。書き込むのは明示的な選択だけにしてください。チェーンが解決した結果をそのまま書き戻すと、ある RP の ui_locales が、他のすべての RP でのユーザの固定の既定になってしまいます。

ソース: examples/15-i18n-locale — :8080 の listener を起動する前に、in-process の httptest サーバで優先順序の各行を検証する self-verify probe を実行します。ブラウザを使わずとも example の出力からリゾルバの挙動が確認できます。

実装 ​

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

frBundle, _ := op.LocaleBundleFromMap("fr", map[string]string{
  "login.title":            "Connexion",
  "login.identifier.label": "Identifiant",
  "login.password.label":   "Mot de passe",
  "login.button.submit":    "Se connecter",
  /* ... */
})

brandJa, _ := op.LocaleBundleFromMap("ja", map[string]string{
  "consent.title": "{client_name} へのアクセス許可", // 1 キーだけ上書き
})

op.New(
  /* 必須オプション */
  op.WithDefaultLocale("fr"),
  op.WithLocaleBundle(frBundle),  // 新規ロケールを登録
  op.WithLocaleBundle(brandJa),   // seed の ja bundle に 1 キーだけ重ねる
)

ライブラリは 英語(en) と 日本語(ja) の seed bundle を同梱しています。op.WithLocaleBundle は置き換えではなく、重ね書きです。呼び出すたびに、seed と同じロケールの以前の bundle の上にキー単位で重ねられます。ロケールタグは、小文字でハイフン区切りの BCP 47 形式に正規化されます(pt_BR は pt-br になります)。変えたいキーだけを渡せば、それ以外のキーは下の層の値が使われます。ロケールの各キーは単一値です。2 回の WithLocaleBundle で同じロケールの別々のキーを渡すことはできますが、同じキーを 2 回渡すと op.New が失敗します。

fr や de のような新しいロケールは、最初の呼び出しで登録され、以降の呼び出しで重ねられます。そのロケールが一度も供給しないキーは、既定ロケール(op.WithDefaultLocale)にフォールバックするので、部分的な翻訳を出荷しても安全です。op.New は、seed でも WithLocaleBundle でも登録されていないロケールを既定にすると失敗し、登録していないロケールを WithDiscoveryMetadata(...UILocalesSupported...) で広告しても失敗します。

op.Locale は文字列型なので、内部パッケージを import せずにロケールの定数を宣言できます。seed のロケールは op.LocaleEnglish("en")と op.LocaleJapanese("ja")です。LocaleBundleFromMap は、構築時にマップを検証します。

ユーザ単位の優先ロケールストア ​

ログイン済みユーザの保存ロケールは、他のすべてのシグナルより優先されます。op.PreferredLocaleStore を実装して op.WithPreferredLocaleStore で渡してください:

go
type pgLocaleStore struct{ /* db ハンドル、キャッシュ等 */ }

func (s *pgLocaleStore) PreferredLocale(ctx context.Context, sub string) (op.Locale, error) {
  loc, err := s.fetch(ctx, sub)
  if err != nil {
    return "", err  // err nil 以外 は「設定なし」として扱われ、次の層へ進む
  }
  return op.Locale(loc), nil  // 空 Locale も同様に次の層へフォールスルー
}

op.New(
  /* 必須オプション */
  op.WithPreferredLocaleStore(&pgLocaleStore{...}),
  op.WithLocaleBundle(frBundle),
  op.WithDefaultLocale(op.LocaleEnglish),
)

PreferredLocale は /auth のたびに呼ばれるので、ルックアップは軽くなければなりません。バックエンドがリモートなら、ローカルにキャッシュしてください。呼び出しが遅いと、すべてのログイン画面が遅れます。エラーを返しても空の Locale を返しても「設定なし」を意味し、チェーンは次の層に進みます。

解決済みロケールの読み取り ​

OP は prompt ごとにこのチェーンを実行し、結果を 2 つの場所で公開します。

SPA またはカスタム Driver から ​

各 prompt(GET LoginMount/state/{uid}、素の JSON ドライバなら /interaction/{uid})には、Driver.Render の前に設定される 3 つのロケールフィールドが含まれます。

フィールド用途
locale解決済みの BCP 47 タグ。<html lang> に乗せて翻訳 bundle の選択に使う。
ui_locales_hintRP の ui_locales 生リスト(空白区切り)。リゾルバを上書きしたいときだけ参照する。多くの SPA は無視して locale を信用する。
locales_available登録済みのロケール(discovery の ui_locales_supported と同じ)。言語ピッカの構築に使う。OP は選択を受け取らないので、自前のエンドポイントに送り、そこで Provider.SetLocaleCookie を呼ぶ。
js
function applyLocale(prompt) {
  if (prompt.locale) {
    document.documentElement.lang = prompt.locale;
  }
  // bundles[prompt.locale] で SPA 自身のメッセージカタログを引く。
}

次の 2 つのフィールドは、locale で解決済みの文言として届くので、SPA が翻訳表を持つ必要はありません。

フィールド用途
inputs の各要素、および auth.choose_factor prompt の data.options の各要素にある label_textlabel のキーを locale で解決した文言。これを表示し、無いときだけ label(i18n キー)にフォールバックする。
error.message拒否通知の文言。auth.error.<code> のキーから解決される。error.code は安定した識別子(credentials_rejected)なので、code で分岐して message を表示する。

OP をロケールリゾルバなしで構築した場合は、label_text と error.message は出力されません。カタログにエントリのない factor の種別では、label_text も出力されません。prompt の見出しは解決されないため、type から見出しへの対応は引き続き SPA が持ちます。拒否通知そのものについては、SPA / 対話画面のカスタマイズを参照してください。

examples/10-react-login のバンドル(examples/internal/webui を共有)は、prompt の JSON の契約を示しています。上のロケール処理は、自分の SPA のメッセージカタログとドキュメントの入口に実装してください。

Go 側では同じ値が prompt.Locale なので、カスタム interaction.Driver から Content-Language などのヘッダにコピーしたり、描画を分岐したりできます。examples/16-custom-interaction は JSONDriver を包んでこのパターンを示します。

サーバ側のコード(メール、管理画面など)から ​

Provider.LocaleResolver() は *op.LocaleResolver を返します。/auth の外にあり、同じロケールを使いたいが prompt を受け取らない場所(トランザクションメール、管理画面など)で使います。

go
loc := provider.LocaleResolver().Resolve(ctx, op.ResolveRequest{
  Subject:        userSub,                          // PreferredLocaleStore のルックアップに使われる
  AcceptLanguage: r.Header.Get("Accept-Language"),
})

renderEmail(loc, body)

LocaleResolver には、ほかに Default()(フォールバックのロケール)と Available()(登録済みのロケール。seed が先、WithLocaleBundle の追加分が後)があります。Provider が一度だけ構築するもので、並行使用に安全です。

ロケールが決まったら、Message で統合済みのカタログ(組み込みの文字列に WithLocaleBundle の追加分を重ねたもの)を引けます。自前で描画するページが、文言のコピーを別に持たずに OP の文言を再利用できます。

go
title, ok := provider.LocaleResolver().Message(loc, "login.title", nil)
if !ok {
    title = "Sign in" // ロケールにも既定ロケールにも定義が無いキー
}

検索は、完全一致のロケール、その言語部分(ja-JP なら ja)、既定ロケール、組み込みの英語カタログの順に進み、どれにもキーがなければ ok が false になります。data の値は {name} プレースホルダを置換し、未知のプレースホルダはそのまま残ります。

戻り値は escape されていない

Message が返すのはプレーンテキストで、メール本文、JSON のフィールド、ログ行にはこの形が必要です。HTML テンプレートに載せるときは、呼び出し側で文脈に応じたエスケープを行ってください。同梱の interaction.HTMLDriver は自動でエスケープし、同じ resolver を参照するので、WithLocaleBundle の追加分はタイトル、ラベル、ボタンに反映されます。どのカタログにも定義のないキーは、組み込みの英語にフォールバックします。

確認 ​

sh
# discovery が登録済みロケールを公開
curl -s http://localhost:8080/.well-known/openid-configuration | jq .ui_locales_supported
# ["en", "ja", "fr"]

優先順序を手動で確認:

リクエスト描画されるロケール
シグナルなしfr(登録済み既定)
Accept-Language: esfr(es 未登録 → フォールバック)
Accept-Language: ja-JP, ja;q=0.9ja
Cookie __Host-oidc_locale=jaja
?ui_locales=ja en(RFC 5646 のリスト)最初に一致した ja
?ui_locales=ja + cookie が frja(パラメータが cookie より優先)
PreferredLocale が ja を返却 + ?ui_locales=frja(store がパラメータより優先)

翻訳対象 ​

同梱の HTML ドライバは、次のキーを引きます。seed の en bundle(ライブラリのリポジトリの internal/i18n/embedded/en.json)が全キーの一覧です。どれも WithLocaleBundle で上書きできます。

表示面bundle のキー
ログインフォームlogin.title、login.identifier.label、login.password.label、login.button.submit、login.username_hint
第 2 要素と captchaauth.totp、auth.email_otp.send、auth.email_otp.verify、auth.recovery_code、auth.passkey、captcha、各フィールドのラベル(auth.totp.code など)
同意consent.title、consent.subtitle、consent.button.allow、consent.scope_required
factor の選択auth.factor.password、auth.factor.passkey、auth.factor.totp、auth.factor.email_otp、auth.factor.recovery_code(各 option の label_text)
拒否された送信auth.error.credentials_rejected(prompt の error.message と、HTML ドライバが描画する alert)
アカウント選択interaction.chooser、chooser.add_account、chooser.no_accounts
ログアウトlogout.confirm.title、logout.confirm.body、logout.confirm.body_current、logout.confirm.button、logout.title、logout.body

seed カタログの外にあるものが 3 つあります。

  • 同意画面の scope の説明は、op.Scope.Description の既定言語の文字列です。Scope.I18n は保持されますが、同意の prompt はこれを読みません。scope の説明を翻訳するなら、自前のテンプレートか SPA で、scope 名をキーにして引いてください。
  • OP のエラーページ(リダイレクト先を信頼できる前に失敗したリクエストの #op-error ページ)は英語です。
  • 自前の認証器の prompt は、自前のキーを使います。HTML ドライバは、ページタイトルを Prompt.Type と同じ名前のキー(たとえば myorg.sms.collect_phone)から、各フィールドのラベルを FieldSpec.Label から引きます。これらのキーは WithLocaleBundle で追加してください。どの bundle にも定義のないキーは、そのまま表示されます。カスタム認証器 を参照してください。

seed に新しいキーが minor リリースで加わることがあります。リリースノートに載ります。

カスタム UI が引き継ぐ場合 ​

JSON ドライバまたは op.SPAUI を使うと、文字列は SPA が持ちます。OP は引き続きロケールを解決して各 prompt に locale として設定するので、SPA が優先順序を再実行する必要はありません。ただし、seed の en / ja bundle が使われるのは、同梱の HTML 画面だけです。

UI オプションとロケール

  • op.SPAUI と、op.DriverUI に渡したカスタムドライバは、各 prompt で解決済みの locale を受け取ります。
  • op.HTMLUI の Login テンプレートには Locale と、解決済みの各フィールドの LabelText が渡されます(interaction.LoginTemplateData)。1 つのテンプレートでロケールの解決結果に従えます。カスタムログイン UI を参照してください。
  • op.HTMLUI の Consent と Chooser のテンプレートには、ロケールもメッセージ bundle も渡されません(ConsentTemplateData と ChooserTemplateData にそのようなフィールドがありません)。そのため、どちらも単一の言語で描画されます。複数の言語が要るなら、Prompt.Locale を読めるドライバか SPA を使ってください。カスタム同意 UI を参照してください。
  • op.WithLogoutUI のテンプレートには、Locale と、解決済みの Title、Body、Button(上の logout.* キー)が渡されます。ブランド化したログアウト画面も、独自の bundle を持たずにロケールの優先順序に従います。ログアウト画面をブランド化するを参照してください。

オプションの全体は Options 索引 §UI にあります。

続きはこちら ​