Skip to content

カスタム認証器

ライブラリは、パスワード、パスキー、TOTP、メール OTP、captcha、recovery code の組み込み Step を同梱しています。それ以外(ハードウェアトークン、SMS、magic link、独自のデバイス信頼など)を追加するには、op.Authenticator インターフェースを実装し、op.ExternalStep で差し込みます。

このページでは、契約、実装例、よくある落とし穴を扱います。

このページが必要なケース

やりたいことこのページ?
TOTP を追加不要 — op.StepTOTP で足ります(MFA / ステップアップ
パスキーを追加不要 — op.PrimaryPasskey で足ります
SMS OTP を追加必要
ハードウェアトークン(YubiKey OTP、HOTP など)を追加必要
magic link ログインを追加必要
独自のリスク判定を追加不要 — op.RuleRisk + 自前の RiskAssessor で対応
資格情報以外の追加画面(T&C、KYC など)を追加不要 — op.Interaction で対応

差し込み口の違いは明確です。資格情報を集めて subject を確定するものAuthenticatorsubject が確定したあとに追加画面を出すものInteraction です。

interface

go
package op

type Authenticator interface {
    // この authenticator が実装する FactorType を返す。
    // 同一フロー内で 2 つの authenticator が同じ Type を持つことは禁止。
    Type() FactorType

    // Continue が成功したときに、セッションを引き上げる保証レベル。
    // オーケストレータは完了済み認証要素を跨いで最大値を取り、
    // セッションの AAL を導出する。
    AAL() AAL

    // amr claim に寄与する RFC 8176 §2 の登録値、
    // または "" で寄与を抑制。
    AMR() string

    // この authenticator が emit し得るすべての interaction.Prompt.Type
    // を返す。オーケストレータは起動時にルーティングテーブルを
    // 検証する。
    Prompts() []string

    // フローを開始する。複数ステップなら Prompt、すぐに完了する
    // 認証要素なら Result を載せる。
    Begin(ctx context.Context, in BeginInput) (interaction.Step, error)

    // SPA からの送信でフローを進める。
    Continue(ctx context.Context, in ContinueInput) (interaction.Step, error)
}

実装は並行安全でなければなりません。オーケストレータは複数の goroutine から呼び出します。BeginInput には、確定済みなら subject、client ID、基準時刻 AuthTime、要求された scope、読み取り専用の client view が入ります。ContinueInput には、interaction.FormSubmission、同じ AuthTime、確定済みの subject、直前の Step が返した不透明な Scratch が入ります。

オーケストレータは Begin または Continue の戻り値に対して Prompt.StateRef を設定します。認証器が state reference を生成したり保存したりしてはいけません。試行ごとの状態は interaction.Step.Scratch に入れてください。この値はサーバ側に保持され、次の ContinueInput.Scratch に渡されます。

状態遷移の全体像

Authenticator は、毎回「次に表示する Prompt」または「認証完了を表す Result」のどちらかを返します。SMS OTP のような 2 段階の認証要素では、Begin が最初の Prompt を返し、SPA からの送信ごとに Continue が次の Prompt か Result を返します。

カスタム Authenticator を prompt 単位で追う
LoginFlow が Begin を呼び、Authenticator が prompt を返し、ユーザが送信し、その内容で Continue が呼ばれ、2 つ目の prompt が続きます。Result を返すとチェーンが終わり、subject と factor がセッションに書き込まれます。LoginFlowオーケストレータAuthenticator自前実装 · Type / AAL / AMR画面SPA でもサーバ描画でもよい1Begin(ctx, input)2Prompt · myorg.sms.collect_phone番号を受け取り、OTP を生成して送信する3ユーザが電話番号を送信する4Continue → Prompt · myorg.sms.collect_code送信されたコードを検証する5ユーザがコードを送信する6Result{ Subject: … }チェーンが終わり、subject と factor がセッションに入る
Authenticator は画面を描画せず、セッションにも触れません。返すのは prompt か result のどちらかで、残りはオーケストレータが担います。だから同じ Authenticator が SPA の裏でもサーバ描画の裏でも動きます。

最後のステップが返す Result が認証要素の完了を表します。オーケストレータはこの Result から subject、AAL、AMR の値をセッションに書き込み、LoginFlow は次の Rule へ進みます。

実装例: SMS OTP

実装する認証要素: 電話番号を集め、6 桁のコードを SMS で送り、ユーザが入力したコードを検証する。

1. Authenticator を実装する

go
package smsauth

import (
    "context"
    "crypto/rand"
    "encoding/binary"
    "fmt"
    "time"

    "github.com/libraz/go-oidc-provider/op"
    "github.com/libraz/go-oidc-provider/op/interaction"
    "github.com/libraz/go-oidc-provider/op/store"
)

type SMSAuthenticator struct {
    Sender    SMSSender                 // SMS プロバイダのアダプタ
    OTPStore  OTPStore                  // 試行ごとの OTP レコードを保管するストア
    UserStore store.UserStore            // 「電話番号 → subject」検索
    CodeTTL   time.Duration             // 通常は 5 分
}

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,
                Required: true,
                MaxLen:   254,
            }},
        },
        Scratch: []byte(phonePrompt),
    }, nil
}

func (a *SMSAuthenticator) Continue(ctx context.Context, in op.ContinueInput) (interaction.Step, error) {
    switch string(in.Scratch) {
    case phonePrompt:
        phone := in.Submission.Values["phone"]
        if phone == "" {
            return interaction.Step{}, fmt.Errorf("phone required")
        }
        // 定数時間で照会する。登録済み番号と未登録番号で、応答の
        // 形とタイミングを一致させてください(ユーザの存在を漏らさ
        // ないため)。
        subject, _ := a.UserStore.LookupByPhone(ctx, phone)

        // subject が空でもコードは常に送信する(情報漏えい対策)。
        code, err := generate6DigitCode()
        if err != nil {
            return interaction.Step{}, err
        }
        if subject != "" {
            if err := a.OTPStore.Put(ctx, subject, hash(code), a.CodeTTL); err != nil {
                return interaction.Step{}, err
            }
            if err := a.Sender.Send(ctx, phone, code); err != nil {
                return interaction.Step{}, err
            }
        }
        return interaction.Step{
            Prompt: &interaction.Prompt{
                Type: codePrompt,
                Inputs: []interaction.FieldSpec{{
                    Name:     "code",
                    Kind:     interaction.FieldOTPCode,
                    Required: true,
                    MinLen:   6,
                    MaxLen:   6,
                }},
            },
            Scratch: []byte(codePrompt),
        }, nil

    case codePrompt:
        submitted := in.Submission.Values["code"]
        // 保存ハッシュとの定数時間比較。
        subject, ok := a.OTPStore.Verify(ctx, submitted)
        if !ok {
            return interaction.Step{}, fmt.Errorf("code rejected")
        }
        return interaction.Step{
            Result: &interaction.Result{
                Subject:  subject,
                AuthTime: in.AuthTime,
            },
        }, nil
    }
    return interaction.Step{}, fmt.Errorf("unexpected authenticator state")
}

func generate6DigitCode() (string, error) {
    b := make([]byte, 4)
    if _, err := rand.Read(b); err != nil {
        return "", err
    }
    n := binary.BigEndian.Uint32(b) % 1_000_000
    return fmt.Sprintf("%06d", n), nil
}

2. LoginFlow に差し込む

go
flow := op.LoginFlow{
    Primary: op.PrimaryPassword{Store: myStore.UserPasswords()},
    Rules: []op.Rule{
        op.RuleAlways(op.ExternalStep{
            Authenticator: &smsauth.SMSAuthenticator{
                Sender:    twilioSender,
                OTPStore:  redisOTPs,
                UserStore: myStore,
                CodeTTL:   5 * time.Minute,
            },
            KindLabel: "myorg.sms_otp", // ドット形式のプリフィックスが必須
        }),
    },
}

op.New(
    /* 必須オプション */
    op.WithLoginFlow(flow),
)

KindLabel のドット形式プリフィックス(myorg.sms_otp)は 必須 です。LoginFlow のコンパイラは、プリフィックス無しの名前や組み込み名を構築時に拒否します。組織識別子をプリフィックスにしてください。

3. SPA で画面を描画する

JSON ドライバ(op.WithInteractionDriver(interaction.JSONDriver{}))構成下では、SPA は /interaction/{uid} で画面状態を JSON として受け取ります。レスポンスにはカスタムの type、宣言した inputs、不透明な state_ref が含まれます。SPA はこの state_refFormSubmission.StateRef に戻し、値を FormSubmission.Values に入れて送信します。

json
{
  "prompt": {
    "type": "myorg.sms.collect_phone",
    "inputs": [{"name": "phone", "kind": 0, "required": true}],
    "state_ref": "..."
  }
}

電話番号入力を描画し、values{ "phone": "+1..." } を入れた FormSubmission を同じエンドポイントに POST します。次のレスポンスは myorg.sms.collect_code の画面なので、コード入力を描画して { "values": { "code": "123456" } } を POST します。3 番目のレスポンスは、subject が確定した Result です。

HTML ドライバ構成では、2 種類の画面を扱うカスタムテンプレートを登録してください。

Prompt のフィールドと上限

カスタム Prompt では、送信するフィールドをすべて Prompt.Inputs に宣言します。PromptData は sealed interface なので、アプリケーションが interaction パッケージの外から任意の data 型を追加することはできません。カスタムのフォーム項目には FieldSpec を使い、secret を Prompt.StateRef や Driver に公開し得る Step.Scratch に入れないでください。

FieldKindFieldTextFieldPasswordFieldOTPCodeFieldEmailFieldHidden に限られます。RequiredMinLenMaxLen、全体一致の PatternContinue の前に検証されます。MaxLen が 0 の場合、byte 単位の上限は text が 512、password が 1024、OTP code が 32、email が 320、hidden が 16 KiB です。1 回の submission 全体にも名前と値の合計 32 KiB、宣言した項目を超える追加項目 4 個までという上限があります。Prompt の StateRef は 10 分で失効し、1 回しか使えません。古い、または再利用された reference は認証器が呼ばれる前に拒否されます。

組み込み MFA の Prompt も同じ契約を使います。TOTP は AttemptsRemaining とちょうど 6 桁の code を返します。メール OTP はマスク済みアドレスと ExpiresAt を返し、254 byte のメールアドレスと 6 桁の code を受け付け、code の既定有効期間は 5 分です。passkey は WebAuthn の challenge と allow-list を返し、assertion response は 16 KiB までです。recovery code は AttemptsRemaining を返し、10〜32 byte を受け付けます。TOTP と recovery の AttemptsRemaining は、未使用の recovery code 数ではなく、失敗した提出をあと何回許容するかを示します。これらは Prompt のメタデータと入力上限であり、要素ごとのレート制限の代わりにはなりません。

契約条件

オーケストレータは Provider 構築時に設定を検証し、実行時に空の Step を拒否します。次の項目が公開契約です:

要件理由
Type() がフロー内で一意振り分け経路を一意にするため
Kind()ExternalStep.KindLabel 経由)にドット形式プリフィックス組み込み用にプリフィックス無しの名前を予約しているため
AMR() は RFC 8176 §2 の値、または ""範囲外の値は警告付きの監査イベントを出して除外
Prompts() は Begin / Continue が返し得る全画面種別を列挙起動時検証のため。漏れているとランタイムエラーになる
Begin と Continue は Prompt または Result を返す。空の Step は拒否されるオーケストレータの状態遷移の前提
既知 / 未知の識別子でレスポンス形状とタイミングを一致させるユーザ存在の漏えい対策の基本
呼び出しをまたいでステートレス試行ごとの状態は interaction.Prompt.StateRef に載せること。struct に持たない

テスト

認証器は通常の Go テストで検証します。Begin を呼び、interaction.FormSubmission{Values: ...}Continue に渡し、返された Prompt または Result を確認し、同じ手順を並行実行します。公開パッケージ op/testkit には HTTP 層の fixture 用 subject-binding 認証器がありますが、本番用の認証器ではありません。

go
func TestSMSAuthenticator(t *testing.T) {
    auth := &smsauth.SMSAuthenticator{Sender: sender, OTPStore: otps, UserStore: users, 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) }
}

この差し込み口が存在する理由

ライブラリの初期は Authenticator を直接公開していました。ステップアップフローを書く組み込み側は、認証器の Begin の中で「次にどの認証要素を実行するか」を毎回再実装することになり、オーケストレータの不変条件「1 認証要素 = 1 ステップ」と相性が悪い構造でした。これを次の 4 要素に分解したことで整理が付きました:

  • Step — どの認証要素かを示す記述子
  • Rule — どの条件下で起動するか
  • Decider — 早期確定のための上書き判断
  • Authenticator — 実際の認証フロー本体

オーケストレータが順序と重複排除を所有し、自前コードは認証要素の仕組みを所有できるようになりました。ExternalStep は、組み込み Step のいずれにも当てはまらない Authenticator を橋渡しします。

参考