Skip to content

カスタム authenticator

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

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

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

やりたいことこのページ?
TOTP factor を追加不要 — op.StepTOTP で足ります(MFA / ステップアップ
passkey factor を追加不要 — 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 が成功したときに、セッションを引き上げる assurance level。
    // オーケストレータは完了済み factor を跨いで maximum を取り、
    // セッションの AAL を導出する。
    AAL() AAL

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

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

    // フローを開始する。返した interaction.Step は、Prompt(複数ステップ
    // の factor)か Result(即完結する 1 ステップの factor。稀)の
    // いずれかを載せる。
    Begin(ctx context.Context, in BeginInput) (interaction.Step, error)

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

実装は並行安全であってください。オーケストレータは複数の goroutine の間で dispatch します。

実装例: SMS OTP

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

1. Authenticator を実装する

go
package smsauth

import (
    "context"
    "crypto/rand"
    "fmt"

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

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

func (a *SMSAuthenticator) Type() FactorType { return "myorg.sms_otp" }
func (a *SMSAuthenticator) AAL() op.AAL      { return op.AALMedium }
func (a *SMSAuthenticator) AMR() string      { return "sms" } // RFC 8176 §2

func (a *SMSAuthenticator) Prompts() []string {
    return []string{"myorg.sms.collect_phone", "myorg.sms.collect_code"}
}

func (a *SMSAuthenticator) Begin(ctx context.Context, in op.BeginInput) (interaction.Step, error) {
    // 最初のプロンプト: 電話番号を集める。
    return interaction.Step{
        Prompt: &interaction.Prompt{
            Type:     "myorg.sms.collect_phone",
            StateRef: in.StateRef, // 試行ごとの state を引き継ぐ
        },
    }, nil
}

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

        // subject が空でもコードは常に dispatch する(情報漏えい対策)。
        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:     "myorg.sms.collect_code",
                StateRef: in.StateRef,
            },
        }, nil

    case "myorg.sms.collect_code":
        submitted := in.Submission["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,
            },
        }, nil
    }
    return interaction.Step{}, fmt.Errorf("unexpected prompt: %q", in.Prompt.Type)
}

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 でプロンプトを描画する

WithSPAUI 構成下では、SPA は /interaction/{uid} でプロンプトを JSON として受け取ります:

json
{
  "prompt": {
    "type": "myorg.sms.collect_phone",
    "state_ref": "..."
  }
}

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

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

契約条件

オーケストレータが強制する項目です。違反すると LoginFlow のコンパイル時、または最初のリクエストでエラーになります:

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

テスト

ストア実装には op/store/contract の contract suite、authenticator にはオーケストレータ側の harness を使います:

go
// 擬似コード — 実パスはソースリポジトリの harness を参照。
import "github.com/libraz/go-oidc-provider/internal/authn/test"

func TestSMSAuthenticator(t *testing.T) {
    auth := &smsauth.SMSAuthenticator{
        Sender:    fakeSender{},
        OTPStore:  inmemOTPs(),
        UserStore: testStore,
        CodeTTL:   5 * time.Minute,
    }
    test.AuthenticatorContract(t, auth)
}

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

ライブラリの初期は Authenticator を直接公開していました。step-up フローを書く組み込み側は、authenticator の Begin の中で「次にどの factor を走らせるか」を再実装することになり、これはオーケストレータの不変条件「1 factor = 1 ステップ」に反していました。次の 4 要素に分解したことで、整理が付きました:

  • Step — descriptor(どの factor か)
  • Rule — いつ走るか
  • Decider — short-circuit override
  • Authenticator — 実際のフロー

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

参考