Skip to content

Use case — i18n / locale negotiation

What is ui_locales?

OIDC Core 1.0 §3.1.2.1 defines a ui_locales parameter on the /authorize request. The RP sends a space-separated list of BCP 47 language tags (RFC 5646 — e.g. ja en-US) and the OP renders the login / consent UI in the highest-priority language it actually has translations for.

If the RP does not send ui_locales, you can still pick up the user's preference from a locale cookie (set by the OP itself) or the Accept-Language HTTP header (RFC 9110), falling back to a registered default locale.

Specs referenced on this page
Vocabulary refresher
  • BCP 47 — The IETF language-tag scheme (RFC 5646). Tags look like en, en-US, ja, zh-Hant-TW — language, optional script, optional region. The OIDC ui_locales parameter is a space-separated list of BCP 47 tags in priority order.
  • Accept-Language — A standard HTTP request header where the browser publishes the user's preferred languages with q= quality factors (ja-JP, ja;q=0.9, en;q=0.5). When the RP does not pin a language with ui_locales, the OP can fall back to this signal.
  • Message bundle — A flat JSON object of string keys. Bundle values support simple {name} placeholders; plural / gender formatting stays in the embedder's UI layer for v0.x.

Resolution chain

The OP picks the language for built-in consent / login screens from a five-layer priority chain:

PreferredLocaleStore (per-user override)
  → ui_locales request parameter
    → __Host-oidc_locale cookie
      → Accept-Language header
        → registered default locale

The first signal that resolves to a registered locale wins. The chain always terminates at the configured default, so the resolver never returns an empty value when at least one bundle is wired.

Cookie write endpoint

The OP reads __Host-oidc_locale at /authorize but does not yet ship a user-facing write endpoint for setting it. Embedders that want to remember the user's pick today either set the cookie from their own UI (over the same Domain / Secure / HttpOnly=false constraints __Host- requires) or wire WithPreferredLocaleStore (see below). A first-party write surface is on the roadmap.

Source: examples/15-i18n-locale — boots a self-verify probe that drives every row of the chain through an in-process httptest server before opening port 8080, so the resolver behaviour is visible in the example output without a browser session.

Wiring

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

frBundle, _ := op.LocaleBundleFromMap("fr", map[string]string{
  "login.identifier": "Identifiant",
  "login.password":   "Mot de passe",
  /* ... */
})

brandJa, _ := op.LocaleBundleFromMap("ja", map[string]string{
  "consent.title": "Acme へのアクセス許可",  // override one key only
})

op.New(
  /* required options */
  op.WithDefaultLocale("fr"),
  op.WithLocale(frBundle),  // register a new locale
  op.WithLocale(brandJa),   // overlay one key on top of the seed ja bundle
)

The library ships seed bundles for English (en) and Japanese (ja) out of the box. op.WithLocale is layered, not replacement: each call merges on top of any earlier bundle for the same locale at key granularity. Embedders supply only the keys they want to change, and every unsupplied key falls through to the previous layer (the seed catalogue, or an earlier op.WithLocale call). Repeated calls for the same locale compose by repeated merge, so the last call wins per key.

Unknown locales (e.g. fr, de) register fresh on first call and merge on subsequent calls. The resolver still falls back to the configured default locale (see op.WithDefaultLocale) for any key the new locale's bundles never supply, so it is safe to ship a partial overlay without re-translating the whole catalogue. op.New rejects a default locale that is neither a seed locale nor registered through WithLocale, so this fallback cannot point at an empty catalogue.

The bundle format is a flat key-value catalogue with simple placeholder substitution (passed through LocaleBundleFromMap for construction-time validation).

The op.Locale type is a thin alias over a string so embedders can declare locale constants without importing internal packages; the seed locales are exposed as op.LocaleEnglish ("en") and op.LocaleJapanese ("ja").

Per-user preferred locale store

A logged-in user's saved locale outranks every other signal. Implement op.PreferredLocaleStore and pass it through op.WithPreferredLocaleStore:

go
type pgLocaleStore struct{ /* db handle, cache, ... */ }

func (s *pgLocaleStore) PreferredLocale(ctx context.Context, sub string) (op.Locale, error) {
  loc, err := s.fetch(ctx, sub)
  if err != nil {
    return "", err  // a non-nil error is treated as "no preference" — chain continues
  }
  return op.Locale(loc), nil  // empty Locale also falls through to the next layer
}

op.New(
  /* required options */
  op.WithPreferredLocaleStore(&pgLocaleStore{...}),
  op.WithLocale(frBundle),
  op.WithDefaultLocale(op.LocaleEnglish),
)

PreferredLocale is consulted on every /authorize hit, so the lookup must be cheap. Cache the value locally if your backing store is remote — a slow store call adds latency to every login screen render. Returning either an error or an empty Locale is fine; both are interpreted as "no preference" so the chain continues.

Reading the resolved locale

The OP runs the priority chain once per /authorize and exposes the resolved locale in two places.

From a SPA / custom interaction Driver

Every prompt envelope (GET LoginMount/state/{uid}) carries three locale fields, stamped by the orchestrator before Driver.Render runs:

FieldUse
localeThe resolved BCP 47 tag. Stamp it onto <html lang> and pick translation bundles.
ui_locales_hintThe RP's raw ui_locales list (whitespace-split). Read this only when overriding the resolver — most SPAs ignore it and trust locale.
locales_availableThe registered locale list (= discovery ui_locales_supported). Use it to build a language picker without re-fetching discovery.
js
function applyLocale(prompt) {
  if (prompt.locale) {
    document.documentElement.lang = prompt.locale;
  }
  // bundles[prompt.locale] looks up the SPA's own message catalogue.
}

A worked SPA example ships in examples/10-react-login/web/static/assets/main.js — its applyLocale is called from the prompt-fetch loop and stamps <html lang> exactly as above.

The same value also appears as prompt.Locale on the Go side, so a custom interaction.Driver can copy it into headers (e.g. Content-Language) or branch its rendering. examples/16-custom-interaction wraps JSONDriver to demonstrate the pattern.

From server-rendered code (emails, admin pages)

Provider.LocaleResolver() returns an *op.Resolver for surfaces that run outside /authorize — transactional emails, admin pages, anything where the same locale should apply but no prompt envelope exists:

go
loc := provider.LocaleResolver().Resolve(ctx, op.ResolveRequest{
  Subject:        userSub,                          // PreferredLocaleStore lookup
  AcceptLanguage: r.Header.Get("Accept-Language"),
})

renderEmail(loc, body)

Resolver also exposes Default() (the fallback locale) and Available() (registered locales in registration order — seed first, then WithLocale additions). The Provider builds it once at startup and never replaces it; it is safe for concurrent use.

Verifying

sh
# Discovery advertises the registered locales
curl -s http://localhost:8080/.well-known/openid-configuration | jq .ui_locales_supported
# ["en", "ja", "fr"]

Drive the priority chain manually:

RequestRendered locale
No signalfr (registered default)
Accept-Language: esfr (es not registered → fallback)
Accept-Language: ja-JP, ja;q=0.9ja
Cookie __Host-oidc_locale=jaja
?ui_locales=ja en (RFC 5646 list)first match = ja
?ui_locales=ja + cookie set to frja (parameter outranks cookie)
PreferredLocale returns ja, ?ui_locales=frja (store outranks parameter)

What gets translated

SurfaceBundle key example
Login form labelslogin.identifier, login.password, login.submit
Consent promptconsent.title, consent.scope.profile.description
Error pageserror.invalid_request_uri, error.unsupported_response_type
Logout confirmationlogout.confirm

The keys follow the surface they render. New keys are added in minor releases — listing keys is part of the release notes when bundles change shape.

Custom UI takes over

When you switch to the JSON driver (op.WithInteractionDriver(interaction.JSONDriver{})) your SPA / custom Driver owns the rendered strings. The library still negotiates the locale and stamps it on every prompt as prompt.locale (see "Reading the resolved locale" above), so SPAs and custom Drivers do not re-run the chain. The seed en / ja bundles continue to feed the built-in HTML templates only.

UI options and locale

WithSPAUI, WithConsentUI, and WithChooserUI all use the same locale resolution chain as the bundled HTML driver. The resolved prompt.locale is stamped under every driver. See Options reference §UI.