最小構成 OP
OP を起動する最短の手順です。op.New は WithIssuer、WithStore、WithKeyset の 3 つが揃わないと起動を拒否します。既定の grant 集合には、ユーザをブラウザ経由で認可する authorization_code が含まれます。この grant を使うときは、ブラウザの cookie を暗号化する WithCookieKeys と、サインイン方法を決める WithLoginFlow も必要です。client_credentials だけを提供する OP なら、最初の 3 つで足ります。下のコードでは WithLogger も渡しています。WithLogger を渡さないと、リクエスト処理中のログと監査レコードは破棄され(保存されたパスワードハッシュが argon2id でない場合に一度だけ出る「PrimaryPassword.LegacySchemes を設定してください」という警告も、その 1 つです)、起動時にその旨の警告が出ます。セキュリティプロファイルは宣言していません。WithProfile なしで構築した OP は profile.OAuth21 で動くため、すべての authorization-code request に PKCE が必要です(セキュリティプロファイルの宣言 を参照)。
http.Handler です。だからこそ、既にあるルータのどこにでも OP を置けます。package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"log"
"log/slog"
"net/http"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
func main() {
// 揮発性 ECDSA P-256 (ES256) — 本番では Vault / KMS の鍵に差し替えてください。
// Keyset は {KeyID, Signer} のスライスです。
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cookieKey := make([]byte, 32) // AES-256-GCM
if _, err := rand.Read(cookieKey); err != nil {
log.Fatal(err)
}
st := inmem.New()
handler, err := op.New(
op.WithIssuer("http://127.0.0.1:8080"),
op.WithLogger(slog.Default()),
op.WithStore(st),
op.WithKeyset(op.Keyset{{KeyID: "k1", Signer: priv}}),
op.WithCookieKeys(cookieKey),
op.WithLoginFlow(op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
}),
)
if err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", handler))
}package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"log"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
func main() {
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cookieKey := make([]byte, 32)
if _, err := rand.Read(cookieKey); err != nil {
log.Fatal(err)
}
st := inmem.New()
handler, err := op.New(
op.WithIssuer("http://127.0.0.1:8080"),
op.WithLogger(slog.Default()),
op.WithStore(st),
op.WithKeyset(op.Keyset{{KeyID: "k1", Signer: priv}}),
op.WithCookieKeys(cookieKey),
op.WithLoginFlow(op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
}),
)
if err != nil {
log.Fatal(err)
}
r := chi.NewRouter()
r.Mount("/", handler)
log.Fatal(http.ListenAndServe(":8080", r))
}package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"log"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/libraz/go-oidc-provider/op"
"github.com/libraz/go-oidc-provider/op/storeadapter/inmem"
)
func main() {
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
cookieKey := make([]byte, 32)
if _, err := rand.Read(cookieKey); err != nil {
log.Fatal(err)
}
st := inmem.New()
handler, err := op.New(
op.WithIssuer("http://127.0.0.1:8080"),
op.WithLogger(slog.Default()),
op.WithStore(st),
op.WithKeyset(op.Keyset{{KeyID: "k1", Signer: priv}}),
op.WithCookieKeys(cookieKey),
op.WithLoginFlow(op.LoginFlow{
Primary: op.PrimaryPassword{Store: st.UserPasswords()},
}),
)
if err != nil {
log.Fatal(err)
}
r := gin.New()
r.Any("/*path", gin.WrapH(handler))
log.Fatal(http.ListenAndServe(":8080", r))
}本番運用の注意
- 鍵: ここでは起動のたびに生成しています。本番では Vault / KMS から読み込み、再起動後もトークンが有効なままにします。
- ストア: ここでは in-memory です。本番では
op/storeadapter/sql、op/storeadapter/dynamodb、compositeのいずれかを使います。Redis はcomposite構成の揮発層で、単独のストアにはなりません。 - issuer: 下の
curlが動くよう、ここではループバックのhttpURL にしています。本番では公開するhttpsURL にします。 - リスナ: ここでは平文 HTTP です。前段に TLS 終端の ingress を置いてください。
この OP で今すぐ試せること
curl http://127.0.0.1:8080/.well-known/openid-configurationで discovery ドキュメントを取得できます。パスを含まない issuer なら、mount prefix に関わらずホストのルート直下に公開されます。curl http://127.0.0.1:8080/oidc/jwksで ID トークンの検証に使う公開鍵を取得できます。既定の mount prefix は/oidcで、op.WithMountPrefix("/")を渡すとエンドポイントがルート直下に移ります。/oidc/authは、クライアントを登録するまで何を送ってもエラーを返します。次の節でクライアントとユーザを 1 つずつ追加します。
クライアントとユーザを 1 つずつ追加する
RP(Relying Party)は、OP にユーザのサインインを依頼するアプリケーションです。OP は登録済みのクライアントからのリクエストしか受け付けません。RP を登録し、パスワード認証の対象になるユーザも用意します。
// 追加の import: "context", "github.com/libraz/go-oidc-provider/op/store"
hash, err := op.HashPassword("demo")
if err != nil {
log.Fatal(err)
}
st.PutUserWithPassword(context.Background(), &store.User{
Subject: "demo-user",
Claims: map[string]any{"name": "Demo User", "email": "[email protected]"},
}, "demo", hash)
// 上のオプションと並べて op.New に渡す
op.WithStaticClients(op.PublicClient{
ID: "demo-rp",
RedirectURIs: []string{"http://127.0.0.1:9090/callback"},
Scopes: []string{"openid", "profile"},
})PutUserWithPassword はハッシュをそのまま保存するため、平文のパスワードを渡すと一致しません。op.HashPassword でハッシュ化してください。PublicClient はシークレットを持たず PKCE に依存するので、RP は code challenge を送る必要があります。次の例には、それを行う RP が付属しています。
付属の例の実行
git clone https://github.com/libraz/go-oidc-provider.git
cd go-oidc-provider
(cd examples/01-minimal && GOWORK=off go run -tags example .)01-minimal は 1 つのプロセスで 2 つのリスナを起動します。:8080 が OP、:9090 が小さな RP です。http://127.0.0.1:9090/ を開いてログインを選び、demo / demo でサインインして同意画面を承認すると、RP が検証済みの ID トークンの claim を表示します。鍵の生成(examples/internal/devkeys)とリスナの定型コード(examples/internal/serve)は別パッケージに出してあるので、main.go は op.New に集中できます。
次へ
- 必須オプション: この 5 つが必要な理由。
- ルータへのマウント:
chi、gin、prefix の付け方。 - ユースケース: 最小構成の OP と RP のペア: クライアント登録と authenticator を組み込んだ同じ OP。