ユースケース — 既存ユーザストアの投影
既に users、members、employees、accounts などのテーブルがあり、その形が OP 同梱の oidc_users テーブルと一致しない場合でも、そのテーブルを正本として使えます。OP に必要なのは、subject を解決し、許可された claim を返し、パスワードログインを使う場合は store.UserPasswordStore の契約に沿ってパスワードハッシュを読める投影です。
境界は OAuth レコードとエンドユーザレコードの間にあります。前者は本ライブラリが持ち、後者は組み込み側が持ちます。OP はこの境界をまたいで 3 つの読み取りメソッドを呼びます。
構成
この例はストレージを 2 つの責務に分けます。
| 責務 | バックエンド |
|---|---|
| OAuth / OIDC レコード: クライアント、認可コード、リフレッシュトークン、grant、セッション、PAR、IAT、RAT、アクセストークン | 同梱の op/storeadapter/sql schema |
| エンドユーザレコード: subject、email、name、locale、パスワードハッシュ、tenant メタデータ | 組み込み側が所有する members テーブル |
migration ツールで Store.Migrations() を適用するか、デプロイジョブから Store.Migrate(ctx) を 1 回呼んでください。oidc_users もほかのテーブルと一緒に作成されます。リリースごとの migration がこのテーブルを変更し、Store.ValidateSchema も確認するためです。このパターンでは空のままです。op.New がこのテーブルを確認するのは、アダプタ自身の Users()(または UserPasswords())を PrimaryPassword.Store、StepEmailOTP.Users、op.WithUserStore のいずれかに渡したときだけです。どれにも自前のユーザストアを渡していれば確認しません。members テーブルにカラムは増えません。
OP は /userinfo と ID トークンの claim を、次の表で最初に設定されているものから読みます。op.WithStore に渡した store.Store がこの取得元になることはなく、3 番目のフォールバックもありません。
| 順位 | claim の取得元 | ログの claim_source |
|---|---|---|
| 1 | op.WithUserStore | WithUserStore |
| 2 | LoginFlow.Primary が op.PrimaryPassword ならその Store。primary が op.StepOneOf なら、その中で Store が nil でない最初の op.PrimaryPassword の Store | LoginFlow.Primary PrimaryPassword.Store |
op.New はどれに決まったかを、op.WithLogger で設定したロガーに claim_source 付きの INFO ログ 1 行で出します(未設定なら slog.Default() に出ます)。どちらの取得元も設定されておらず、有効な grant がエンドユーザを解決しうる場合(client_credentials 以外のすべて)、op.New は起動を拒否します。そのため設定は 1 か所で足ります。パスワードを検証する投影が claim も供給し、SQL store をラップする必要はありません。
members := &MemberUserStore{db: db}
provider, err := op.New(
op.WithStore(durable),
op.WithLoginFlow(op.LoginFlow{
Primary: op.PrimaryPassword{Store: members},
}),
// required options...
)op.WithUserStore を足すのは、claim の置き場所がログインで認証するレコードとは別にある場合だけです。たとえば passkey のみのフロー、PrimaryPassword を持たない device-code や CIBA の構成、認証情報テーブルとは別のプロフィールサービスがある構成です。指定すると、推論されたストアより優先されます。同梱アダプタ自身のユーザテーブルから claim を読むには、明示的に op.WithUserStore(st.Users()) を渡します。
投影の契約
通常、ユーザストアアダプタは次を実装します。
| メソッド | 役割 |
|---|---|
FindBySubject(ctx, sub) | /userinfo とトークン組み立て用に、安定した OIDC subject と claim のマップを読む。 |
FindByUsername(ctx, username) | メールアドレスなどのログイン識別子を、同じ安定 subject に解決する。 |
ReadPasswordHash(ctx, subject) | op.PrimaryPassword 用に PHC 形式のパスワードハッシュを返す。未知ユーザやパスワードレスユーザでは store.ErrNotFound を返す。 |
store.UserStore に属するのは FindBySubject だけで、残り 2 つは組み込みパスワードステップが必要とする store.UserPasswordStore のメソッドです。パスキーのみの構成なら store.UserStore だけを実装すれば足ります。
PostgreSQL ではクエリのプレースホルダが $1、$2 になります。MySQL や SQLite で使う ? は使えません。
カラム名は自由です。この例では member_id、email_address、password_phc、full_name、locale_pref、tenant_id を store.User.Subject と store.User.Claims へ投影しています。
subject の選び方
store.User.Subject は sub クレームになり、RP は sub(と iss)でローカルのアカウントを識別します。OpenID Connect Core §2 は、別のユーザに再割り当てしないことを求めています。実際には変更もできません。sub が変われば、どの RP でも別アカウントとして扱われます。この前提で列を選んでください。
| 候補 | 使えるか |
|---|---|
| ユーザごとに一度だけ割り当て、再利用しない不透明な値(ランダムな UUID や hex) | 使えます。 |
| メールアドレス、ユーザ名 | 使えません。ユーザが変更すると、RP からは別人に見えます。 |
| auto-increment の行 ID | 使えません。RP からユーザ数と登録順が読めます。また、エンジンによっては最大の ID を持つ行を削除すると、その ID を再発行します。 |
テーブルにそうした列が無ければ、1 列追加して一度だけ値を埋めてください。OP が users テーブルに求める変更はこの列だけです。多くのエンジンは、行がすでにあるテーブルに UNIQUE 列を直接追加できません。NULL 許容で追加し、値を埋めてから一意にします。
-- PostgreSQL 14+
ALTER TABLE members ADD COLUMN subject text;
UPDATE members SET subject = gen_random_uuid()::text WHERE subject IS NULL;
ALTER TABLE members ALTER COLUMN subject SET NOT NULL;
CREATE UNIQUE INDEX members_subject ON members (subject);
-- MySQL 8.0
ALTER TABLE members ADD COLUMN subject VARCHAR(64) COLLATE utf8mb4_bin NULL;
UPDATE members SET subject = LOWER(HEX(RANDOM_BYTES(16))) WHERE subject IS NULL;
ALTER TABLE members MODIFY subject VARCHAR(64) COLLATE utf8mb4_bin NOT NULL,
ADD UNIQUE KEY members_subject (subject);
-- SQLite (UNIQUE / NOT NULL の列はその場で追加できない)
ALTER TABLE members ADD COLUMN subject TEXT;
UPDATE members SET subject = lower(hex(randomblob(16))) WHERE subject IS NULL;
CREATE UNIQUE INDEX members_subject ON members (subject);アプリ側の新規登録処理でも、新しい行ごとにこの列を設定する必要があります。crypto/rand で 16 バイトを生成し、hex にしてください。MySQL の UUID() は時刻とホストの MAC アドレスから作られるので、代わりにはなりません。
列を用意したら、FindBySubject はこの列で検索し、FindByUsername は引き続きログイン名で照合します。同じユーザをクライアント間で突き合わせられないようにしたい場合は、この列を持ったうえで pairwise subject を有効にします。
op.WithUserStore を指定するときは、意図した分離でない限り、op.PrimaryPassword に渡した値と同じものを渡してください。食い違いに気付かずに別々の値を渡すと何も失敗しません。ログインは成功し、一方のレコードから subject が解決され、ID トークンと /userinfo は別のレコードから組み立てられます。2 つの値が異なると op.New は警告をログに出します。
起動時のテーブル確認
op.New は起動時に SQL アダプタのテーブルを確認します。ユーザストアが store.SchemaValidator を実装していれば、同じ確認をユーザストアにも行います。
type SchemaValidator interface {
ValidateSchema(ctx context.Context) error
}ValidateSchema はカタログを読むか、行を返さないクエリを発行するだけにし、テーブルが合っていれば nil を返します。失敗は構築時のエラーにまとめて含まれるため、members テーブルやカラムが欠けていても、最初のログインではなく起動時に止まります。実装していないユーザストアは確認されません。examples/53-production-sql は SELECT ... FROM app_users WHERE 1 = 0 でこれを行っています。
レガシーなパスワードハッシュ
既存のテーブルに bcrypt のハッシュがある場合は、そのまま使えます。PrimaryPassword.LegacySchemes に []op.PasswordHashScheme{op.BcryptHashScheme()} を設定すると、OP はログイン時に bcrypt のハッシュを検証し、ストアが store.UserPasswordHashUpdater を実装していれば UpdatePasswordHash で argon2id に置き換えます。この書き戻しが、OP がユーザレコードに対して行う唯一の書き込みです。詳しくは レガシーなパスワードハッシュの移行 を参照してください。
ソース:
examples/53-production-sqlは、bcrypt ハッシュを持つ既存の users テーブルをop.PrimaryPassword{LegacySchemes: ...}の裏に置き、op.WithUserStoreを呼びません。
削除・無効化したユーザ
アプリケーション側でメンバーを削除または無効化したら、ルックアップでそれが分かるようにします。FindBySubject と FindByUsername から store.ErrNotFound を返してください。
- ログインは、そのユーザについて止まります。
- リフレッシュは
invalid_grantで失敗し、OP は grant を破棄します。そのためリフレッシュトークンのチェーンは二度と使えません。 - 発行済みのトークンは、失効させない限り有効期限まで使えます。アカウントを無効化するときは
Provider.RevokeSubject(ctx, subject)を呼んでください。その subject の全 grant(アクセストークンとリフレッシュトークンを含む)を失効させます。「即座に」には次の例外があります。- grant レコードを持たないトークン(custom grant のハンドラが発行したものと、jwt-bearer のアクセストークン)は対象外で、有効期限まで使えます。
op.RevocationStrategyNoneでは JWT アクセストークンの失効を確認しないため、expまで有効です。リフレッシュトークンは失効します。既定の戦略とRevocationStrategyJTIRegistryでは失効します。- JWT アクセストークンをイントロスペクションなしでローカル検証するリソースサーバは、失効を知る手段がなく、
expまでトークンを受け付けます。イントロスペクションを使うか、アクセストークンの有効期間を短くしてください。
ユーザの消去
RevokeSubject が失効させるのは grant とトークンだけです。OP が subject について保持するものをすべて消すには、Provider.ForgetSubject(ctx, subject) を呼びます。
| 項目 | ForgetSubject の動作 |
|---|---|
| 順序 | 先にユーザを削除し、OP が読むすべてのユーザストア(op.WithUserStore、PrimaryPassword.Store、StepEmailOTP.Users)がその subject に store.ErrNotFound を返す状態にしてから、ForgetSubject を呼びます。どれかがまだ subject を解決する間は configuration_error を返し、何も変更しません。並行して進むログインが、削除したセッションを作り直してしまうためです。 |
| 消すもの | すべての grant(同意の記録、アクセストークン、リフレッシュトークンを含む。最初に RevokeSubject を呼びます)。その subject のブラウザセッションすべて。ブラウザのアカウントチューザを共有する別 subject のセッションは残ります。lockout の記録。ログインフローが参照するストアにある TOTP の登録、リカバリコード、保留中のメール OTP チャレンジ、登録済みの passkey。ユーザストアへの書き込みは行いません。 |
| 届かないもの | custom grant のハンドラや jwt-bearer grant が発行したトークン、承認済みで未償還の device code と CIBA のリクエスト。これらは有効期限まで残ります。interactions、PAR、消費済み JTI は短命で、subject をキーに持ちません。監査シンク、Shared Signals のキュー、WithPreferredLocaleStore のデータ、ExternalStep の状態は組み込み側の持ち物なので、組み込み側で削除してください。Redis と DynamoDB では、subject のインデックスができる前に書かれ、その後 Touch されていないセッションが有効期限まで残ります。grant がなくなればトークンは発行できません。 |
| 通知 | RevokeSubject と同じく、RP へ Back-Channel Logout は送られず、監査イベントも出ません。op.WithSharedSignals を使う場合は、RevokeSubject の段階で、その subject の grant を持つ受信者ごとに CAEP の session-revoked イベントがキューに入ります。 |
| 失敗 | セッションストアか lockout ストアが store.SessionSubjectDeleter または store.AuthnLockoutDeleter を実装していないと、足りないインターフェースをすべて挙げた configuration_error を返し、何も変更しません(Provider のメソッドが要求する拡張を参照)。前提を満たした後は、途中の段階が失敗しても残りを実行し、失敗は 1 つの server_error にまとめて返ります。すでに無いレコードは削除済みとして扱うため、呼び直せば残りが完了し、成功後に呼び直すと nil を返します。 |
ユーザ行を残すアプリケーション(法的保全や、FindBySubject が見ない論理削除フラグなど)では、subject が解決される間は拒否されるため、ForgetSubject は使えません。代わりに RevokeSubject と、factor ストア自身の Delete を呼びます。store.TOTPStore、store.RecoveryStore、store.EmailOTPStore は Delete(ctx, subject)、store.PasskeyStore は ListBySubject の後に credential ごとの Delete(ctx, credentialID) です。lockout の記録は、lockout ストア(store.AuthnLockoutDeleter)の Delete(ctx, subject) で消せます。
アプリケーション側の再認証
パスワード変更や二要素目の登録を行うアカウント管理画面では、変更前にユーザを再認証してください。保存済みハッシュを ReadPasswordHash で読み、checkPassword で検証します。アプリケーション側で PHC 形式を parse しないでください。op.VerifyPassword は有効な PHC Argon2id ハッシュを受け付けます。パスワードが違う場合、レコードが壊れている場合、検証器の上限外のパラメータの場合は、いずれも false を返します。
hash, err := members.ReadPasswordHash(ctx, subject)
if err != nil || !checkPassword(hash, submittedPassword) {
// 未知、壊れたハッシュ、誤ったパスワードで同じ汎用応答を返す。
return ErrReauthenticationFailed
}op.VerifyPassword が受け付けるのは argon2id だけです。テーブルに、ログインでまだ書き換えられていない bcrypt のハッシュが残っている場合(レガシーなパスワードハッシュ を参照)は、bcrypt パスワードの移行 にあるように、保存されたハッシュの接頭辞で検証器を選ぶ checkPassword にします。argon2id のハッシュだけなら、op.VerifyPassword(hash, submittedPassword) を直接呼べます。
op.VerifyPassword が行うのは比較だけです。OP の総当たり対策は OP の認証フローにだけ適用され、組み込み側が所有するパスワード変更や factor 登録の endpoint には適用されません。呼び出し側でレート制限またはロックアウトを実装してください。
claim の開示
store.User.Claims に値を入れても、それだけで全 RP に出るわけではありません。OP は scope と claims request によるフィルタを引き続き適用します。この例は member 行から独自の tenant claim を読みますが、それを許可する scope がないため demo RP には返りません。
標準 claim は OpenID Connect Core §5.1 が定める JSON の型で返してください。email_verified と phone_number_verified は真偽値、address はオブジェクトで、それ以外は文字列です。変換は FindBySubject の中で行います。よくあるのは、DB の真偽値をドライバが整数として読み出す場合です。store.StandardClaims で map を組み立てれば、型は構造上正しくなります。各フィールドが対応する Go の型を持ち、未設定のフィールドは省かれ、email_verified は email があるときだけ出ます。独自 claim は、返された map に足します。
sub と updated_at は map に含めません。sub は store.User.Subject です。updated_at は store.User.UpdatedAt から取られるので、行が最後に更新された時刻を設定してください。profile scope が許可されたとき、OP が Unix 秒として開示します。UpdatedAt がゼロ値なら claim は出ず、Claims["updated_at"] に自分で数値を入れた場合はそちらが優先されます。
claims := store.StandardClaims{
Name: fullName.String,
Email: email.String,
EmailVerified: emailVerified == 1, // TINYINT のカラム
}.Map()
claims["tenant"] = tenantID型が違う標準 claim は ID トークンと /userinfo から外され、op.WithLogger で設定したロガーに、claim 名と Go の型を示す WARN が claim 名ごとに 1 回出ます。独自 claim はそのまま開示されます。
アプリケーション固有 claim を scope 経由で出す場合は Public / Internal スコープ、RP が細かく claim を選ぶ場合は Claims リクエスト を参照してください。
composite が必要なケース
このパターンはエンドユーザ claim の読み取り元だけを差し替えます。トランザクションが必要な OAuth レコード群は 1 つの SQL アダプタに残るため、storeadapter/composite は不要です。WithUserStore なら adapter の任意 capability を隠してしまうラッパも不要です。
複数のサブストアを別バックエンドに振り分けたい場合、たとえば永続化が必要な grants / refresh tokens は SQL に置き、interactions / sessions は Redis に置く場合は Hot / Cold 分離 を使います。
実行
(cd examples/24-byo-userstore && GOWORK=off go run -tags example .)この例は OP を :8080、ペアの RP を :9090 で起動します。[email protected] / demo でログインすると、RP の /me ページで開示された ID トークン claim を確認できます。
続きはこちら
- SQL ストア — OIDC レコードに使う同梱 SQL アダプタ。
- MFA / ステップアップ — 組み込みパスワード、TOTP、captcha、ステップアップの接続。
- カスタム認証器 — SMS OTP やハードウェアトークンなど新しい認証要素を足す。