Skip to content

Use case — Custom chooser UI ​

prompt=select_account has two separable concerns:

  • the session semantics: the browser holds a chooser group with several signed-in accounts, and the account the user picks decides the next sub;
  • the rendering surface: the page that lists those accounts and POSTs the chosen SessionID.

Multi-account chooser covers the first. This page covers the second: use op.HTMLUI{Chooser: ...} for a branded, server-rendered account picker while the OP keeps state, CSRF, and the session switch.

Source: examples/12-custom-chooser-ui demonstrates op.HTMLUI{Chooser: ...} on the built-in HTML pages. Compare examples/13-multi-account for the JSON-driver / SPA path.

When to use it ​

NeedUse
Keep the bundled chooserno option; the built-in HTML pages render it
Change chooser HTML / copy / layout while staying server-renderedop.WithUI(op.HTMLUI{Chooser: op.PageTemplate{Template: tmpl}})
Render the chooser inside a SPAop.WithUI(op.SPAUI{...}) or op.WithUI(op.DriverUI{Driver: interaction.JSONDriver{}})
Change how accounts are grouped or switchedsession-store / authenticator logic, not the template

The Chooser template is deliberately narrow. It swaps the template only; the template cannot choose arbitrary subjects, mint sessions, or bypass the OP's state machine. To lay the template over a driver of your own, pass an interaction.TemplateOverlayDriver with that driver as Inner to op.DriverUI.

Template contract ​

The template receives interaction.ChooserTemplateData:

FieldPurpose
Accountslive sessions in the chooser group, most recently used first; each has SessionID, Subject, DisplayName (the user's name claim, empty when unknown), and AuthTime
StateRefopaque interaction state reference that must be echoed back
CSRFTokentoken the OP validates on POST
SessionIDFieldform field name expected by the OP for the chosen account
SubmitMethodnormally POST
SubmitActioninteraction endpoint URL
AddAccountURL/auth URL that signs in another account into the same group; empty when the deployment requires PAR

Minimal shape:

go
tmpl := template.Must(template.New("chooser").Parse(`
{{range .Accounts}}
  <form method="{{$.SubmitMethod}}" action="{{$.SubmitAction}}">
    <input type="hidden" name="state_ref" value="{{$.StateRef}}">
    <input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
    <input type="hidden" name="{{$.SessionIDField}}" value="{{.SessionID}}">
    <button type="submit">Continue as {{if .DisplayName}}{{.DisplayName}}{{else}}{{.Subject}}{{end}}</button>
  </form>
{{end}}
{{if .AddAccountURL}}<a href="{{.AddAccountURL}}">Sign in to another account</a>{{end}}
`))

provider, err := op.New(
  /* required options */
  op.WithUI(op.HTMLUI{
    Chooser: op.PageTemplate{Template: tmpl},
  }),
)

The field names are part of the OP contract. Keep state_ref, csrf_token, and the field named by SessionIDField (session_id) in the submitted form. Render the "add account" link only when AddAccountURL is non-empty: under RequirePAR (for example FAPI 2.0) a bare query-string /auth URL would be refused, so the OP leaves the field empty.

Each op.PageTemplate has its own ContentSecurityPolicy field, so the chooser declares its policy separately from the consent one; the two templates may load from different origins. An empty value uses the strict default (default-src 'none'; style-src 'none'; frame-ancestors 'none'; base-uri 'none'), which blocks stylesheets and images too. A custom policy is normalized by interaction.NormalizeCSP when op.New runs: form-action is forbidden, frame-ancestors and base-uri must be exactly 'none' when present, and missing protections are appended. A refused policy fails op.New with interaction.ErrCSPNotPermitted. Add only the asset directives the template needs, and keep scripts off.

Flow ​

The chooser, from request to switch
A request carrying prompt=select_account makes the OP load the chooser group and hand it to your template. The submission comes back with a CSRF token and a state reference, both of which are validated before the session is switched.Browserthe user'sOPgo-oidc-providerChooser templateyoursGET /authprompt=select_accountload the chooser groupbehind the cookierender ChooserTemplateDatathe account listplus CSRFToken and StateRefPOST SubmitActionsession_idvalidate before switchingCSRFToken · StateRefswitch the sessionSwitch(group, session_id)302 to the RP, with a code
Your template owns the markup and nothing else. The two opaque values it has to echo back are what stop a submitted session_id from being anything other than one this browser was just offered.

The template never performs the switch; it only returns the chosen session identifier. The OP checks that the identifier belongs to the active chooser group and refuses a foreign or expired session_id with a 4xx. If the chosen session expired or was signed out in the meantime, the RP receives login_required.

If the chosen session cannot serve the request (the request has prompt=login, the session is older than max_age, or its acr does not meet acr_values), the OP does not fail the request. It runs the login factors for that account again.

The picked account goes through the same LoginFlow rules, Decider and risk step-up as a returning session, so a rule or a denial applies to a pick as it does to any other sign-in. A pick that re-authenticates rotates the picked session record with the new auth_time, acr and amr.

Who owns the chooser ​

op.SPAUI makes the SPA own the chooser surface through the JSON state envelope. op.WithUI takes one mode per deployment, so the chooser owner follows from the mode you pass:

UI ownerValue for op.WithUI
OP server-rendered HTMLop.HTMLUI{Chooser: ...}
SPA shell mounted by the OPop.SPAUI{...}
Your own router serves the SPAop.DriverUI{Driver: interaction.JSONDriver{}}

Production notes ​

  • Parse templates once at startup, not per request.
  • Keep the CSP strict. The data can contain attacker-influenced labels, so rely on html/template escaping and avoid inline scripts.
  • Treat SessionID as an opaque value. Do not display it or build it from other fields.
  • Use AddAccountURL as given. It carries private markers that make the next sign-in join the existing group; a hand-built prompt=login URL replaces the current account instead of adding one.