Skip to content

ユースケース — 既存ユーザストアの投影 ​

既に users、members、employees、accounts などのテーブルがあり、その形が OP 同梱の oidc_users テーブルと一致しない場合でも、そのテーブルを正本として使えます。OP に必要なのは、subject を解決し、許可された claim を返し、パスワードログインを使う場合は store.UserPasswordStore の契約に沿ってパスワードハッシュを読める投影です。

境界は OAuth レコードとエンドユーザレコードの間にあります。前者は本ライブラリが持ち、後者は組み込み側が持ちます。OP はこの境界をまたいで 3 つの読み取りメソッドを呼びます。

プロトコルのレコードはこちら、人のデータは向こう
本ライブラリは OAuth / OIDC のレコードを自身のストアで持ち、組み込み側はエンドユーザの行を今ある場所のまま持ちます。OP が境界をまたぐのは 3 つの読み取り呼び出しだけです。プロトコルのレコードはライブラリが持つclients · codes · grants · sessionsrefresh/access token · PAR · IAT · RATエンドユーザの行は組み込み側が持つmembers: member_id · email_addresspassword_phc · full_name · locale_pref境界を越える 3 つの読み取りPrimaryPassword.StoreFindBySubjectsubject の member 行を読む — ID トークンと /userinfo の claim の出どころFindByUsernameログイン識別子を subject に変換する — ここでは email_address と照合するReadPasswordHashop.PrimaryPassword が検証できるよう password_phc を返す
3 つとも読み取りです。ライブラリが member の行を作ることも、作り出すこともありません。スキーマの形を指定することもなく、必要なのはこの 3 つの問いに答えられることだけです。書き込みは、レガシーなパスワードハッシュを任意で再ハッシュするときの 1 回だけです(レガシーなパスワードハッシュを参照)。

ソース: examples/24-byo-userstore

構成 ​

この例はストレージを 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
1op.WithUserStoreWithUserStore
2LoginFlow.Primary が op.PrimaryPassword ならその Store。primary が op.StepOneOf なら、その中で Store が nil でない最初の op.PrimaryPassword の StoreLoginFlow.Primary PrimaryPassword.Store

op.New はどれに決まったかを、op.WithLogger で設定したロガーに claim_source 付きの INFO ログ 1 行で出します(未設定なら slog.Default() に出ます)。どちらの取得元も設定されておらず、有効な grant がエンドユーザを解決しうる場合(client_credentials 以外のすべて)、op.New は起動を拒否します。そのため設定は 1 か所で足ります。パスワードを検証する投影が claim も供給し、SQL store をラップする必要はありません。

go
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 許容で追加し、値を埋めてから一意にします。

sql
-- 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 を実装していれば、同じ確認をユーザストアにも行います。

go
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 を返します。

go
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"] に自分で数値を入れた場合はそちらが優先されます。

go
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 分離 を使います。

実行 ​

sh
(cd examples/24-byo-userstore && GOWORK=off go run -tags example .)

この例は OP を :8080、ペアの RP を :9090 で起動します。[email protected] / demo でログインすると、RP の /me ページで開示された ID トークン claim を確認できます。

続きはこちら ​