Skip to content

ユースケース — API を守る(リソースサーバ) ​

op パッケージはトークンを発行し、op/rs パッケージは受け取る側でそれを検証します。受信したリクエストを、検証済みのトークンに変えるか、HTTP ステータスと仕様どおりの WWW-Authenticate チャレンジを持つエラーに変えます。API を自分で書き、OP のトークンで守りたいときに使います。OP と同じプロセスでも、別のプロセスでも構いません。

API が必要としているのがトークンが有効かどうかだけで、JWT を検証するゲートウェイがすでにあるなら、op/rs で得られるものは多くありません。送信者制約(DPoP、mTLS)の確認、scope とステップアップの要求、正しいチャレンジの生成を、RFC から組み立てずに済ませたいときに役立ちます。

このページで触れる仕様
  • RFC 6750 — Bearer トークンの使い方と WWW-Authenticate チャレンジ
  • RFC 9068 — アクセストークンの JWT プロファイル
  • RFC 7662 — トークン introspection
  • RFC 9449 — DPoP
  • RFC 8705 — 証明書に結び付いたアクセストークン
  • RFC 9470 — ステップアップ認証のチャレンジ
  • RFC 8707 — リソースインジケータ

ソース: examples/36-resource-server は、OP と op/rs で守る /api/orders ルートを同じプロセスで動かし、マシンクライアントとして呼び出して、成功と 3 種類の拒否を出力します。

検証器を作る ​

go
import "github.com/libraz/go-oidc-provider/op/rs"

verifier, err := rs.New(rs.Config{
  Issuer:   "https://op.example.com",
  Audience: "https://api.example.com",
})

Issuer と Audience は必須です。rs.New はネットワーク I/O を行いません。OP の discovery ドキュメントと JWK Set は最初の利用時に取得し、キャッシュします。*rs.Verifier は並行利用が安全なので、起動時に 1 つ作って共有します。

フィールド意味
Issuer受け付けるすべてのトークンの iss が、バイト単位で一致する必要があります。Issuer + "/.well-known/openid-configuration" の discovery も、同じ issuer を示していなければなりません。
Audienceこの API の識別子です。トークンの aud(文字列または配列)に含まれているときだけ受け付けます。Bearer チャレンジの realm にもなります。
JWKSURIJWK Set の URL です。空なら discovery の jwks_uri を使います。
Introspectionopaque トークンを有効にします。下記を参照してください。
DPoPrs.DPoPAllowed(零値)または rs.DPoPRequired。
DPoPReplay使用済みの DPoP proof を記録する store.ConsumedJTIStore。nil ならプロセス内に保持します。
MTLSProxy信頼する proxy と証明書ヘッダを示す *op.MTLSProxy。op.WithMTLSProxy が記録し、(*op.Provider).MTLSProxy() が読み出す値です。
HTTPClient、Logger、Clock外向き取得のトランスポートとタイムアウト、その失敗を出力する logger、時刻の供給元。

外向きの取得(discovery、JWK Set、introspection)はすべて、ライブラリの SSRF 対策済みクライアントを通ります。https が必須で、リダイレクトは辿らず、応答にはサイズ上限があり、プライベートアドレスは拒否されます。AllowPrivateNetwork は、ローカル開発専用に、プライベートアドレスと loopback の IP リテラルへの http を許可します。example 36 の issuer は http://127.0.0.1:8080 なので、これを設定しています。

ルートを守る ​

Middleware は、ハンドラを検証と、そのルートが必要とする要件で包みます。

go
requireOrdersRead := verifier.Middleware(rs.RequireScope("orders:read"))
mux.Handle("/api/orders", requireOrdersRead(http.HandlerFunc(listOrders)))

検証を通ったリクエストは、コンテキストにトークンを持ってハンドラに届きます。失敗したリクエストには、エラーのステータスとチャレンジ、空の本文が返り、ハンドラは実行されません。

ハンドラの中では rs.FromContext でトークンを読みます。

go
func listOrders(w http.ResponseWriter, r *http.Request) {
  tok, ok := rs.FromContext(r.Context())
  if !ok {
    http.Error(w, "no verified token", http.StatusInternalServerError)
    return
  }
  // tok.Subject、tok.ClientID、tok.Scopes など
}

rs.Token は、Subject、ClientID、Scopes、ACR、AMR、AuthTime、ExpiresAt、送信者制約を示す Binding(JKT または CertThumbprint。bearer トークンでは両方とも空)、payload のすべての claim を持つ Claims を持ちます。client_credentials のトークンでは、subject はクライアント自身の識別子です。

net/http の外で使うコードは、verifier.Verify(r) を直接呼びます。戻り値は *rs.Token か、Status、Code、Description、WWWAuthenticate() を持つ *rs.Error です。エラーは常にこの型です。

要件は次の 3 つで閉じています。

要件失敗時の応答
rs.RequireScope("orders:read", ...)403 insufficient_scope。必要な scope は scope 属性に入ります
rs.RequireACR(levels, acr)401 insufficient_user_authentication。チャレンジに acr_values が入ります
rs.RequireMaxAge(d)401 insufficient_user_authentication。max_age は秒の整数です

RequireACR は OP に設定したものと同じ op.ACRLevels を受け取るので、両側で acr の序列が一致します。acr が levels に宣言されていなければ、組み込み時に panic します。RequireMaxAge には正の時間幅が必要で、auth_time のないトークンは要件を満たしません。Middleware に nil の要件を渡すと、ルートが無防備になるのではなく panic します。このステップアップの OP 側は、MFA / ステップアップで説明しています。

検証する内容 ​

JWT のアクセストークンは、issuer の JWK Set に対してローカルで検証します(RFC 9068 §4)。

  • typ ヘッダが at+jwt または application/at+jwt
  • 署名。alg は RS256、PS256、ES256、EdDSA のいずれかで、鍵が alg を宣言している場合はそれと同じであること。none と HMAC 系は一切受け付けません
  • 必須 claim と、iss、aud、exp、iat。時刻の検証に猶予はありません

キャッシュした JWK Set に kid がなければ、頻度を抑えて 1 回だけ再取得します。鍵のローテーションが API に伝わるのは、この仕組みによります。検証に失敗した JWT を、introspection でやり直すことはありません。

opaque トークン ​

JWT でないトークンは、Config.Introspection を設定していれば RFC 7662 の introspection に送られ、設定していなければ invalid_token で拒否されます。API は OP に対してクライアントとして認証します。

go
verifier, err := rs.New(rs.Config{
  Issuer:   "https://op.example.com",
  Audience: "https://api.example.com",
  Introspection: &rs.IntrospectionConfig{
    ClientID:     "orders-api",
    ClientSecret: secret,
  },
})

Endpoint は省略でき、省略すると discovery の introspection_endpoint を使います。API が認証する方法は、次のうち厳密に 1 つを選びます。指定がない場合や 2 つ以上の場合は rs.New が失敗します。

設定するもの方式備考
ClientSecretclient_secret_basic
PrivateKey(crypto.Signer)と KeyIDprivate_key_jwtassertion の aud は issuer。P-256 鍵は ES256、RSA は PS256、Ed25519 は EdDSA
TLSCertificatetls_client_auth または self_signed_tls_client_authEndpoint が空で、OP が mtls_endpoint_aliases を公開していれば、その introspection endpoint を使う

検証器は RFC 9701 の JWT 形式の introspection 応答を要求し、検証します(typ が token-introspection+jwt、iss が issuer、aud がクライアントの ID、署名は OP の JWK Set で確認)。OP が通常の JSON 応答を返す場合は、それも受け付けます。OP 側では、次の 3 つを揃える必要があります。

go
op.WithFeature(feature.Introspect),
op.WithAccessTokenFormat(store.AccessTokenFormatOpaque), // トークンが opaque の場合
op.WithProtectedResources(op.ProtectedResource{
  Resource:             "https://api.example.com",
  IntrospectionClients: []string{"orders-api"},
}),

orders-api は、登録済みの confidential クライアントでもある必要があります。OP が introspection リクエストにトークンの内容を返すのは、認証したクライアントが、そのトークンの宛先リソースの IntrospectionClients に挙がっている場合だけです。それ以外は、すべてのトークンが inactive になり、API はすべての呼び出しを拒否します。委譲の範囲はそのリソースに限られ、ほかのリソース宛てのトークンはそのクライアントには見えません。リフレッシュトークンは対象外です。形式が opaque の場合、OP 側のストアには Store.OpaqueAccessTokens が必要です。

アクセストークンの既定の形式は JWT です。inactive の応答は invalid_token です。通信やプロトコルの失敗はサーバ側の障害として扱われ、503 になります。

rs は introspection の結果をキャッシュしないため、API を 1 回呼ぶたびに OP へ 1 回問い合わせます。そのコストが気になるなら、手前にキャッシュを置くか、JWT を発行してください。

送信者に結び付ける ​

cnf に jkt を持つトークンは、DPoP の鍵に結び付いています(RFC 9449)。DPoP スキームで、同じ鍵の有効な proof とともに届く必要があります。proof の htu と htm がリクエストと一致し、ath がトークンと一致し、jti が 1 回しか使われないことを確認します。cnf に x5t#S256 を持つトークンは、証明書に結び付いています(RFC 8705 §3)。接続が、結び付いた証明書を提示している必要があります。introspection 応答の cnf も、JWT の cnf と同じように検証します。

  • DPoPAllowed は bearer トークンを受け付け、DPoP に結び付いたトークンは DPoP スキームのときだけ受け付けます。チャレンジは両方のスキームを示します。
  • DPoPRequired は、DPoP に結び付いたトークンを DPoP スキームで受けたときだけ受け付けます。チャレンジは DPoP だけです。
  • 結び付いたトークンを通常の bearer として提示すると invalid_token です。proof が不正なら invalid_dpop_proof、再利用された proof も拒否されます。

検証器は、proof の htu を、リクエストの宛先 URL(r.URL、r.Host、r.TLS から組み立てます)と比較します。ホストを書き換える、または TLS を終端するリバースプロキシの背後では、検証器にリクエストを渡す前にそれらのフィールドを元に戻してください。

DPoPReplay の既定はプロセス内のストアなので、最初の使用を見たインスタンスでしか再利用を検出できません。インスタンスが複数なら、OP が使っているものなどの共有ストアを渡します。Logger を設定していると、rs.New は既定のプロセス内ストアについて警告を 1 回出力します。検証器は DPoP の nonce を発行しません。クライアント TLS を終端する proxy の背後で証明書の結び付きを確認するには、MTLSProxy を設定します。

FAPI 2.0 の OP と組み合わせる ​

profile.FAPI2Baseline または profile.FAPI2MessageSigning で動く OP に対しては、次のようにします。

  • JWT のアクセストークンを優先する。 rs は OP を呼ばずにローカルで検証でき、管理するクライアント認証情報もありません。
  • opaque トークンでは、鍵または証明書で認証する。 FAPI プロファイルは client_secret_basic を拒否します。IntrospectionConfig.PrivateKey と KeyID(その公開鍵を API クライアントの JWKS として登録)を設定するか、mTLS クライアントなら TLSCertificate を設定します。共有シークレットの introspection クライアントは OP に拒否されます。
  • Message Signing からは署名付きの応答が返る。 profile.FAPI2MessageSigning では、リクエストの内容によらず /introspect が RFC 9701 の JWT で応答します。rs は追加設定なしで、OP の JWK Set に対して検証します。
  • 送信者制約を確認する。 DPoP や証明書に結び付いたトークンは、送信者に結び付けるのとおりに検証されます。

これらのプロファイルの OP 側は FAPI 2.0 Baseline と FAPI 2.0 Message Signing を参照してください。

チャレンジ ​

失敗すると、ミドルウェアはステータスと WWW-Authenticate ヘッダを書き、本文は空にします。

状況ステータスerror
Authorization ヘッダがない401なし(realm だけの素のチャレンジ)
ヘッダが複数、未知のスキーム、トークンの形式不正400invalid_request
issuer または audience の不一致、期限切れ、署名不正、inactive、結び付きの不一致401invalid_token
DPoP proof が不正、または再利用401invalid_dpop_proof
scope 不足403insufficient_scope
acr または max_age を満たさない401insufficient_user_authentication
discovery、JWK Set、introspection、replay ストアに到達できない503なし(チャレンジも書きません)

DPoPAllowed では、ヘッダに Bearer realm="https://api.example.com", ... と DPoP algs="..." の 2 つのチャレンジが入ります。エラー属性が付くのはクライアントが使ったスキームのチャレンジだけで、資格情報のないリクエストにはどちらにも付きません。error_description は固定の文字列で、リクエストの内容は含まれません。scope、acr_values、max_age の各属性は、そのルートが要求した値を示し、クライアントがステップアップを始めるために必要な情報になります(RFC 9470 §3)。

audience を決める ​

検証器は、Config.Audience が aud に含まれているトークンだけを受け付けます。そのため OP は、トークンの宛先を API にする必要があります。クライアントは、トークンリクエストの RFC 8707 resource パラメータでそれを求めます。

sh
curl -u orders-client:... \
  -d 'grant_type=client_credentials&scope=orders:read' \
  --data-urlencode 'resource=https://api.example.com' \
  https://op.example.com/oidc/token

次の 3 つの設定が揃うと動きます。

  • op.WithProtectedResources(op.ProtectedResource{Resource: "https://api.example.com", ...}) で API を宣言します。OP はそれを protected-resource metadata として公開し、その API 宛てのトークンを発行します。
  • クライアントの Resources に、要求してよいリソースを並べます。リストが空のクライアントは、resource を指定したリクエストが拒否されます。
  • Config.Audience は、同じ識別子にします。

resource を指定しないと、OP はトークンの宛先を自分自身(aud が issuer)にするため、API は拒否します。example 36 は 3 種類の拒否を示します。トークンなし、scope のないトークン、別の audience のトークンです。API について OP が公開する内容は、Protected resource メタデータを参照してください。

同じ SPA から API と /userinfo を呼ぶ ​

API を呼びつつユーザのプロフィールも読みたい SPA は、audience の規則に突き当たります。/userinfo が受け付けるのは、aud に OP の issuer を含むアクセストークンだけです。resource=https://api.example.com を付けて要求したトークンは API だけを宛先にするため、OP は /userinfo に対して 401 invalid_token を返します。そして同じトークンこそ、API の検証器が求めているものです。resource を付けずに要求したトークンは aud が issuer になり、/userinfo では使えますが、API は拒否します。

一方を他方に変換する手段はありません。リフレッシュトークンは、それを作った grant の resource に結び付くため、refresh_token リクエストで、返されるアクセストークンの audience を変えることはできません。OP は /userinfo の検査も緩めません。リソースサーバ宛てのトークンを受け付けると、そのトークンを持つ API ならどれでもユーザのプロフィールを読めてしまい、それを防いでいるのは API 自身の audience 検査だけになるためです。

推奨する形は、アクセストークンと一緒にトークンエンドポイントが返す ID Token からプロフィールを読むことです。ID Token に載せたい claim を持つ scope を指定します。

go
op.New(
  /* 必須オプション */
  op.WithProtectedResources(op.ProtectedResource{Resource: "https://api.example.com"}),
  op.WithIDTokenScopeClaims("profile", "email"),
)

このオプションを指定すると、authorization_code、refresh_token、device_code、CIBA の各 grant の ID Token に、応答の scope に含まれる profile と email が /userinfo で開放する claim が載ります。SPA は、1 回のトークン応答で、プロフィールと API 宛てのアクセストークンを得られます。このオプションは OIDC Core 1.0 §5.4 から意図的に外れ、ID Token が大きくなります。詳細はスコープを参照してください。このオプションを使わない場合のもう 1 つの経路は、resource なしで 2 回目の認可リクエストを出すことです。その結果のアクセストークンは /userinfo 専用になります。

API を置く場所 ​

API は OP とプロセスもルータも共有できますし、別に動かすこともできます。ルータを共有する場合は、アプリケーション自身の 404 と API ルートを組み込み側のものに保てるよう、OP をルートごとにマウントします。example 36 がこの形です。

次へ ​