Skip to content

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 ​

NeedUse this page?
Add a TOTP factorNo. op.StepTOTP covers it (MFA / step-up)
Add a passkey factorNo. op.PrimaryPasskey covers it
Add SMS OTPYes
Add a hardware token (YubiKey OTP, hardware HOTP)Yes
Add magic-link loginYes
Add a custom risk gateNo. 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 ​

go
// 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.

A custom authenticator, prompt by prompt
The login flow calls Begin and the authenticator answers with a phone-number prompt. After the user submits the number, Continue sends the SMS and answers with a code prompt. When the code matches, Continue returns a Result, which writes the subject and the factor into the session.LoginFlowthe orchestratorAuthenticatoryour implementationThe screenSPA or server-renderedBegin(ctx, input)Begin returns a phone promptmyorg.sms.collect_phonethe user submits the phone numberlook up the number,mint an OTP,send the SMSContinue returns a code promptmyorg.sms.collect_codethe user submits the codeContinue returns a ResultResult{ Subject: … }code matched:subject and factorland in the session
The authenticator never renders anything and never touches the session. It answers with a prompt or a result and the orchestrator does the rest, so the same authenticator works behind an SPA and a server-rendered page alike.

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 ​

go
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, and Verify checks 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 Begin or Continue is classified by sentinel: an error wrapping op.ErrFactorRetry records a failure (login context, audit, observer) and presents the same prompt again carrying error.code credentials_rejected; one wrapping op.ErrFactorAbort ends 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 in OTPs.Verify.

2. Plug it into a LoginFlow ​

go
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:

json
{
  "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:

go
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:

  • Registration must be an op.ExternalStep. Built-in steps and op.StepOneOf are rejected by op.New, as is CountFailures: true (no subject is bound yet) and a KindLabel that 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.Subject you 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_time is the time of this attempt and amr comes from your authenticator's AMR().
  • Discovery lists create in prompt_values_supported only when Registration is configured.
  • Without Registration, prompt=create is rejected with invalid_request.
  • prompt=create combined with any other value (none, login, consent, select_account) is rejected with invalid_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:

KindDefault MaxLen (bytes)
FieldText512
FieldPassword1024
FieldOTPCode32
FieldEmail320
FieldHidden16 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:

RequirementWhy
Type() is unique within a flowdispatch routing
ExternalStep.KindLabel has a dotted prefix and appears in one rule onlyreserves 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 emitstartup validation; a missing type causes runtime errors
Begin and Continue return a Prompt or a Result; an empty Step is rejectedorchestrator state machine
A returned Result.Subject is empty or equals the bound subjecta different subject ends the attempt
Identical response shape and timing for known and unknown identifiersuser-enumeration defence
Stateless across callsper-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.

go
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:

TypeDecides
Stepwhich factor (a descriptor such as op.StepTOTP or op.ExternalStep)
Rulewhen that factor runs
Decidera policy override that can allow, deny, or require a step before the rules run
Authenticatorhow 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 ​