ルータへのマウント
op.New は *op.Provider を返します。ServeHTTP メソッドを持つので、標準の http.Handler としてそのまま使えます。ライブラリはリスナを持たず、ルータも選びません。
OP は、組み込み側のルータの中で自前のハンドラと並ぶ位置に入ります。ミドルウェアは OP も外側から包み、OP が受け持たないパスはすべて組み込み側のものです。
net/http、chi、gin で使う
mux := http.NewServeMux()
mux.Handle("/", provider)
http.ListenAndServe(":8080", mux)r := chi.NewRouter()
r.Mount("/oidc", provider) // WithMountPrefix と同じパス(既定は "/oidc")
r.Handle("/.well-known/*", provider)
http.ListenAndServe(":8080", r)r := gin.New()
h := gin.WrapH(provider)
r.Any("/oidc/*path", h) // WithMountPrefix と同じ prefix(既定は "/oidc")
r.Any("/.well-known/*path", h) // discovery などの well-known ドキュメント
http.ListenAndServe(":8080", r)URL の prefix を決める
ハンドラは完全なリクエストパスで振り分けるので、ルータはパスを書き換えずに渡す必要があり、エンドポイントの置き場所はライブラリが決めます。net/http では、ハンドラをルートに登録します。chi では WithMountPrefix に渡したのと同じパス(既定は /oidc)に Mount し、discovery ドキュメントは prefix の外にあるため /.well-known/* もルートでハンドラに振り分けます。gin も同じ 2 つの部分木を、/oidc/*path と /.well-known/*path の 2 本のワイルドカードルートで渡します。r.Any("/*path", ...) のようなルート直下の catch-all は、アプリ自身のルートが取らないパス(アプリ自身の 404 を含む)をすべて OP が受けることになるため使いません。chi でルータにすでに /* の catch-all がある状態で r.Mount("/", provider) を呼ぶと panic します。http.StripPrefix で包むと、削られたパスがどのエンドポイントにも一致しなくなります。エンドポイントを動かすには WithMountPrefix を使います。
op.New(
op.WithMountPrefix("/"), // 既定は "/oidc"
/* ... */
)既定の prefix /oidc では、エンドポイント /auth が /oidc/auth、/token が /oidc/token になります。issuer URL にパスが含まれる場合は、そのパスが先頭に付きます。mount prefix が動かすのはプロトコルのエンドポイントだけで、discovery ドキュメントの位置は動きません。
| issuer | mount prefix | token エンドポイント | discovery ドキュメント |
|---|---|---|---|
https://op.example.com | /oidc(既定) | /oidc/token | /.well-known/openid-configuration |
https://op.example.com | / | /token | /.well-known/openid-configuration |
https://example.com/tenant | /oidc | /tenant/oidc/token | /.well-known/openid-configuration/tenant と /tenant/.well-known/openid-configuration |
パスを持つ issuer は、RFC 8414 の位置と OpenID Connect Discovery 1.0 §4 の位置のどちらからも探されるため、OP は両方で公開します。
カスタムエンドポイントパス
op.New(
op.WithEndpoints(op.Endpoints{
Authorize: "/oauth/authorize",
Token: "/oauth/token",
}),
/* ... */
)空フィールドは既定値のままで、変更したパスは discovery にそのまま載ります。既定は /auth、/token、/userinfo、/end_session、/jwks です。機能を有効にしたときだけ存在するエンドポイントとして、/par、/introspect、/revoke、/register、/device_authorization、/bc-authorize、/grant_management があります。上書きしたパスどうしが衝突すると、op.New は error を返します。
ルートを 1 つずつマウントする
/ を provider に向けると、ルータに該当するハンドラがないパスはすべて OP が応答します。存在しない URL に対して、アプリケーション自身ではなく OP の 404 が返ることになります。ルータと 404 ページをアプリケーションが持っている場合は、provider のパターンを 1 つずつ登録します。(*op.Provider).Routes() は、provider が登録した http.ServeMux のパターンを登録順に返します。返るスライスはコピーです。
provider, err := op.New(
op.WithIssuer("https://app.example.com"),
op.WithMountPrefix("/identity"),
/* ... */
)
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
for _, pattern := range provider.Routes() {
mux.Handle(pattern, provider)
}WithMountPrefix("/identity") を指定すると、エンドポイントは /identity 配下(/identity/token、/identity/auth)に置かれ、discovery は /.well-known/openid-configuration のままです。それ以外のパスは /no-such-page も含めて組み込み側のハンドラに届き、404 も組み込み側のものになります。
パターンの集合は設定に応じて変わります。オプションのエンドポイント、op.SPAUI、WithMountPrefix、WithGrantManagement が項目を追加・移動・削除するため、一覧を手で書かず、Routes() からループを組んでください。パターンは net/http の構文で、HTTP メソッドは含みません。メソッドの検査はハンドラが行うためです。別のルータを使う場合は、{name}(1 セグメント。例: /login/state/{uid})をそのルータの単一セグメント用パラメータ形式に、{path...}(残りのパス全体。例: /login/assets/{path...})を catch-all 形式に読み替えます。
WithMountPrefix が動かすのはプロトコルのエンドポイントだけです。SPAUI.LoginMount は絶対パスで、LoginMount: "/login" なら mount prefix にかかわらず、SPA の入口が /login、state エンドポイントが /login/state/{uid} に置かれます。OP を /identity 配下に置き、ログイン画面もその下に置きたい場合は LoginMount: "/identity/login" と書きます。
examples/36-resource-server は、この方法で OP を /identity 配下にマウントし、op/rs で守る API と並べています。
自前のミドルウェアと重ねる
リクエストを観測するだけのミドルウェアは、上の図のとおり OP を外側から包めます。アクセスログ、トレース、メトリクスが、すべてのリクエストを捉えます。セキュリティ上の挙動を変えるミドルウェアは別です。OP は、CORS の応答、double-submit の CSRF cookie、Content-Security-Policy、Referrer-Policy、セッション cookie を自分で設定します。CORS、CSRF、セキュリティヘッダ、セッションのミドルウェアは、組み込み側のルートに適用し、OP はその外に置いてください。ルータ全体ではなく、ルートごと、またはルートのグループごとに付けます。
mux := http.NewServeMux()
mux.Handle("/api/", withCORS(withCSRF(withSecurityHeaders(api)))) // 自前のルートにはミドルウェアを付ける
mux.Handle("/", provider) // OP には付けない
http.ListenAndServe(":8080", accessLog(mux)) // 観測だけ。全体を包む実際に問題になるのは、次の 2 点です。
クライアント IP と WithTrustedProxies
OP が X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Host を信頼するのは、TCP のピア(r.RemoteAddr)が op.WithTrustedProxies に渡した CIDR に含まれるときだけです。chi の middleware.RealIP は、これらのヘッダから、送信元を問わず RemoteAddr を書き換えます。そのため RealIP の後ろでは、OP から見たピアがプロキシではなくなります。OP は転送されたスキームとホストを無視し、監査とリスク判定に記録するクライアントのアドレスはヘッダの値になります。サーバに直接到達できるクライアントなら、この値を偽装できます。どちらかを選んでください。OP に届く経路から RealIP を外して proxy の範囲を op.WithTrustedProxies に列挙するか、RealIP を組み込み側のルートのグループだけに付けます。
Referrer-Policy: no-referrer は form の POST を壊す
OP のログイン画面とログアウト画面は、自分自身に form を送信するため、同一 origin の Origin ヘッダが必要です。プロキシ、セキュリティヘッダのミドルウェア、あるいは組み込んだテンプレート内の <meta name="referrer"> がそれらのページに Referrer-Policy: no-referrer を設定すると、ブラウザは代わりに Origin: null を送ります。許可リストの外は拒否するという OP の規則は変わりません。SPA の状態ルートは 403、/end_session は 400 を返します。さらに provider ごとに 1 回だけ、次の WARN を出力します。
request rejected: the browser sent Origin: null on a same-origin form POST; a Referrer-Policy of no-referrer on the OP's pages (from a proxy, a security-header middleware, or a <meta name=referrer> in an embedder template) causes this — the OP's login and logout pages need same-originOP のパスではそのヘッダを外すか、same-origin(OP 自身のページが使う値)を指定してください。既定の cors mode の fetch() は、ポリシーにかかわらず実際の Origin を送るので、fetch() で状態ルートを呼ぶ SPA は影響を受けません。Origin: null が現れるのは、HTML form の POST と、mode: "same-origin" で行った fetch() です。
OP が公開 しない もの
OP がルータに載せるものは、意図的に絞ってあります。
| OP が公開する | OP が公開しない |
|---|---|
| discovery(issuer に応じたパス) | /metrics(組み込み側が promhttp で公開) |
/jwks | /healthz、/readyz |
/auth、/token、/userinfo | リクエスト時間のヒストグラム用ミドルウェア |
/end_session | OpenTelemetry の HTTP サーバスパン用ミドルウェア |
オプション: /par、/introspect、/revoke、/register、/device_authorization、/bc-authorize、/grant_management | /debug/pprof の公開 |
/interaction/{uid}(ログイン・同意画面)。op.SPAUI を使うときは LoginMount 配下のパス | 汎用の IP 別レートリミッタ |
OP は 業務 メトリクスとログ・監査イベントを、op.WithPrometheus、op.WithLogger、op.WithAuditLogger を通じて、利用者が所有する出力先に流します。OpenTelemetry のスパンは作りません。HTTP ライフサイクルの観測は組み込み側の責務です。 otelhttp や promhttp.InstrumentHandler で、自分の SRE 規約に合わせてルータをラップしてください。
TLS / プロキシ
OP は TLS 終端 ingress の背後で動く想定です。op.WithTrustedProxies(cidrs ...) で、X-Forwarded-For を提供するプロキシの範囲を許可リスト化してください。
未設定の間、OP は X-Forwarded-For、X-Forwarded-Proto、X-Forwarded-Host を無視し、監査・リスク判定・レート判定にはピアアドレスを使います。これらのヘッダを持つリクエストが最初に届いた時点で、設定済みのロガーに警告を Provider ごとに 1 回だけ出力します。
プロキシ側でクライアント TLS を終端し、RFC 8705 の証明書結び付けアクセストークン用に証明書をヘッダ転送する場合は、op.WithMTLSProxy(op.MTLSProxy{HeaderName: headerName, TrustedProxies: cidrs}) をあわせて指定します。feature.MTLS を有効にしていないと op.New が op.WithMTLSProxy を拒否します。TrustedProxies が空なら op.WithTrustedProxies の範囲を使います。
安全に停止する
/end_session は OP のセッションを消した時点でブラウザに応答し、RP への Back-Channel Logout トークンの送信はバックグラウンドで行います。リクエストの処理経路はこの送信を待たないため、http.Server.Shutdown の直後にプロセスを終了すると、送信待ちのログアウト通知が失われることがあります。リスナがリクエストの受け付けを止めたあとで、Provider.Shutdown を呼んでください。
srv := &http.Server{Addr: ":8080", Handler: mux}
// ... SIGTERM を受けたら:
_ = srv.Shutdown(ctx) // 受け付けを止め、処理中のリクエストを終える
_ = provider.Shutdown(ctx) // back-channel logout の送信待ちを完了させるProvider.Shutdown は、送信待ちが空になれば nil を、時間切れなら context の error を返します。/end_session を公開しない provider に呼んでも問題ありません。upstream の例は examples/internal/serve がこの処理を行います。