Skip to content

ユースケース — 永続化(SQL) ​

OP は何を保存し、保存先はなぜ重要か ​

OP が保持する行のうち、OAuth / OIDC 仕様が 再起動越しに保持 することを要求するもの:

  • リフレッシュトークンチェーン(RFC 6749 §6, RFC 9700 §4.14)— 失えば全ユーザのセッションが切れる。
  • 登録クライアント(DCR が ON なら OIDC Dynamic Client Registration 1.0 / RFC 7591、OFF なら静的シード)— 失えば全 RP が動かなくなる。
  • セッション(OIDC RP-Initiated Logout 1.0)— ブラウザのログイン状態と RP-Initiated Logout の状態を再起動後も保持する。
  • 同意グラント(OIDC Core 1.0 §3.1.2.4)— 失えば再起動のたびに全ユーザに再同意を強いることになる。
  • 監査 / introspection / revocation の shadow 行 — トークン で説明したアクセストークンのレジストリ。

既定の inmem ストアは再起動で全てを失う点で、テスト・デモには十分ですが本番には不向きです。ライブラリは op/storeadapter/sql を同梱しており、database/sql アダプタで SQLite / MySQL 8.0+ / PostgreSQL 14+ を対象にします。CI のテストは MySQL 8.4 と PostgreSQL 16 に対して実行しています。MariaDB 10.5 以降は MySQL dialect で動作する見込みですが、CI のテストの対象外です。

ソース:

  • examples/06-sql-store — SQLite クイックスタート(CGO 不要)。
  • examples/07-mysql-store — 本番形プールを持つ MySQL。OP と in-process RP を組み合わせ、docker-compose スタックとして同梱。
  • examples/53-production-sql — 本番の構成を 1 か所にまとめた例。起動時のスキーマ検証、鍵ファイル、SIGHUP でのリロード、定期 GC、静的クライアントの同期、既存の users テーブルを扱います。

users テーブルがすでにある場合

アダプタのテーブルが保持するのはプロトコルのレコードだけです。ユーザは今の場所に置いたまま、その投影を OP に渡してください。配線は 既存ユーザストアの投影 にあります。その場合、下のコードで storage.UserPasswords() を渡している箇所に、その投影を渡します。

なぜサブモジュール ​

SQL アダプタは 別 Go モジュール として公開されているので、明示的に追加するまで driver 依存(SQL driver、migration ライブラリ)は go.sum に混入しません:

sh
go get github.com/libraz/go-oidc-provider/op/storeadapter/sql@latest

Redis アダプタと DynamoDB アダプタも、同じ理由で別モジュールです。

アーキテクチャ ​

アダプタ 1 つ、サブストアごとにテーブル 1 つ
OP は Store インタフェース越しに SQL ストアアダプタを呼び、アダプタが各サブストアを、既に運用しているデータベース内の専用テーブルへ永続化します。op.ProviderSQL について何も知らないStore インタフェース経由storeadapter/sqlSQL を話すのはここだけサブストアごとにテーブル 1 つ自社の SQL データベースoidc_* tables既に運用中のスキーマ
テーブル名に oidc_ の接頭辞を付けているのは、既存のスキーマにそのまま同居させても衝突しないようにするためです。その上の層はスキーマの存在を知りません。

サブストアはそれぞれ 1 つのテーブルに対応し、全部で 25 個です。組み込み側がやることは多くありません。Store.Migrations() のファイルをすべて migration ツールで適用し(既存 DB で最初のリクエストを受ける前にを参照)、構成で使わないテーブルは空のままにしておきます。自分のテーブルに加える変更は、users テーブルに不透明な subject 列が無い場合にそれを足すことだけです。自前のユーザストアを使う場合、oidc_users は作られますが空のままで、同期も要りません。

op.New と ValidateSchema が確認するもの
  • op.New は構築時に、構成が使うテーブルだけを確認し、足りないものを 1 つのエラーにまとめて挙げます。空のデータベースに対して実行すれば、構成に必要なテーブルがそのエラーに一覧されます。
  • 既定の構成で必要なのは 13 個です。そのうち 9 個は、失効処理やトークン検査が構成にかかわらず参照するため、どの構成でも確認され、空のままのこともあります。
  • oidc_users を要求するのは、アダプタ自身の Users()(または UserPasswords())を PrimaryPassword.Store、StepEmailOTP.Users、op.WithUserStore のいずれかに渡したときだけです。op.WithAuthnLockoutStore で別のストアを指定していれば、oidc_authn_lockouts も要求しません。
  • デプロイ時のゲートである Store.ValidateSchema はすべてのテーブルを検証し、リリースごとの migration は任意のテーブルにも ALTER を行います。
  • 欠けているテーブルとカラムは 1 つのエラーに 1 行ずつすべて並び、欠けたカラムの行には、それを追加する migration ファイル名が付きます。
  • schema/<dialect>/v1.sql は同じ現行の完全なスキーマを 1 ファイルにしたもので、参照とレビュー用です。

schema/TABLES.md はテーブルごとのリファレンスです。冒頭でやることと最小構成に必要なテーブルを挙げ、続いて各テーブルの WithNaming のキー、保持する内容、op.New が確認する条件、行が現れる条件、カラムを載せています。アダプタのソースから生成されており、モジュールに含まれるコピーは、実行しているリリースと一致します。subject 列を足す SQL は subject の選び方 にあります。

失効の書き込みは grant と同じトランザクションに入る

oidc_grant_revocations と oidc_revoked_jtis は、既定の RevocationStrategyGrantTombstone を支えます。oidc_opaque_access_tokens に書き込まれるのは、opaque 形式のアクセストークンを設定したときだけです。どちらのグループも、起点となる grant やリフレッシュの書き込みと同じトランザクションでコミットされるため、カスケードが途中で切れて「失効した grant の隣に、まだ引き換え可能なトークンが残る」状況にはなりません。

自前の Store を実装する場合に、どのサブストアを nil にできるかは別の規則です。ストアバックエンドを自前実装する を参照してください。

コード ​

go
import (
  "context"
  databasesql "database/sql"
  _ "modernc.org/sqlite" // または MySQL / Postgres driver

  "github.com/libraz/go-oidc-provider/op"
  oidcsql "github.com/libraz/go-oidc-provider/op/storeadapter/sql"
)

db, err := databasesql.Open("sqlite", "file:op.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil { /* ... */ }

storage, err := oidcsql.New(db, oidcsql.SQLite()) // または oidcsql.MySQL() / oidcsql.Postgres()
if err != nil { /* ... */ }

// deploy job から 1 回実行する。または storage.Migrations() を migration ツールで適用する(後述)。
if err := storage.Migrate(context.Background()); err != nil {
  /* ... */
}

provider, err := op.New(
  op.WithIssuer("https://op.example.com"),
  op.WithStore(storage),
  op.WithKeyset(myKeyset),
  op.WithCookieKeys(myCookieKey),
  op.WithLoginFlow(op.LoginFlow{
    Primary: op.PrimaryPassword{Store: storage.UserPasswords()}, // oidc_users のユーザ。自前の投影でもよい
  }),
)

上の Migrate は Store.Migrations() のファイルを適用し、バージョンテーブルに記録します(Migrate による適用を参照)。自前の migration ツールがある場合は、同じファイルをそのツールで適用してください。どちらの場合も、プロセスの起動時に op.New がスキーマを確認します。設定が使わないテーブルも含めてすべてを検証したいデプロイゲートや CI では、あわせて Store.ValidateSchema(v1.5 以降)を実行してください。

現行の完全な DDL をレビューするには、op/storeadapter/sql/schema/<dialect>/v1.sql(sqlite、mysql、postgres)を読みます。WithTablePrefix や WithNaming でテーブル名を変えている場合は storage.Schema() を使います。スキーマの確認はカタログを読むだけで、作成・変更・削除は一切行いません。そのため OP が接続する DB ロールに CREATE 権限は要りません。テーブルが無ければ oidcsql: schema for <table> is missing、参照 DDL が定義するカラムが無ければ ... is missing required columns: <names> で失敗します。後のリリースで追加されたカラムなら、続けて ; apply migration <file> が付きます。組み込み側が足したカラムは許容され、カラムの型は確認しません。テーブルが無い場合は、作り方を示す 1 行が最後に付き、確認した範囲(PostgreSQL では接続の search_path のスキーマ、MySQL では既定のデータベース)も示されます。テーブル名を変えている場合、この行は Store.Schema() を案内します。同梱の v1.sql が作るのは既定名のテーブルだからです。migration を飛ばしても、負荷のかかった状態でクエリエラーとして現れるのではなく、起動時に止まります。欠落は 1 つのエラーに 1 行ずつすべて並ぶので、1 回の実行で残りの migration 全体が分かります。

MySQL の DSN には既定のデータベースを含めてください。含めないと、すべてのテーブルが欠けているように見えます。

Migrate による適用 ​

Store.Migrate(ctx) は Store.Migrations() のファイルを順に適用し、進捗をバージョンテーブル <prefix>schema_migrations(既定では oidc_schema_migrations。WithNaming の schema_migrations キーで変更できます)に記録します。テーブルの構造は golang-migrate と同じです。そのため、後で golang-migrate に移る場合は x-migrations-table=oidc_schema_migrations を指定すれば、migrate force なしで続きから適用できます。すでに最新の DB に対しては何も変更せず、最後に ValidateSchema を実行します。

呼び出しは各レプリカの起動時ではなく、デプロイジョブから 1 回だけにしてください。渡す context は DDL に十分な長さにします。MySQL では 1 つの migration がすべてのテーブルを変更します。同時に呼ばれた場合は、golang-migrate と同じ導出の advisory lock で直列化されるので、Migrate と golang-migrate も互いに排他されます。pgbouncer 経由では、transaction pooling だと advisory lock が保持されないため、session pooling か直接接続を使ってください。

  • テーブルはあるがバージョンテーブルがない場合。 アダプタのテーブルが現行リリースと一致していれば、Migrate はそのリリースを記録するだけで、ほかは変更しません。一致しなければ何も変更せず、エラーに、スキーマが今いるリリースを記録する INSERT 文を示します。先に適用すべき migration が分かる場合は、その名前を挙げます。
  • アダプタと同じ名前の自前テーブル。 Migrate が作成するテーブルがすでに存在し、アダプタのカラムを持っていない場合(たとえば接頭辞を共有するアプリケーションのテーブル)、Migrate は文を 1 つも実行せずに拒否します。WithTablePrefix か WithNaming でアダプタのテーブル名を変えてから、再実行してください。
  • dirty なバージョン行。 MySQL の DDL はロールバックできないため、ファイルの途中で文が失敗するとバージョン行が dirty のまま残り、Migrate は止まります。エラーが示すファイルのどの文まで反映されたかを確認し、手で完了させるか元に戻してから、エラーにある UPDATE 文(または migrate force N)を実行し、Migrate を呼び直します。PostgreSQL と SQLite はファイルごとに 1 トランザクションで適用するため、失敗しても DB は元のままです。
  • MySQL で Migrate を使うのに multiStatements=true は不要です。文を 1 つずつ実行します。
  • PostgreSQL では、バージョンテーブルと lock は接続の search_path の先頭のスキーマに置かれます。OP のテーブルもそのスキーマに置いてください。
  • WithTablePrefix("") では Migrate が拒否されます。バージョンテーブルが golang-migrate の既定である schema_migrations になり、アプリケーション自身の履歴と衝突しやすいためです。その場合は Migrations() をツールで適用してください。

goose、Flyway、Atlas は、従来どおり自前の履歴テーブルで Migrations() を適用します。Migrate はそれらを読み書きしません。Schema() は現行の完全な DDL を文字列で返すので、レビューに使えます。スキーマファイルは op/storeadapter/sql/schema/ に embed されています。

既存 DB で最初のリクエストを受ける前に ​

SQL は op/storeadapter/sql/schema/migrations/<dialect>/ にあり、リリースごとに golang-migrate 形式で 1 ファイルです。000001_v1.0.0.up.sql が v1.0.0 のスキーマを作り、以降の 000006_v1.5.0.up.sql までが 1 リリース分の変更を適用します。各リリースの内容は schema/MIGRATIONS.md にあります。Store.Migrations()(v1.5 以降)は、アクティブな dialect のファイルを、WithTablePrefix / WithNaming のテーブル名を適用した fs.FS として返します。

go
src, err := iofs.New(storage.Migrations(), ".")
if err != nil { /* ... */ }
// x-migrations-table で、OP のバージョン履歴をアプリケーションのものと分けます。
databaseURL := "postgres://user:pass@db/app?x-migrations-table=oidc_schema_migrations"
m, err := migrate.NewWithSourceInstance("iofs", src, databaseURL)
if err != nil { /* ... */ }
err = m.Up()

OP のファイルとアプリケーション自身の migration は、どちらも 1 から採番されます。両方を 1 つのテーブルに記録するツールでは、後から適用する側が適用済み、または順序違反に見えてしまいます。OP には専用のバージョンテーブルを用意してください(ファイルをディスクから読むツールでは、専用のディレクトリも用意します)。

  • golang-migrate: 上の x-migrations-table URL パラメータを使います。WithInstance でドライバを組み立てる場合は Config.MigrationsTable です。migrate force N はそのテーブルだけを対象にするので、アプリケーションの履歴には触れません。
  • goose: OP のファイルを専用のディレクトリに置き、goose -table oidc_goose_db_version ... で実行します。
  • Flyway: OP のファイルには、別の table と別の locations を使います。

Atlas を含む詳細は schema/MIGRATIONS.md にあります。

  • 既存の DB は、最後に適用したリリースをまず記録し、それより後のファイルだけを適用します。golang-migrate では、v1.3.0 で作成した DB は OP のバージョンテーブルに対して migrate force 4 を実行してから migrate up で v1.4.0 以降を適用します。
  • Migrate で作成した DB にはバージョンテーブルがあるので、oidc_schema_migrations を指した golang-migrate は、migrate force なしで記録済みのバージョンから続きを適用します。
  • v1.sql や Schema() で作成した DB は最新のスキーマを持ちますが、バージョンの記録がありません。Migrate を呼べば記録されます。自分で記録する場合は、最新の migration(migrate force N。N は番号が最大のファイル。OP 専用のバージョンテーブルに対して実行します)として記録してください。記録しないと、ツールが最初のファイルから適用し直します。
  • MySQL では各ファイルが複数の statement を含みます。mysql:// URL なら golang-migrate が複数文の実行を自動で有効にし、mysql.WithInstance に渡す *sql.DB ではその DSN に multiStatements=true が必要です。Migrate 自体には不要です。
  • Atlas は、atlas migrate hash で atlas.sum を作成したあと、format=golang-migrate でファイルを読みます。
  • goose と Flyway はディスク上のファイルを読むため、op-schema export(下記)で書き出します。goose のファイルは先頭が -- +goose Up、Flyway のファイルは V<N>__vX_Y_Z.sql の名前になります。
  • その他のツールは、ファイルを番号順に適用します。down ファイルはありません。

op-schema export は、データベースに接続せずに同じファイルを golang-migrate、goose、Flyway のいずれかの配置で書き出します。渡したテーブルの接頭辞と命名も反映されます。

sh
go run github.com/libraz/go-oidc-provider/op/storeadapter/sql/cmd/op-schema export \
  --dialect postgres --format goose --out db/oidc \
  [--prefix auth_] [--naming clients=auth_clients]...

--dialect は mysql、postgres、sqlite のいずれかです。--format は golang-migrate(既定)、goose、flyway から選びます。--prefix には WithTablePrefix の値を渡し、既定は oidc_ です。--naming key=name は WithNaming の 1 エントリで、複数回指定できます。アダプタが拒否する名前を渡すとコマンドは失敗します。

PostgreSQL で public 以外のスキーマを使う場合、アダプタは接続の search_path でテーブルを解決し、スキーマ確認もクエリと同じ search_path をたどります。DSN か接続の search_path に OP のスキーマを含めてください(例: search_path=oidc)。WithTablePrefix や WithNaming に渡す名前は単なる識別子で、スキーマ修飾はしません。インデックス名にはテーブル名が含まれるため、長い接頭辞や名前でインデックス名が識別子の長さの上限を超えると、構築時に失敗します。

カラムが欠けている場合、スキーマの確認は適用すべきファイルを示します。リファレンス DDL のインデックスが無いテーブルでも失敗します。照合はインデックス名ではなく先頭カラムで行います。migration 000006_v1.5.0 は、clients テーブルに previous_secret_hash、previous_secret_expires_at と 5 つの tls_client_auth_* カラムを追加し、ssf_streams と ssf_events のテーブルを作成し、sessions テーブルの subject に idx_oidc_sessions_subject を追加します。subject のセッションを削除するときにテーブル全体を走査しないためで、インデックスが無ければ op.New がその名前を示します。既存の MySQL / MariaDB が自力では取り込めないのは、このインデックスです。CREATE TABLE の中で宣言されているため、既存テーブルに加わるのは migration ファイルを適用したときだけで、適用するのは Migrate かお使いのツールです。MySQL / MariaDB の識別子カラムに必要な MODIFY ... COLLATE utf8mb4_bin の文も、同じファイルに含まれます。MySQL の既定の照合順序は大文字小文字とアクセント記号を区別しないため、適用しないと PutUserWithPassword("Alice", ...) が既存の alice の行を上書きし、GetClient("MYAPP") が myapp に解決されてしまいます。識別子カラムの照合順序が utf8mb4_bin でも utf8mb4_0900_bin でもなければ確認は失敗し、カラムとファイルを示します。既存の値が binary 照合順序で衝突すると MODIFY は失敗し、整理すべき行がエラーに示されます。SQLite と PostgreSQL はもともとバイト単位で比較します。

既存 DB の更新を v1.sql の実行に任せないでください。更新するのは Migrate か migration ファイルです。

カラムの性質から、ロールアウトの規則は 2 つあります。

  • MFA テーブル。 oidc_totp_secrets と oidc_email_otps に、旧 writer と現行 writer を同時に向けないでください。旧 writer は更新から row_version を外し、compare-and-swap の手順を迂回します。旧 writer を止め、カラムを追加して検証してから、現行 writer だけを起動します。
  • 認可ごとの状態と失効のカラム。 カラムが存在すれば、旧 writer と現行 writer を並行して動かせます。旧バイナリは authorization_recorded を 0 のままにし、現行バイナリはその行を grant から解決するためです。差が残るのは 1 点だけです。旧ノードは access_revoked_at を読まないので、全ノードが現行バイナリになるまでは、リフレッシュトークン 1 つの失効で grant が失効した JWT アクセストークンが、旧ノードでは検証を通ります。

ロールアウトの全体の順序は ストレージの保守 にまとめています。

テーブル名を差し替える ​

同梱テーブルの名前は、既定では oidc_clients、oidc_refresh_tokens のように oidc_ 接頭辞が付きます。既に clients テーブルを持つ DB に OP を組み込みたい場合や、社内規約が oidc_ 接頭辞を許さない場合は、oidcsql.WithTablePrefix と oidcsql.WithNaming で OP 内部レコードの物理テーブル名を差し替えられます。アダプタは物理名を SQL 標準の識別子文法で検証し、同梱 DDL を書き換え、すべてのクエリを差し替え後のテーブルに対して組み立てます。そのため Schema() / Migrate() と実行時クエリは常に一致します。Store.Migrations() と op-schema export も、同じ名前をマイグレーションファイルに反映します。

すべてのテーブルの oidc_ 接頭辞をまとめて置き換えるには、oidcsql.WithTablePrefix を使います。

go
storage, err := oidcsql.New(db, oidcsql.Postgres(), oidcsql.WithTablePrefix("auth_"))
// oidc_clients は auth_clients に、oidc_refresh_tokens は auth_refresh_tokens になります

個別のテーブルだけを差し替えるには WithNaming を渡します。列挙した種別については、接頭辞付きの既定名より WithNaming の指定が優先されます。論理テーブル 2 つが同じ物理名になる指定は構築時に拒否され、比較では大文字小文字を区別しないため、Auth_Clients と auth_clients も衝突します。

go
storage, err := oidcsql.New(db, oidcsql.Postgres(), oidcsql.WithNaming(map[string]string{
  "clients":             "auth_clients",
  "refresh_tokens":      "auth_refresh_tokens",
  "authorization_codes": "auth_codes",
  // ...必要なだけ差し替え可能。指定しない種別は oidc_ 既定のまま。
}))

map のキーは物理名ではなく論理的なレコード種別です。oidcsql.New が受け付けるのは次の 25 個です。

区分論理キー
クライアントと grantclients、grants、authorization_codes、refresh_tokens
アクセストークンaccess_tokens、opaque_access_tokens
失効grant_revocations、revoked_jtis
ブラウザ側の状態sessions、interactions
PAR と再利用検出マーカpar_records、consumed_jtis
ユーザusers
動的登録initial_access_tokens、registration_access_tokens
OP メタデータop_metadata
デバイスフローと CIBAdevice_codes、ciba_requests
認証器の factortotp_secrets、passkeys、recovery_codes、email_otps、authn_lockouts
Shared Signalsssf_streams、ssf_events

WithNaming は、Store.Migrate が記録するバージョンテーブルを指す schema_migrations キーも受け付けます。未知のキーを渡すと oidcsql.New が即座にエラーを返すので、タイポは最初のクエリではなく構築時に検出できます。

解決後の物理テーブル名はすべて相互に異なる必要があります。2 つの論理ストアが同じテーブルへ map される場合や、override が未指定の既定テーブル名と衝突する場合、oidcsql.New は構築時に失敗します。スキーマの書き換えは、同梱 DDL にある既定の oidc_ テーブル名を、長い名前から順に部分文字列として 1 回の走査で置き換えます。テーブル名を含むインデックス名(idx_oidc_access_tokens_expires_at など)が、名前を変えたテーブルに追従するのはこのためです。

ソース: examples/25-byo-table-names は 25 テーブルすべてを auth_ 接頭辞へ差し替え、sqlite_master から読み戻して書き換えが効いたことを確認しています。

差し替えられるのはテーブル名のみ、カラム名は不可

WithTablePrefix と WithNaming が書き換えるのはテーブル名だけです。カラム構成はアダプタが所有しており固定です。カラム名まで自由にしたい場合(reshape できない既存スキーマ、暗号化カラム、共有テーブルなど)は、同梱アダプタを使わず store インターフェースを自分で実装してください。ストアバックエンドを自前実装する を参照。

MySQL プールサイズ ​

examples/07-mysql-store は本番形の DSN を示します:

go
db, err := databasesql.Open("mysql",
  "oidc:secret@tcp(mysql:3306)/op?parseTime=true&charset=utf8mb4&loc=UTC")
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)

アダプタはプールを調整しないので、上限は組み込み側で決めます。

charset=utf8mb4 は維持してください。4 バイト UTF-8(絵文字、CJK 拡張)を claim 値で切り詰めずに往復させるためです。

DSN の connection collation は、カラムに明示された utf8mb4_bin の定義を置き換えません。既存の MySQL / MariaDB では、上の migration の節の MODIFY 文を別途適用してください。

ユーザ名 + パスワードによる認証情報 ​

SQL アダプタは store.UserPasswordStore(inmem リファレンスアダプタと同じインターフェース)を実装するので、組み込みの op.PrimaryPassword Step を SQL バックエンドに対してそのまま組み込めます。glue コードは不要です:

go
flow := op.LoginFlow{
  Primary: op.PrimaryPassword{Store: storage.UserPasswords()},
}

provider, err := op.New(
  /* ... */
  op.WithLoginFlow(flow),
)

スキーマは oidc_users に 2 つのカラムを追加します。ユーザ名検索用の一意インデックス(FindByUsername が利用)と、PHC 形式のハッシュを保持する password_hash カラム(ReadPasswordHash が読み出す)です。

ハッシュ符号化は組み込み側の責務です — 補助の writer *sql.Store.PutUserWithPassword(ctx, user, username, hash) は op.HashPassword(argon2id、ライブラリ既定値)が返したハッシュを受け取り、PutUser と同じ upsert を経由します:

go
hash, _ := op.HashPassword("demo")
_ = storage.PutUserWithPassword(ctx, &store.User{
  Subject: "demo-user",
  Claims:  map[string]any{"name": "Demo User"},
}, "demo", hash)

ユーザ名を空文字、ハッシュを nil にして渡すと認証情報を消去できます — passkey 専用に移行したユーザを扱うときに便利です。

ReadPasswordHash は subject が未知の場合と、行は存在するが password を持たない場合の両方で store.ErrNotFound を返すので、LoginFlow 側はどちらの場合も user enumeration 攻撃に対して安全な応答を返せます。

oidc_users はアダプタ自身の users テーブルで、ユーザが別の場所にある場合は空のままです。op.WithStore に渡した store.Store がユーザの参照元になることはなく、このアダプタのテーブルが使われるのは、Users() を op.WithUserStore に渡した場合(passkey のみなど PrimaryPassword のないフロー)か、UserPasswords() を op.PrimaryPassword に渡した場合だけです。op.PrimaryPassword{Store: ...} に自前の store.UserPasswordStore を渡せば、claim もそのストアから読まれ、同梱のテーブルには書き込まれません。自前のユーザストア を参照してください。

session の耐久性 ​

SQL アダプタの session ストアは、セッションが再起動後も残ることを報告します。そのため op.WithSessionDurabilityPosture を指定しなくても、op.New は session 耐久性の方針(バックチャネルログアウトの bcl.no_sessions_for_subject 監査イベントに付くラベル)を durable と判断します。SQLite の :memory: は durable ではないため、開発用に限ってください。推論を上書きしたい場合は、方針を明示的に宣言します。

コントラクトテストのハーネス ​

inmem を検査する同じ contract test suite (op/store/contract) が、SQL アダプタに対しても go test -tags=testcontainers で testcontainers-go 経由の実 MySQL / Postgres を起動して実行されます。「SQL アダプタは Store interface を実装する」というライブラリの主張は、モックではなく実エンジンに対して検証されたものです。

固定しているイメージ(mysql:8.4、postgres:16-alpine)は、examples/07-mysql-store および examples/09-redis-volatile の docker-compose スタックが使うエンジンマトリクスと揃えてあるので、アダプタレベルと example レベルの統合検証で同じ組み合わせを共有できます。

retention GC をスケジュールする ​

SQL アダプタは、期限切れの行をすべてリクエスト経路で削除するわけではありません。アプリケーションの cron、leader election した worker、または別の保守スケジューラから (*oidcsql.Store).GC(ctx, cutoff) を呼び出します。

go
cutoff := time.Now().UTC().Add(-15 * time.Minute) // 短い調査用猶予を残す
stats, err := storage.GC(ctx, cutoff)
if err != nil {
  return err
}
log.Printf("oidc GC removed %d rows", stats.Total())

GC を 1 回呼ぶと 9 つのテーブルを sweep し、oidcsql.GCStats がそれぞれの削除件数を返します。対象は、期限切れの認可コード、PAR、interaction、session、refresh token ローテーション履歴、アクセストークンのレジストリの row、opaque access token、grant revocation(tombstone と失効済み jti の denylist)、consumed jti のマーカー(ConsumedJTIs)です。実行する replica は 1 つで足ります。複数の replica で並行して sweep しても、DELETE は冪等で互いの作業を繰り返すだけなので安全です。HTTP ルートの背後には置かないでください。sweep は 1 テーブルの期限切れ row を 1 文で削除するため、backlog が大きい場合の初回 sweep はオフピークに実行してください。refresh sweep は grant 配下の refresh token がすべて有効期限を過ぎるまで履歴を保持し(grant ごとの anti-join なので、大きなテーブルでは最も重い処理です)、row 自体を残す場合でも predecessor 自身の期限を過ぎた sealed retry-response blob を消します。blob の消去は row 件数に加算されません。過去の cutoff は猶予期間を残し、time.Now() はその時点ですでに期限切れのものを回収します。アダプタはバックグラウンド GC goroutine も timer も起動しないため、呼び出しのスケジュールと監視は組み込み側が担当します。自前の goroutine でループさせるなら、storage.RunGC(ctx, time.Hour, report)(v1.5 以降)が即座に 1 回 sweep し、その後は ctx が終わるまで interval ごとに sweep して、各 sweep の GCStats とエラーを report に渡します。GCStats、所要時間、エラー、各テーブルの残量を記録し、sweep の停止を検知できるようにしてください。access token、opaque token、grant revocation の各サブストアは、単独のテーブルを別のスケジュールで回収したい場合のために独自の GC メソッドを持ち続けています。device code と CIBA の row は insert 経路で期限切れが排除されます。それ以外のテーブルは sweep の対象外です。email OTP のコードは subject ごとに上書きされ、Shared Signals のイベントは同機能の worker が削除し、クライアント、ユーザ、認証要素のレコード、ロックアウトのカウンタ、登録トークンは、各 store 自身の削除で消えます。

いつ Redis を載せるか ​

Hot データ(interaction、およびキャッシュ消失後の再ログインを許容するなら session)は生成と陳腐化のサイクルが速く、永続 DB に載せるとテーブルが肥大化します。次ページの Hot/Cold 分離 で、永続サブストアを SQL に保ったまま揮発サブストアを Redis にルーティングする方法を示します。