Skip to content

既存のデータベースに組み込む ​

users テーブルとデータベースを持つアプリケーションを、OpenID Provider としてユーザをサインインさせる側にします。OP は自身のレコード(クライアント、コード、トークン、セッション)を、自分で作るテーブルに保存し、ユーザは 3 つのメソッドで読み取ります。既存の users テーブルを移したり置き換えたりすることはありません。

1. ユーザごとに subject を用意する ​

ユーザごとに、一度だけ割り当てて再利用しない不透明な識別子が必要です。これが sub claim になり、どの RP もこの値でローカルのアカウントを識別します。メールアドレス、ユーザ名、自動採番の ID は使えません。

subject の列は、次の条件で確認してください。

  • 255 バイト以内。OP 自身の subject 列は VARCHAR(255) です。
  • バイト単位で、大文字小文字を区別して比較される。OP の列は MySQL で utf8mb4_bin を使うため、Alice と alice は別の subject です。
  • テーブル内で一意。
  • 別の人に再利用せず、付け替えもしない。

テーブルにそうした列が無ければ、1 列追加して一度だけ値を埋めてください。OP が users テーブルに求める変更はこの列だけです。PostgreSQL、MySQL、SQLite 向けの SQL は subject の選び方 にあります。

OP がユーザをどこから読むかは、渡すものによって決まります。

渡すものOP が読むもの使う場面
op.PrimaryPassword{Store: ...}ログインと claim の両方を、そのストアからusers テーブルにパスワードがある(通常はこちら)
op.WithUserStore(...)claim をそのストアからclaim が別の場所にある、またはパスワードでサインインしない
SQL アダプタ自身の Users()(テーブル oidc_users)ログインと claim を OP のテーブルから自前の users テーブルがない

2. ユーザストアを書く ​

users テーブルを読む 3 つのメソッドを持つ型を書きます。未知のユーザには、いずれも store.ErrNotFound を返します。

go
func (u users) FindBySubject(ctx context.Context, sub string) (*store.User, error)
func (u users) FindByUsername(ctx context.Context, login string) (*store.User, error)
func (u users) ReadPasswordHash(ctx context.Context, sub string) ([]byte, error) // PHC argon2id、または保存済みのレガシーハッシュそのまま

FindBySubject はユーザの claim を読み、FindByUsername はログイン識別子を subject に変換し、ReadPasswordHash は保存済みのハッシュを返します。スコープが開示する claim は、アクセストークンを発行する場合の OpenID Connect Core §5.4 の規定どおり /userinfo から返ります。ID トークンに載るのは op.WithIDTokenScopeClaims に挙げたスコープの claim だけで、たとえば op.WithIDTokenScopeClaims("profile", "email") と指定します。PostgreSQL ではクエリのプレースホルダが ? ではなく $1、$2 になります。契約の詳細と claim の型は 既存ユーザストアの投影 を参照してください。

bcrypt のハッシュ。 OP が標準で検証できるのは argon2id です。users テーブルに bcrypt のハッシュがある場合は、ステップ 4 で PrimaryPassword.LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()} を設定し、ReadPasswordHash は各ハッシュを保存されたまま返します。この scheme を設定しないと、$2a$、$2b$、$2y$ のハッシュを検証できる仕組みが OP になく、該当ユーザは全員サインインに失敗します。認識できないハッシュを最初に見つけたとき、プロセスごとに 1 回だけ WARN ログを出します。ユーザもハッシュも含まれないので、op.WithLogger で渡したロガーを確認してください。scheme を設定し、ストアが store.UserPasswordHashUpdater も実装していれば、各ハッシュは所有者が次にサインインに成功したとき argon2id に書き換えられます。この書き戻しは bcrypt パスワードの移行 で説明しています。同じ列でパスワードを検証している別のアプリケーションが残っている間は、UserPasswordHashUpdater を実装しないでください。書き戻された argon2id のハッシュを、そのアプリケーションは検証できません。

3. OP の migration を適用する ​

OP のテーブルは SQL アダプタが管理します。既存の接続でアダプタを開き、すでに使っている migration ツールで migration を適用します。

go
db, err := sql.Open("mysql", dsn) // 既存のデータベース
if err != nil {
    log.Fatal(err)
}
st, err := oidcsql.New(db, oidcsql.MySQL()) // op/storeadapter/sql
if err != nil {
    log.Fatal(err)
}
// st.Migrations() を migration ツールで適用するか、
// deploy job から err = st.Migrate(ctx) を 1 回実行する。

golang-migrate では、st.Migrations() を iofs ソースに渡し、OP 専用のバージョンテーブルを指定します。github.com/golang-migrate/migrate/v4/database/mysql を blank import すると、mysql:// ドライバが登録されます。

go
src, err := iofs.New(st.Migrations(), ".")
if err != nil {
    log.Fatal(err)
}
m, err := migrate.NewWithSourceInstance("iofs", src,
    "mysql://user:pass@tcp(db:3306)/app?x-migrations-table=oidc_schema_migrations")
if err != nil {
    log.Fatal(err)
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
    log.Fatal(err)
}

OP のファイルとアプリケーション自身の migration は、どちらも 1 番から採番されます。既定の schema_migrations テーブルを共有すると、OP の migration が適用済みとみなされて飛ばされるため、別のテーブルを指定します。各ファイルは複数の文を含みます。上のような mysql:// URL では golang-migrate が複数文の実行を自動で有効にします。既存の *sql.DB を mysql.WithInstance に渡す場合は、その DSN に multiStatements=true が必要です。Store.Migrate には不要です。PostgreSQL は 永続化(SQL) を参照してください。

goose と Flyway は fs.FS ではなくディスク上のファイルを読みます。op-schema は、データベースに接続せずに、同じファイルを各ツールの命名とヘッダで書き出します。

sh
go run github.com/libraz/go-oidc-provider/op/storeadapter/sql/cmd/op-schema \
    export --dialect mysql --prefix oidc_ --format goose --out db/oidc

--dialect は mysql、postgres、sqlite のいずれかです。--format は golang-migrate、goose、flyway のいずれかです。--prefix には oidcsql.WithTablePrefix に渡す値を指定し(既定は oidc_)、--out のディレクトリは無ければ作られます。--naming key=name(繰り返し可)は oidcsql.WithNaming と同じテーブル名の書き換えを適用します(例: --naming users=app_oidc_users)。生成されるファイルは、ストアが発行するクエリのテーブル名と一致します。OP のファイルは、アプリケーション側とは別のディレクトリ・別のバージョンテーブルで管理します。ツールごとの設定は MIGRATIONS.md にあります。

Store.Migrations() は、golang-migrate の命名規則に沿った migration ファイルを fs.FS として返します。構成で使わないテーブルのものも含め、すべて適用してください。有効にしていない機能のテーブルは空のまま残るので、スキーマレビューでは OP が書き込む数より多くの oidc_* テーブルが見えます。構成が実際に読むテーブルは TABLES.md の該当節 にあります。migration ツールがない場合は、Store.Migrate(ctx) がファイルを順に適用し、進捗を oidc_schema_migrations テーブルに記録します。各レプリカの起動時ではなく deploy job から 1 回だけ、DDL に十分な長さの context で呼んでください。作成される oidc_users テーブルは、自前のユーザーストアがユーザーを返している間は空のままで、同期は要りません。適用順は 永続化(SQL)、テーブルの一覧は TABLES.md にあります。

期限切れの認可コード・トークン・セッションはリクエスト処理の中では削除されないため、retention sweep を動かすまでこれらのテーブルは増え続けます。プロバイダと並べて sweep を起動してください。1 つのレプリカで足り、複数のレプリカで重なって動いても安全なので、リーダー選出は要りません。

go
go func() {
    if err := st.RunGC(ctx, time.Hour, nil); err != nil { // すぐに 1 回、以後 1 時間ごと
        log.Print(err)
    }
}()

自前のスケジューラから sweep を呼ぶ方法は retention GC をスケジュールする にあります。

4. プロバイダを構築する ​

鍵をファイルから読み、ストアとユーザストアと一緒に op.New へ渡します。

go
keys, err := op.LoadKeyset("signing.pem") // PEM の秘密鍵
if err != nil {
    log.Fatal(err)
}
cookieKeys, err := op.LoadSymmetricKeys("cookie.key") // 32 バイト、AES-256-GCM
if err != nil {
    log.Fatal(err)
}

handler, err := op.New(
    op.WithIssuer("https://idp.example.com"),
    op.WithStore(st),
    op.WithKeyset(keys),
    op.WithCookieKeys(cookieKeys...),
    op.WithLogger(slog.Default()), // 未指定だと、リクエスト時の警告は捨てられる
    op.WithLoginFlow(op.LoginFlow{
        Primary: op.PrimaryPassword{
            Store: users{db},
            // テーブルに bcrypt のハッシュがある場合はコメントを外す:
            // LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()},
        },
    }),
)
if err != nil {
    log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", handler))

op.WithLogger は外さないでください。外すと、保存されたパスワードハッシュが argon2id でないときに一度だけ出る「PrimaryPassword.LegacySchemes を設定してください」という警告など、リクエスト時の警告が破棄されます。

鍵は一度だけ生成します。

sh
openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out signing.pem
openssl rand -hex 32 > cookie.key

PrimaryPassword のストアが ID トークンと /userinfo の claim も供給するので、op.WithUserStore は不要です。op.New が起動を拒否する条件は 必須オプション、既存の users テーブルを使って動かせる版は 53-production-sql にあります。

サインイン画面。 op.WithUI を指定しなければ、OP 組み込みの HTML のログイン画面と同意画面(interaction.HTMLDriver)が使われます。ログインと同意を自前の SPA に任せるなら op.WithUI(op.SPAUI{...})(SPA / 対話画面のカスタマイズ)、自前のサーバサイド描画のページにするなら独自の interaction.Driver を op.DriverUI{Driver: ...} に渡します。op.HTMLUI{Consent: ...} が差し替えるのは同意画面のテンプレートだけで、ログイン画面は組み込みのままです(カスタム同意 UI)。

既存のサーバに載せる。 上の ListenAndServe は OP を単体で動かす形です。すでにルータを持つアプリケーションでは、http.StripPrefix で包まず、そのルータのルートに自分のルートと並べて handler を登録します。

go
mux.Handle("/", handler) // 自分のルートはこの隣にそのまま登録しておける

エンドポイントは /oidc 配下に置かれ(op.WithMountPrefix で移動できます)、discovery ドキュメントは issuer 直下の /.well-known/openid-configuration のままです。リバースプロキシの後ろでは、OP がクライアントのアドレスとスキームを正しく得られるよう、プロキシの CIDR を op.WithTrustedProxies に渡します。chi や gin、パス付きの issuer は ルータへのマウント を参照してください。

アプリケーションのホストに置かれる cookie。 OP は issuer のホストに 5 つの cookie を設定します。どれも HttpOnly、Secure、Path=/ で、__Host- プレフィックスが付きます。そのため、mount prefix にかかわらず、ブラウザはアプリケーション自身のページを含むそのホストのすべてのルートに送ります。Domain は付かないので、兄弟サブドメインには届きません。

cookie有効期間内容
__Host-oidc_sessionアイドル 14 日までサインイン済みのブラウザセッション(暗号化)
__Host-oidc_interaction1 時間進行中のログインまたは同意(暗号化)
__Host-oidc_csrfブラウザセッションOP のフォームの double-submit トークン
__Host-oidc_logout_csrf5 分RP 起点ログアウトを確認するトークン
__Host-oidc_locale1 年ユーザが OP の画面で選んだ言語

アプリケーション自身のセッション cookie はそのまま使えます。これらは置き換えるものではないので、ミドルウェアで __Host-oidc_ の名前を削除しないでください。

5. RP を登録する ​

OP は登録済みのクライアントからのリクエストしか受け付けないため、1 件も登録しないうちは /oidc/auth がすべてのリクエストを拒否します。ユーザをサインインさせるアプリケーションを、同じオプション列で op.WithStaticClients に宣言します。

go
op.WithStaticClients(op.ConfidentialClient{
    ID:           "my-app",
    Secret:       secretFromYourSecretStore, // または SecretHash(後述)
    AuthMethod:   op.AuthClientSecretBasic,
    RedirectURIs: []string{"https://app.example.com/callback"},
    Scopes:       []string{"openid", "profile", "email"},
}),

起動時、ストアに無いクライアントは書き込まれますが、保存済みのレコードが定義と食い違うクライアントがあると起動を拒否します。そのため、定義の変更(redirect URI の追加、シークレットのローテーションなど)は、新しいバージョンを起動する前にデプロイジョブから op.SyncStaticClients(ctx, st, seeds...) で反映します。平文の Secret が 32 byte に満たないと op.New が拒否します。op.NewClientSecret() を使うと、ランダムなシークレットとそのハッシュを一度に得られます。平文を設定に置きたくない場合は、go run github.com/libraz/go-oidc-provider/cmd/op-client-secret -argon2id が出力するハッシュを SecretHash に設定します。動的登録や管理 API は クライアントオンボーディングのパターン を参照してください。

古い RP

既定値のうち 2 つが、それより前に作られた RP を止めることがあります。OP は認可コードのリクエストすべてに PKCE を要求するため、code_challenge を送らない confidential client は拒否されます。op.WithProfile(profile.OIDCCore)(github.com/libraz/go-oidc-provider/op/profile)を指定すると、confidential client では PKCE が任意になります。また OP は ES256 で署名するため、RS256 の ID トークンしか受け付けないクライアントには、op.WithSigningAlgorithm(op.AlgRS256) と、op.WithKeyset に渡す 2048 ビット以上の RSA 鍵が必要です。セキュリティプロファイルの宣言 と 必須オプション を参照してください。

OP はクライアントへ claim を渡す前に同意画面を表示します。RP が自社のアプリケーションであれば、op.WithFirstPartyClients("my-app") にその ID を指定すると同意画面を省略できます。指定する ID は op.WithStaticClients でも宣言されている必要があり、FAPI 2.0 プロファイルではこのオプションを指定すると起動を拒否します。このオプションが表す信頼の判断は ファーストパーティ同意スキップ で説明しています。

6. ユーザがいなくなるとき ​

ユーザを無効化するのか、消去するのかを決めます。

  • 無効化: ユーザストアがその subject に store.ErrNotFound を返すようにし、Provider.RevokeSubject(ctx, subject) を呼びます。ログインとリフレッシュが止まり、その subject の全 grant がアクセストークンとリフレッシュトークンごと失効します。
  • 消去: ユーザストアがその subject に store.ErrNotFound を返すようにしてから、Provider.ForgetSubject(ctx, subject) を呼びます。RevokeSubject は内部で実行され、さらにブラウザのセッション、lockout の記録、第 2 要素のレコードも削除します。ユーザストアのどれかがまだその subject を解決する間は configuration_error で拒否するので、順序は強制されます。

どちらの場合も、自分のユーザ行を削除するのは組み込み側の役割です。それぞれの呼び出しが届く範囲と残るものは 削除・無効化したユーザ にまとめています。

7. 組み込みをテストする ​

op/testkit は https の httptest サーバ上でプロバイダを動かし、サインインの各ステップを JSON で返します。そのため、テストは標準の cookie jar のままログイン全体を進められ、各プロンプトの入力項目も HTML を解析せずにレスポンスから読めます。組み込みの HTML ページは __Host- 付きの Secure cookie を設定し、Go の cookie jar はこれを平文 http では送りません。http のリスナに対するテストでは、cookie を手で付け直すことになります。自前のストア、ログインフロー、クライアントは testkit.WithOptions で渡します。渡したものが testkit の既定値を置き換えます。

go
p := testkit.NewProvider(t, testkit.WithOptions(
    op.WithStore(st),
    op.WithLoginFlow(op.LoginFlow{Primary: op.PrimaryPassword{
        Store:         users{db},
        LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()},
    }}),
    op.WithStaticClients(op.ConfidentialClient{ /* ステップ 5 と同じ */ }),
))
jar, _ := cookiejar.New(nil)
client := p.HTTPClient(jar)

/oidc/auth は /oidc/interaction/{uid} にリダイレクトします。そこへ GET すると、state_ref と入力すべき項目を含むプロンプトが返り、__Host-oidc_csrf cookie が設定されます。値は {"state_ref": "...", "values": {...}} の JSON で送り、Origin に issuer を、X-CSRF-Token に cookie の値を指定します。cookie はステップごとに発行し直されます。クライアントが op.WithFirstPartyClients に含まれていなければ、続いて同意のプロンプトが出ます。testkit.IsConsentPrompt で判定し、testkit.ApprovedScopesFrom で、提示されたスコープをすべて承認する approved_scopes の値を作ります。最後のレスポンスは、コードを付けてクライアントの redirect_uri にリダイレクトします。

RP が RS256 の ID トークンを必要とする場合(ステップ 5 の tip)は、testkit.WithOptions と並べて testkit.WithSigningAlgorithm(op.AlgRS256) を渡し、本番と同じアルゴリズムで署名させます。p.SignedJWT(claims) はプロバイダ自身の鍵で署名するので、/oidc/end_session に渡す id_token_hint をテストで作るときに使えます。クライアントの assertion への署名には使えません。

続きはこちら ​