カスタム認証器
ライブラリは、パスワード、パスキー、TOTP、メール OTP、captcha、recovery code の組み込み Step を同梱しています。それ以外の認証要素(SMS、ハードウェアトークン、magic link、独自のデバイス信頼など)を追加するには、op.Authenticator インターフェースを実装し、op.ExternalStep で LoginFlow に差し込みます。
認証器は、1 つの認証要素を担当する小さなステートマシンです。OP が Begin を呼ぶと、認証器は prompt を返します。ユーザが送信すると OP が Continue を呼び、認証器は次の prompt か結果を返します。認証器が HTML を描画することも、セッションに触れることもありません。
このページが必要なケース
| やりたいこと | このページ? |
|---|---|
| TOTP を追加 | 不要。op.StepTOTP で足ります(MFA / ステップアップ) |
| パスキーを追加 | 不要。op.PrimaryPasskey で足ります |
| SMS OTP を追加 | 必要 |
| ハードウェアトークン(YubiKey OTP、HOTP など)を追加 | 必要 |
| magic link ログインを追加 | 必要 |
| 独自のリスク判定を追加 | 不要。op.RuleRisk と自前の RiskAssessor で対応します |
| 資格情報以外の追加画面(利用規約、KYC など)を追加 | 不要。op.Interaction を実装し op.WithInteractions で登録します(オプション一覧) |
2 つの境界は次のとおりです。資格情報を集めて subject を確定するものは Authenticator、subject の確定後に画面を出すだけのものは Interaction です。
interface
// Declared in op/authn; package op re-exports it as op.Authenticator.
package authn
type Authenticator interface {
// この authenticator が実装する FactorType を返す。
// 同一フロー内で 2 つの authenticator が同じ Type を持つことは禁止。
Type() FactorType
// Continue が成功したときに、セッションを引き上げる保証レベル。
// オーケストレータは完了済みの認証要素の最大値を取る。
AAL() AAL
// amr claim に寄与する RFC 8176 §2 の登録値。
// "" なら、この認証要素は amr に寄与しない。
AMR() string
// この authenticator が返し得るすべての 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)
}実装は並行安全でなければなりません。BeginInput には、確定済みの subject(最初の認証要素では空)、client ID、基準時刻 AuthTime、要求された scope、読み取り専用の client view が入ります。ContinueInput には、interaction.FormSubmission、同じ AuthTime、確定済みの subject、直前のステップが返した不透明な Scratch、リクエスト元の RemoteIP が入ります。時刻は time.Now() ではなく AuthTime から読んでください。そうすれば、チェーン全体が Provider の clock と食い違いません。
Prompt.StateRef は、Begin または Continue の戻り値に対してオーケストレータが設定します。認証器が state reference を生成したり保存したりしてはいけません。試行ごとの状態は interaction.Step.Scratch に入れてください。OP がサーバ側に保持し、次の ContinueInput.Scratch で返します。どのドライバも、Scratch をブラウザに送りません。
状態遷移の全体像
Authenticator は、次の prompt か、認証要素の完了を表す結果のどちらかを返します。SMS OTP のような 2 段階の認証要素では、Begin が最初の prompt を返し、送信のたびに Continue が次の prompt か最終結果を返します。
Continue が Result を返すと、オーケストレータは subject、AAL、AMR の値をチェーンに記録し、LoginFlow は次の Rule へ進みます。
実装例: SMS OTP
実装する認証要素は、電話番号を尋ね、6 桁のコードを SMS で送り、ユーザが入力したコードを検証するものです。依存する 3 つは自前で用意する interface で、ライブラリは同梱していません。
1. 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 は登録済みの電話番号を subject に対応付ける。
type PhoneDirectory interface {
SubjectByPhone(ctx context.Context, phone string) (subject string, ok bool)
}
// OTPStore は subject ごとに有効なコードを 1 つ(ハッシュで)保持し、失敗を数える。
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 は SMS プロバイダ経由でコードを届ける。
type SMSSender interface {
Send(ctx context.Context, phone, code string) error
}
type SMSAuthenticator struct {
Phones PhoneDirectory
OTPs OTPStore
Sender SMSSender
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,
Label: "myorg.sms.phone", // i18n キー。バンドルに無ければそのまま表示される
Required: true,
MaxLen: 32,
}},
},
Scratch: []byte(phonePrompt),
}, nil
}
func (a *SMSAuthenticator) Continue(ctx context.Context, in op.ContinueInput) (interaction.Step, error) {
// Scratch は "<stage>\n<subject>"。未登録の番号では subject が空になる。
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
}
}
// 登録済みでも未登録でも同じ prompt を返すので、
// どの番号が登録されているかは応答から分からない。
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 // もう一度尋ねる。試行回数の上限は Verify が管理する
}
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
}このコードのうち、認証要素の安全性を支えているのは次の 3 点です。
- subject は、フォームではなく
Scratchで運びます。 ユーザが入力するのはコードだけで、Verifyはその subject に発行したコードと照合します。subject なしでコードを引くと、別のユーザのコードに一致してしまいかねません。 - 登録済みの番号でも未登録の番号でも、見た目は同じにします。 実運用ではタイミングも揃えます。たとえば SMS の送信をキューに渡し、未登録の分岐でも同じ処理を走らせます。
- 誤ったコードはサーバ障害ではなく、拒否された資格情報として扱います。
BeginやContinueが返すエラーは sentinel で分類されます。op.ErrFactorRetryを wrap したエラーは失敗として記録され(ログインコンテキスト、監査、observer)、同じ prompt がerror.codecredentials_rejected付きで再提示されます。op.ErrFactorAbortを wrap したエラーは、ユーザに起因する失敗として試行を 4xx で終わらせます。それ以外のエラーはサーバ障害として HTTP 500 で終わります。nil のエラーで prompt を返し直した場合は失敗として記録されません。この例がOTPs.Verifyで推測を数えているのはそのためです。
2. 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", // ドット形式のプリフィックスが必須
}),
},
}
op.New(
/* 必須オプション */
op.WithLoginFlow(flow),
)KindLabel(myorg.sms_otp)のドット形式プリフィックスは必須で、プリフィックスなしの名前や組み込みと同じ名前は op.New が拒否します。プリフィックスには組織の識別子を使ってください。同じ Kind を持てる Rule は 1 つだけなので、同じ種類の ExternalStep を 2 回並べることはできません。
この例のように別の認証要素の後ろで動かす場合、Result に入れる Subject は、確定済みの subject と同じか、空でなければなりません。別の subject を返すと、その試行は HTTP 400 で終わります。そうしないと、最初の認証要素が持つ保証レベルを、別のユーザの ID のまま引き継いでしまうためです。
この例のように Primary より後に置いた ExternalStep は、CountFailures: true を設定すると、組み込みの第 2 要素が共有する認証要素横断のロックアウトカウンタに加わります。op.ErrFactorRetry 1 回が失敗 1 回として数えられます。Primary では subject がまだ確定していないため、op.New はこの設定を拒否し、リモートの verifier 自身のロックアウトが適用されます。設定しない場合、SMS コードのレート制限は OTPStore の側で実装してください。
op.StepOneOf の中の ExternalStep は、それを完了できる subject にだけ提示されます。authenticator に op.EnrollmentChecker を実装すると、登録済みかどうかを報告できます。実装しない場合、そのステップはすべての subject について登録済みとして扱われます。
3. UI に prompt を描画する
既定の HTML ドライバなら、追加の作業はありません。未知の prompt 種別は、Inputs から汎用のフォームとして描画されます。ページタイトルは Prompt.Type と同じ名前のメッセージバンドルのキー、各フィールドのラベルは FieldSpec.Label が指すキーから引かれ、バンドルに無ければキーがそのまま表示されます。文字列は op.WithLocaleBundle で登録します(i18n)。
JSON ドライバでは、SPA が /interaction/{uid} で prompt を受け取ります(op.SPAUI では LoginMount/state/{uid})。レスポンスは Prompt そのものです。SPA は state_ref を送信にそのまま入れて返し、フィールドの値は values に入れます。FieldSpec のメンバー名は snake_case で、kind はフィールド種別の名前です。
{
"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"
}OP にロケール resolver があれば、各入力は label_text(label を prompt の locale で解決した値)も持ちます。認証情報の拒否後に再表示される prompt は、code とローカライズ済みの message を持つ error を含みます。SPA は電話番号の入力欄を描画し、X-CSRF-Token ヘッダ付きで { "state_ref": "...", "values": { "phone": "+1..." } } を同じ URL に POST します。次のレスポンスは myorg.sms.collect_code の prompt なので、{ "state_ref": "...", "values": { "code": "123456" } } を POST します。コードが一致すると、OP は次の Rule か同意画面に進み、最後に終端のレスポンスを返します。この終端のエンベロープは SPA / 対話画面のカスタマイズ で説明しています。
登録ステップ (prompt=create)
RP は prompt=create(OpenID Connect Prompt Create 1.0)を送ることで、ログインではなくアカウント作成から始めるよう求められます。OP はユーザーテーブルを持たないため、アカウントを作るのは OP ではなくあなたの Authenticator です。LoginFlow.Registration を設定すると、OP は Primary の代わりにそのステップを実行します。
flow := op.LoginFlow{
Primary: op.PrimaryPassword{Store: myStore.UserPasswords()},
Registration: op.ExternalStep{
Authenticator: &signup.Authenticator{Users: directory},
KindLabel: "myorg.signup", // ドット区切りの接頭辞が必須
},
}
op.New(
/* 必須オプション */
op.WithLoginFlow(flow),
)ルールは次のとおりです。
Registrationはop.ExternalStepに限ります。組み込みステップとop.StepOneOfはop.Newが拒否します。CountFailures: true(まだ subject が bind されていないため)と、フロー内の他の場所で使われているKindLabelも同様に拒否されます。- ステップが動くのは、リクエストに
prompt=createが含まれるときだけです。通常のログイン中に、ルールや decider が選ぶことはできません。 - すでにセッションがあるユーザーでも実行されます。この試行はサインイン済みのユーザー、そのユーザーの認証コンテキスト、そのユーザーの同意のいずれも引き継ぎません。返した
Result.Subjectが新しい subject になり、同意はその subject に対して最初から求められます。 - ステップが subject を bind したあとは、
Primaryの完了後と同じ流れで、ルール、同意、認可コードの順に進みます。auth_timeはこの試行の時刻で、amrは Authenticator のAMR()から決まります。 - discovery の
prompt_values_supportedにcreateが含まれるのは、Registrationを設定したときだけです。 Registrationが未設定のとき、prompt=createはinvalid_requestで拒否されます。prompt=createを他の値(none、login、consent、select_account)と併記するとinvalid_requestで拒否されます。PAR やリクエストオブジェクト経由で渡された場合も同じです。
Prompt のフィールドと上限
送信するフィールドは、すべて Prompt.Inputs に宣言します。PromptData は sealed interface なので、アプリケーションが interaction パッケージの外から独自の data 型を追加することはできません。カスタムのフォーム項目には FieldSpec を使ってください。secret は Prompt.StateRef にも、ドライバが公開し得る Scratch の値にも入れてはいけません。カスタム prompt の種別には組織のプリフィックス(myorg.…)が必要です。ライブラリの auth.*、consent.*、captcha、interaction.* と衝突させないためです。
FieldKind は FieldText、FieldPassword、FieldOTPCode、FieldEmail、FieldHidden のいずれかです。Required、MinLen、MaxLen、Pattern は Continue の前に検証されます。Pattern は値全体に一致する必要があります。MaxLen が 0 のフィールドには、種別ごとの byte 上限が適用されます。
| 種別 | MaxLen 未指定時の上限(byte) |
|---|---|
FieldText | 512 |
FieldPassword | 1024 |
FieldOTPCode | 32 |
FieldEmail | 320 |
FieldHidden | 16 KiB |
1 回の submission 全体にも、名前と値の合計 32 KiB、宣言した項目を超える追加項目 4 個までという上限があります。Prompt の StateRef は 10 分で失効し、1 回しか使えません。古い reference や再利用された 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() がフロー内で一意 | 振り分け経路を一意にするため |
ExternalStep.KindLabel にドット形式のプリフィックスがあり、1 つの Rule にしか現れない | プリフィックスなしの名前は組み込み用に予約されており、Rule ごとに識別子を 1 つにするため |
AMR() は RFC 8176 §2 の値、または "" | 範囲外の値は警告付きの監査イベントを出して除外されます |
Prompts() は Begin と Continue が返し得る prompt 種別をすべて列挙 | 起動時検証のため。漏れているとランタイムエラーになります |
Begin と Continue は Prompt または Result を返す。空の Step は拒否される | オーケストレータの状態遷移の前提です |
返す Result.Subject は空か、確定済みの subject と同じ | 別の subject を返すと試行が終了します |
| 既知と未知の識別子で、レスポンスの形とタイミングを一致させる | ユーザ列挙の対策です |
| 呼び出しをまたいでステートレス | 試行ごとの状態は構造体ではなく Scratch に持ちます |
テスト
認証器は通常の Go テストで検証します。Begin を呼び、interaction.FormSubmission{Values: ...} を Continue に渡して、返された Prompt または Result を確認し、同じ手順を並行実行します。公開パッケージ op/testkit には、HTTP 層のフィクスチャ用に subject を確定させる認証器がありますが、本番用ではありません。
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)
}
}役割の分担
ログインの判断は 4 つの部品に分かれ、それぞれ別の型が担当します。
| 型 | 決めること |
|---|---|
Step | どの認証要素か(op.StepTOTP や op.ExternalStep のような記述子) |
Rule | その認証要素をいつ実行するか |
Decider | Rule の前に、許可・拒否・特定のステップの要求を決める方針上書き |
Authenticator | 認証要素の手順そのもの |
順序と重複排除はオーケストレータが担当し、認証要素の仕組みは自前のコードが担当します。ExternalStep は、組み込みの Step がない Authenticator を橋渡しします。
参考
- アーキテクチャ概観 § LoginFlow の内部: オーケストレータが認証器に対して何をするか。
- MFA / ステップアップ: 組み込みの Step を
Ruleで組み合わせるパターン。 - 監査イベントカタログ § ログイン / MFA / ステップアップ: 認証器の成功と失敗で何が発火するか。