Custom authenticator
The library ships built-in Step values for password, passkey, TOTP, email OTP, captcha, and recovery codes. For any other factor (SMS, a hardware token, a magic link, a proprietary device-trust check) you implement the op.Authenticator interface and plug it into a LoginFlow through op.ExternalStep.
An authenticator is a small state machine for one factor. The OP calls Begin, you answer with a prompt; the user submits it, the OP calls Continue, you answer with the next prompt or a result. You never render HTML and never touch the session.
When you need this
| Need | Use this page? |
|---|---|
| Add a TOTP factor | No. op.StepTOTP covers it (MFA / step-up) |
| Add a passkey factor | No. op.PrimaryPasskey covers it |
| Add SMS OTP | Yes |
| Add a hardware token (YubiKey OTP, hardware HOTP) | Yes |
| Add magic-link login | Yes |
| Add a custom risk gate | No. Use op.RuleRisk with your own RiskAssessor |
| Add a non-credential prompt (terms of service, KYC) | No. Implement op.Interaction and register it with op.WithInteractions (options reference) |
The line between the two: anything that collects credentials and binds a subject is an Authenticator. Anything that runs after the subject is bound and only shows a prompt is an Interaction.
The interface
// Declared in op/authn; package op re-exports it as op.Authenticator.
package authn
type Authenticator interface {
// Type returns the FactorType this authenticator implements.
// Two registered authenticators MUST NOT share a Type.
Type() FactorType
// AAL returns the assurance level a successful Continue raises
// the session to. The orchestrator takes the maximum across all
// completed factors.
AAL() AAL
// AMR returns the RFC 8176 §2 registered value contributed to
// the amr claim, or "" to suppress this factor's contribution.
AMR() string
// Prompts returns every interaction.Prompt.Type this authenticator
// may emit.
Prompts() []string
// Begin starts the ceremony. The returned interaction.Step
// carries a Prompt for a multi-step factor or a populated Result
// for a factor that completes immediately.
Begin(ctx context.Context, in BeginInput) (interaction.Step, error)
// Continue advances the ceremony with the SPA's submission.
Continue(ctx context.Context, in ContinueInput) (interaction.Step, error)
}Implementations must be safe for concurrent use. BeginInput carries the subject bound so far (empty for the first factor), the client ID, the reference AuthTime, the requested scopes, and a read-only client view. ContinueInput carries the submitted interaction.FormSubmission, the same AuthTime, the bound subject, the opaque Scratch from the previous step, and the caller's RemoteIP. Read time from AuthTime, not time.Now(), so the chain stays consistent with the provider's clock.
The orchestrator stamps Prompt.StateRef after Begin or Continue returns; an authenticator must not invent or persist one. Put per-attempt state in interaction.Step.Scratch. The OP keeps it server-side and hands it back in ContinueInput.Scratch; no driver ever sends it to the browser.
State transitions
An Authenticator returns either the next prompt or a result that completes the factor. For a two-step factor such as SMS OTP, Begin returns the first prompt and each submission makes Continue return the next prompt or the final result.
When Continue returns a Result, the orchestrator records the subject, the AAL, and the AMR value in the chain, and the LoginFlow moves on to the next rule.
Worked example: SMS OTP
The factor: ask for a phone number, send a 6-digit code by SMS, verify the code. The three dependencies are interfaces you own; the library does not ship them.
1. Implement Authenticator
package smsauth
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"strings"
"time"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/interaction"
)
// PhoneDirectory maps an enrolled phone number to a subject.
type PhoneDirectory interface {
SubjectByPhone(ctx context.Context, phone string) (subject string, ok bool)
}
// OTPStore keeps one live code per subject (as a hash) and counts failures.
type OTPStore interface {
Put(ctx context.Context, subject, code string, ttl time.Duration) error
Verify(ctx context.Context, subject, code string) (ok bool, err error)
}
// SMSSender delivers the code through your SMS provider.
type SMSSender interface {
Send(ctx context.Context, phone, code string) error
}
type SMSAuthenticator struct {
Phones PhoneDirectory
OTPs OTPStore
Sender SMSSender
CodeTTL time.Duration // typically 5 minutes
}
func (a *SMSAuthenticator) Type() op.FactorType { return "myorg.sms_otp" }
func (a *SMSAuthenticator) AAL() op.AAL { return op.AAL2 }
func (a *SMSAuthenticator) AMR() string { return "otp" } // RFC 8176 §2
const (
phonePrompt = "myorg.sms.collect_phone"
codePrompt = "myorg.sms.collect_code"
)
func (a *SMSAuthenticator) Prompts() []string {
return []string{phonePrompt, codePrompt}
}
func (a *SMSAuthenticator) Begin(_ context.Context, _ op.BeginInput) (interaction.Step, error) {
return interaction.Step{
Prompt: &interaction.Prompt{
Type: phonePrompt,
Inputs: []interaction.FieldSpec{{
Name: "phone",
Kind: interaction.FieldText,
Label: "myorg.sms.phone", // i18n key; echoed verbatim when no bundle has it
Required: true,
MaxLen: 32,
}},
},
Scratch: []byte(phonePrompt),
}, nil
}
func (a *SMSAuthenticator) Continue(ctx context.Context, in op.ContinueInput) (interaction.Step, error) {
// Scratch carries "<stage>\n<subject>"; the subject is empty for an unknown number.
stage, subject, _ := strings.Cut(string(in.Scratch), "\n")
switch stage {
case phonePrompt:
phone := in.Submission.Values["phone"]
subject, ok := a.Phones.SubjectByPhone(ctx, phone)
if ok {
code, err := generate6DigitCode()
if err != nil {
return interaction.Step{}, err
}
if err := a.OTPs.Put(ctx, subject, code, a.CodeTTL); err != nil {
return interaction.Step{}, err
}
if err := a.Sender.Send(ctx, phone, code); err != nil {
return interaction.Step{}, err
}
}
// Known and unknown numbers get the same prompt, so the response
// does not reveal which numbers are enrolled.
return codeStep(subject), nil
case codePrompt:
ok, err := a.OTPs.Verify(ctx, subject, in.Submission.Values["code"])
if err != nil {
return interaction.Step{}, err
}
if !ok || subject == "" {
return codeStep(subject), nil // ask again; Verify enforces the attempt limit
}
return interaction.Step{
Result: &interaction.Result{Subject: subject, AuthTime: in.AuthTime},
}, nil
}
return interaction.Step{}, fmt.Errorf("unexpected authenticator state %q", stage)
}
func codeStep(subject string) interaction.Step {
return interaction.Step{
Prompt: &interaction.Prompt{
Type: codePrompt,
Inputs: []interaction.FieldSpec{{
Name: "code",
Kind: interaction.FieldOTPCode,
Label: "myorg.sms.code",
Required: true,
MinLen: 6,
MaxLen: 6,
Pattern: "[0-9]{6}",
}},
},
Scratch: []byte(codePrompt + "\n" + subject),
}
}
func generate6DigitCode() (string, error) {
b := make([]byte, 4)
if _, err := rand.Read(b); err != nil {
return "", err
}
return fmt.Sprintf("%06d", binary.BigEndian.Uint32(b)%1_000_000), nil
}Three things in this code carry the security of the factor:
- The subject travels in
Scratch, not in the form. The user types only the code, andVerifychecks it against the code issued to that subject. Looking a code up without a subject would let one user's code match another's. - Known and unknown numbers look the same. Real deployments also match the timing, for example by handing the SMS to a queue so the unknown branch does the same work.
- A wrong code is a rejected credential, not a server fault. The error from
BeginorContinueis classified by sentinel: an error wrappingop.ErrFactorRetryrecords a failure (login context, audit, observer) and presents the same prompt again carryingerror.codecredentials_rejected; one wrappingop.ErrFactorAbortends the attempt with a 4xx for a failure the user caused; any other error is a server fault and ends the request with HTTP 500. Returning the prompt again with a nil error is not recorded as a failure, which is why this example counts the guess inOTPs.Verify.
2. Plug it into a LoginFlow
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: myStore.UserPasswords()},
Rules: []op.Rule{
op.RuleAlways(op.ExternalStep{
Authenticator: &smsauth.SMSAuthenticator{
Phones: directory,
OTPs: redisOTPs,
Sender: twilioSender,
CodeTTL: 5 * time.Minute,
},
KindLabel: "myorg.sms_otp", // dotted prefix REQUIRED
}),
},
}
op.New(
/* required options */
op.WithLoginFlow(flow),
)The dotted prefix on KindLabel (myorg.sms_otp) is required: op.New rejects bare or built-in labels. Use your organisation identifier as the prefix. Each Kind may appear in only one rule, so a flow cannot list the same ExternalStep kind twice.
When the factor runs after another one, as here, the Subject in your Result must be the subject already bound (or empty). A different subject ends the attempt with HTTP 400, because the session would otherwise carry the first factor's assurance under another user's identity.
An ExternalStep after Primary, as here, can join the cross-factor lockout counter the built-in second factors share by setting CountFailures: true; each op.ErrFactorRetry then counts one failure. At Primary no subject is bound yet, so op.New rejects the flag there and the remote verifier's own lockout applies. Without it, rate limiting for an SMS code belongs in your OTPStore.
An ExternalStep inside an op.StepOneOf is offered only to subjects who can complete it. Implement op.EnrollmentChecker on the authenticator to report that; without it the step counts as enrolled for every subject.
3. Render the prompts in your UI
With the default HTML driver nothing more is needed. It renders an unknown prompt type as a generic form built from Inputs: the page title is the message-bundle entry named after Prompt.Type, and each field label is the entry named by FieldSpec.Label (the key itself when no bundle defines it). Register the strings with op.WithLocaleBundle (i18n).
With the JSON driver, the SPA reads the prompt at /interaction/{uid} (or LoginMount/state/{uid} under op.SPAUI). The envelope is the Prompt itself; the SPA must echo state_ref in its submission and send field values under values. FieldSpec members are snake_case, and kind is the field kind's name:
{
"type": "myorg.sms.collect_phone",
"inputs": [{"name": "phone", "kind": "text", "label": "myorg.sms.phone", "max_len": 32, "min_len": 0, "required": true, "pattern": ""}],
"state_ref": "...",
"csrf_token": "...",
"locale": "en"
}When the OP has a locale resolver, each input also carries label_text (label resolved in the prompt's locale), and a prompt shown again after a rejected credential carries error with a code and a localised message. The SPA renders a phone input and POSTs { "state_ref": "...", "values": { "phone": "+1..." } } back to the same URL with the X-CSRF-Token header. The next response is the myorg.sms.collect_code prompt; the SPA posts { "state_ref": "...", "values": { "code": "123456" } }. Once the code matches, the OP continues with the next rule or consent and finally returns the terminal response; SPA / custom interaction walks through that envelope.
Registration step (prompt=create)
A relying party can send prompt=create (OpenID Connect Prompt Create 1.0) to ask that the user start from account creation instead of login. The OP has no user table, so it does not create the account; your Authenticator does. Set LoginFlow.Registration and the OP runs that step in place of Primary:
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: myStore.UserPasswords()},
Registration: op.ExternalStep{
Authenticator: &signup.Authenticator{Users: directory},
KindLabel: "myorg.signup", // dotted prefix REQUIRED
},
}
op.New(
/* required options */
op.WithLoginFlow(flow),
)The rules:
Registrationmust be anop.ExternalStep. Built-in steps andop.StepOneOfare rejected byop.New, as isCountFailures: true(no subject is bound yet) and aKindLabelthat duplicates one used elsewhere in the flow.- The step runs only when the request carries
prompt=create. A rule or decider cannot select it during an ordinary login. - It runs even when the user already has a session. The attempt never inherits the signed-in user, that user's authentication context, or that user's consent: the
Result.Subjectyou return is the new subject, and consent is asked for it from scratch. - After the step binds a subject, the flow continues as after
Primary: rules, consent, then the authorization code.auth_timeis the time of this attempt andamrcomes from your authenticator'sAMR(). - Discovery lists
createinprompt_values_supportedonly whenRegistrationis configured. - Without
Registration,prompt=createis rejected withinvalid_request. prompt=createcombined with any other value (none,login,consent,select_account) is rejected withinvalid_request. The same applies when the value arrives through PAR or a request object.
Prompt fields and limits
Declare every submitted field in Prompt.Inputs. PromptData is a sealed interface, so an application cannot add its own data type from outside the interaction package; use FieldSpec for custom form fields. Keep secrets out of Prompt.StateRef and out of any Scratch value a driver could expose. Custom prompt types need an organisation prefix (myorg.…), which keeps them apart from the library's auth.*, consent.*, captcha, and interaction.* names.
FieldKind is one of FieldText, FieldPassword, FieldOTPCode, FieldEmail, or FieldHidden. Required, MinLen, MaxLen, and Pattern are enforced before Continue runs; the pattern must match the whole value. A field with MaxLen zero gets the per-kind byte limit:
| Kind | Default MaxLen (bytes) |
|---|---|
FieldText | 512 |
FieldPassword | 1024 |
FieldOTPCode | 32 |
FieldEmail | 320 |
FieldHidden | 16 KiB |
A submission is also capped at 32 KiB across names and values, and may contain at most four entries beyond the declared fields. A prompt's StateRef expires after 10 minutes and works once; a stale or replayed reference is rejected before the authenticator runs.
The built-in MFA prompts use the same contract. TOTP exposes AttemptsRemaining and a six-digit code. Email OTP exposes a masked address and ExpiresAt, accepts a 254-byte email and a six-digit code, and defaults the code lifetime to five minutes. Passkey exposes the WebAuthn challenge and allow-list and caps the assertion response at 16 KiB. Recovery codes expose AttemptsRemaining and accept 10 to 32 bytes. For TOTP and recovery prompts, AttemptsRemaining is the remaining failed-submission budget, not the number of unused recovery codes. These values are prompt metadata and validation bounds, not a substitute for factor-specific rate limiting.
Contract requirements
The orchestrator validates configuration at provider construction and rejects an empty step when it runs. The public contract is:
| Requirement | Why |
|---|---|
Type() is unique within a flow | dispatch routing |
ExternalStep.KindLabel has a dotted prefix and appears in one rule only | reserves bare names for built-ins; one discriminator per rule |
AMR() returns an RFC 8176 §2 value or "" | foreign values are dropped with a warning audit |
Prompts() lists every prompt type Begin and Continue may emit | startup validation; a missing type causes runtime errors |
Begin and Continue return a Prompt or a Result; an empty Step is rejected | orchestrator state machine |
A returned Result.Subject is empty or equals the bound subject | a different subject ends the attempt |
| Identical response shape and timing for known and unknown identifiers | user-enumeration defence |
| Stateless across calls | per-attempt state lives in Scratch, not in your struct |
Testing
Test the authenticator with ordinary Go tests: call Begin, submit interaction.FormSubmission{Values: ...} to Continue, assert the returned prompt or result, and run the same sequence concurrently. The public op/testkit package provides a subject-binding authenticator for HTTP-layer fixtures; it is not a production authenticator.
func TestSMSAuthenticator(t *testing.T) {
auth := &smsauth.SMSAuthenticator{Phones: dir, OTPs: otps, Sender: sender, CodeTTL: 5 * time.Minute}
first, err := auth.Begin(context.Background(), op.BeginInput{})
if err != nil || first.Prompt == nil {
t.Fatal(err)
}
next, err := auth.Continue(context.Background(), op.ContinueInput{
AuthTime: time.Now(),
Scratch: first.Scratch,
Submission: interaction.FormSubmission{Values: map[string]string{"phone": "+1..."}},
})
if err != nil || next.Prompt == nil {
t.Fatal(err)
}
}How the pieces divide the work
A login decision has four parts, and each belongs to a different type:
| Type | Decides |
|---|---|
Step | which factor (a descriptor such as op.StepTOTP or op.ExternalStep) |
Rule | when that factor runs |
Decider | a policy override that can allow, deny, or require a step before the rules run |
Authenticator | how the factor's ceremony works |
The orchestrator owns order and de-duplication; your code owns the factor mechanics. ExternalStep is the bridge for any Authenticator that has no built-in Step.
See also
- Architecture overview § LoginFlow internals: what the orchestrator does with your authenticator.
- MFA / step-up: composing built-in Steps with
Rule. - Audit event catalog § Login / MFA / step-up: what your authenticator emits when it succeeds or fails.