Skip to content

ユースケース — ストアバックエンドを自前実装する

同梱の SQL アダプタWithNaming でテーブル名を差し替えられますが、カラム構成はアダプタが所有します。カラム名まで自由にしたい場合(reshape できない既存スキーマ、暗号化カラム、他システムと共有するテーブル、あるいは SQL ですらないバックエンド)は、store のサブストアインターフェースを自分で実装し、その集約を op.WithStore に渡します。本ライブラリは物理名を一切観測しません。コードが行をマップする先の store.* Go 構造体だけを見ます。

この経路は、SQL アダプタでは永続化境界を表せない場合だけ選んでください。自由度は最大ですが、bearer secret のハッシュ化、sentinel エラー、並行性、トランザクションをすべて自分で守る必要があります。既定カラムで足りるなら SQL アダプタを使い、ユーザ検索だけが独自なら user store だけを差し替える方が単純です。

ソース: examples/26-byo-store-from-scratch — 手書きの vault_* スキーマを持つ SQLite 上に store.Store を完全実装し、CI で実際のブラウザログイン往復を通して検証しています。

何を実装するか

store.Store は小さなサブストアインターフェースの集約です(各インターフェースは 1 レコード種別を所有し、メソッドは 1〜5 個)。認可コードフローの OP では次のサブストアを nil 以外で実装します。

サブストアインターフェースメソッド
クライアントstore.ClientStoreGetClient(動的登録をサポートしない限り ClientRegistry は不要)
認可コードstore.AuthorizationCodeStoreSave / Find / Consume
リフレッシュトークンstore.RefreshTokenStoreSave / Find / Consume / RevokeChain / RevokeByGrant
grantstore.GrantStoreSave / Find / FindBySubjectClient / ListBySubject / Delete / HasAny
セッションstore.SessionStoreSave / Find / Touch / Delete / ListByChooserGroup
PARstore.PushedAuthRequestStoreSave / Find / Consume
インタラクションstore.InteractionStoreSave / Find / Delete
消費済み JTIstore.ConsumedJTIStoreMark / Has
ユーザstore.UserPasswordStoreFindBySubject / FindByUsername / ReadPasswordHash
アクセストークンstore.AccessTokenRegistryRegister / Find / RevokeByJTI / RevokeByGrant / GC
メタデータstore.MetadataStoreGet / Set

残りのサブストアのアクセサは、対応する機能を有効にしない限り nil を返してかまいません — OpaqueAccessTokensInitialAccessTokensRegistrationAccessTokensDeviceCodesCIBARequestsGrantRevocations です。本ライブラリは op.Newnil を検出し、それを必要とするオプションを後から panic させるのではなく構築時に拒否します。GrantRevocations を省くには、あわせて op.WithAccessTokenRevocationStrategy(op.RevocationStrategyNone) を指定する必要があります(非 FAPI デプロイ専用)。既定の grant-tombstone 戦略は構築時にこのサブストアを必須とします。

カラム名は自由

example は、すべてのテーブルとカラムに意図的に非 OIDC 的な名前を付けてこの点を証明しています。本ライブラリはどれも気にしません。

ストアのレコードexample のテーブルexample のカラム
クライアントvault_relying_partiesrelying_party、リダイレクト / scope のメタデータ
ユーザvault_principalsprincipal(subject)、login_namesecret_phc
認可コードvault_grant_codescode_digestprincipalrelying_partyrequested_scopeissued_epochexpires_epochconsumed_epoch
リフレッシュトークンvault_renewal_slipstoken_secret_digestledger_idis_void
grantvault_consent_ledgerledger_idgranted_scope
PARvault_pushed_handleshandle_digest
セッションvault_browser_seatsseat_idchooser_band
アクセストークンvault_wire_tokensjti、ledger_idis_revoked

principal が subject、relying_party が client id、ledger_id が grant id です。物理スキーマを store.* 構造体へマップするのは、サブストア実装だけです。

守るべき 3 つの契約

サブストアの godoc が規範です。コンパイルが通っても、これらを無視するバックエンドはインターフェースを満たしていません。

  1. 保存前ハッシュ(hash-on-store)。 AuthorizationCode.IDRefreshToken.IDPushedAuthRequest.URI は opaque な bearer secret であり、所持しているだけで引き換えられます。提示された値を保存前にハッシュし(SHA-256、できればサーバ側 pepper で HMAC 化)、ダイジェストのみを保存し、Find / Consume では提示値をハッシュしてダイジェストを引き、constant-time で比較します。example は自己完結のため pepper なしの SHA-256 を使い、in-memory リファレンスと同じ方針にしています。本番バックエンドは pepper を加えるべきです。
  2. sentinel エラー。 store.ErrNotFoundstore.ErrAlreadyExistsstore.ErrAlreadyConsumedstore.ErrConflictstore.ErrTxRequired を、メソッドの godoc が定める箇所で正確に返します(sql.ErrNoRowsErrNotFound、2 回目の ConsumeErrAlreadyConsumed)。呼び出し側は errors.Is でこれらを判別します。列挙された失敗モードに別のエラーを返すと、コンパイルが通っても契約違反です。
  3. 原子性。 認可コードの引き換え、リフレッシュトークンのローテーション、PAR の消費は、いずれも複数のレコード種別にまたがります。本ライブラリは各サブストアの Save / Consume がそれ自体で原子的であることに依拠します。トランザクションクラスタをホストするバックエンドは、複数サブストアの書き込みが 1 つの下層トランザクションを共有するよう store.Transactional も実装するべきです。example は同梱アダプタと同じ方式で実装しています — サブストアは *sql.DB*sql.Tx の両方が満たす小さな querier インターフェースを受け取り、BeginTx がクラスタのサブストアを 1 つの *sql.Tx にバインドして返します。

どの方式が合うか

やりたいこと採用する方式
既定のテーブルで、永続化だけしたいSQL アダプタ
テーブル名は独自、カラムは既定でよいSQL アダプタ + WithNaming
既存の users テーブルを残し、OIDC レコードは既定でよいユーザストアを自前実装する
テーブル名もカラム名もすべて独自にしたい、または非 SQL バックエンドこのページ

動かす

sh
(cd examples/26-byo-store-from-scratch && go run -tags example .)

example は OP を :8080、ペアの RP を :9090 で起動します。demo@example.test / demo でサインインすると、RP の /me ページに払い出された ID Token の claim が表示されます。すべて vault_* スキーマから提供されています。

次に読む