Use case — Custom login UI
The bundled sign-in pages work, but they carry the library's markup. The Login field of op.HTMLUI replaces them with a template of yours while the OP keeps owning state, CSRF, credential checks, and the redirect back to the RP.
One template serves every sign-in prompt except consent, the account chooser, and passkey: password, TOTP, email OTP, recovery code, factor choice, captcha, your own Step prompts, and prompts declared through op.WithInteractions. Consent has HTMLUI.Consent and the chooser has HTMLUI.Chooser, which sit in the same op.HTMLUI; passkey prompts and error pages stay with the built-in driver.
tmpl := template.Must(template.ParseFiles("login.html"))
op.New(
/* required options */
op.WithUI(op.HTMLUI{
Login: op.PageTemplate{
Template: tmpl,
ContentSecurityPolicy: "default-src 'none'; style-src 'self'; img-src 'self' data:",
},
}),
)op.WithUI may be called once and takes one mode, so op.HTMLUI is an alternative to op.SPAUI. The template is laid over the built-in HTML pages. To lay it over a driver of your own, pass op.DriverUI{Driver: interaction.TemplateOverlayDriver{Inner: yourDriver, LoginTemplate: tmpl}}. A ContentSecurityPolicy without a Template fails op.New.
What the template receives
interaction.LoginTemplateData:
| Field | Meaning |
|---|---|
Type | the prompt type being rendered, such as auth.password |
Data | the typed payload of a built-in step; nil for a custom Step and for op.WithInteractions prompts |
Fields | the inputs to render (Name, Kind, Label, LabelText, ...); LabelText falls back to the label key when the OP resolved no text |
FactorField | the form field the factor-choice prompt submits under |
Error | a one-shot notice for a rejected submission, or nil |
Locale | the BCP 47 tag the OP resolved |
StateRef, CSRFToken | continuation and CSRF values to echo back |
SubmitMethod, SubmitAction | where the form posts |
What the template must do
- Echo the hidden state. Render
state_refandcsrf_tokenas hidden inputs with those exact names. A form that drops either is refused on submission. - Guard
Data. Wrap typed access in awith .Datablock or branch on.Type. Reading a field of a nilDatafails the render. - Submit the factor choice. For the factor-choice prompt, list
.Data.Optionsas radio inputs and submit the chosen option'sKindunder.FactorField.
<form method="{{.SubmitMethod}}" action="{{.SubmitAction}}">
<input type="hidden" name="state_ref" value="{{.StateRef}}">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{range .Fields}}
<label>{{.LabelText}} <input name="{{.Name}}" type="{{inputType .Kind}}"></label>
{{end}}
{{if eq .Type "auth.choose_factor"}}{{with .Data}}{{range .Options}}
<label><input type="radio" name="{{$.FactorField}}" value="{{.Kind}}"> {{.LabelText}}</label>
{{end}}{{end}}{{end}}
<button type="submit">Continue</button>
</form>inputType is a function the template is parsed with (template.FuncMap), mapping interaction.FieldPassword to password, interaction.FieldEmail to email, interaction.FieldHidden to hidden and the rest to text; a template cannot compare against those constants itself. examples/37-custom-login-ui defines it.
For authors of a custom driver
A driver of your own (op.DriverUI) receives interaction.Prompt values and renders them itself. A prompt shown again after a rejected submission carries Prompt.Error (error on the SPA wire), an interaction.PromptError with a Code and a Message. interaction.PromptErrorCredentialsRejected is the code for a rejected credential, and it is the same whether or not the account exists. Message is the code's text in Prompt.Locale and is empty when the OP has no locale resolver, so fall back on Code; treat an unknown code as a generic failure. interaction.PromptErrorMessageKey(code) returns the i18n catalogue key of a code, or an empty string for a code outside the closed set. The notice is one-shot: a prompt built from a fresh request carries none.
The factor-choice prompt has Type interaction.PromptTypeChooseFactor and Data of type interaction.FactorChoicePromptData. Render its Options and submit the chosen option's Kind under the form field interaction.FactorChoiceField (factor). Each interaction.FactorOption has a Label key and a LabelText resolved in the prompt's locale; the same holds for FieldSpec.LabelText.
One CSP for every login page
Login.ContentSecurityPolicy follows the rules of PageTemplate.ContentSecurityPolicy: empty keeps the library default, which blocks every subresource, and a non-empty value is validated when op.New runs.
The policy is shared by every login prompt. Loosening script-src so a captcha widget can load also loosens the password and TOTP pages. Name hosts only, and never 'unsafe-inline': the page holds the CSRF token and the continuation state.
Try it
examples/37-custom-login-ui runs a password login through a branded template whose stylesheet the example serves itself, so the CSP can say style-src 'self'.
Read next
- Custom consent UI — the same pattern for the consent screen.
- SPA / custom interaction — when a front end owns every screen.