Skip to content

ルータへのマウント ​

op.New は *op.Provider を返します。ServeHTTP メソッドを持つので、標準の http.Handler としてそのまま使えます。ライブラリはリスナを持たず、ルータも選びません。

OP は、組み込み側のルータの中で自前のハンドラと並ぶ位置に入ります。ミドルウェアは OP も外側から包み、OP が受け持たないパスはすべて組み込み側のものです。

OP がサーバのどこに入るか
OP のハンドラは組み込み側のルータの内側、自前のハンドラと並ぶ位置に入ります。リスナとミドルウェアが両方を外側から包むため、追加の実装なしに OP のエンドポイントまで計測とアクセスログが届きます。自前のリスナhttp.ListenAndServe自前のミドルウェアotelhttp · promhttp · アクセスログ自前のルータnet/http · chi · gin自前のハンドラ/ · /api/* · /healthz · /metricsOP が関与しない部分はすべてここop.Provider/oidc/*選んだ mount prefix 配下の subtree 1 つだけ
リクエストはルータが振り分ける前にリスナとミドルウェアを通るので、計測とアクセスログは OP にも届きます。OP が占有するのは 1 つの部分木だけで、その外側には触れません。ホストだけの issuer では discovery はホストのルートに公開されますが、パスを含む issuer では RFC 8414 と OpenID Connect Discovery が定める場所にも公開されます。

net/http、chi、gin で使う ​

go
mux := http.NewServeMux()
mux.Handle("/", provider)
http.ListenAndServe(":8080", mux)
go
r := chi.NewRouter()
r.Mount("/oidc", provider) // WithMountPrefix と同じパス(既定は "/oidc")
r.Handle("/.well-known/*", provider)
http.ListenAndServe(":8080", r)
go
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 を使います。

go
op.New(
  op.WithMountPrefix("/"), // 既定は "/oidc"
  /* ... */
)

既定の prefix /oidc では、エンドポイント /auth が /oidc/auth、/token が /oidc/token になります。issuer URL にパスが含まれる場合は、そのパスが先頭に付きます。mount prefix が動かすのはプロトコルのエンドポイントだけで、discovery ドキュメントの位置は動きません。

issuermount prefixtoken エンドポイント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 は両方で公開します。

カスタムエンドポイントパス ​

go
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 のパターンを登録順に返します。返るスライスはコピーです。

go
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 はその外に置いてください。ルータ全体ではなく、ルートごと、またはルートのグループごとに付けます。

go
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 を出力します。

text
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-origin

OP のパスではそのヘッダを外すか、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_sessionOpenTelemetry の 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 を呼んでください。

go
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 がこの処理を行います。

次へ ​

  • ガイド: OAuth と OIDC が初めての方へ。3 つの役割から DPoP・FAPI まで、読む順番を示しています。ここまでに設定したオプションの意味がつかめます。
  • ユースケース: SPA ログイン、client_credentials、hot/cold ストレージ、MFA などの具体的な構成。
  • セキュリティ方針: cookie、CSRF、SSRF の既定値と、必要なときの緩め方。