PAR と authorization_details で認可リクエストを設計する

はじめに

こんにちは。ポケットサインでエンジニアをしている関です。普段はポケットサインの基盤バックエンドシステムの開発・運用に携わっています。

PocketSign Link v2 は、マイナンバーカード認証と生涯変わらないユーザー ID をサービスに活用できるプラットフォームです。ID 基盤 KLON 上で動作し、OpenID Connect によるログイン連携や、氏名・メールアドレスなどのリソースへのアクセス権限管理を提供します。マイナンバーカードや JPKI の詳しい知識がなくても、標準的な OIDC の作法で組み込めます。

OIDC を使ったサービスでは、ログインだけでなく、ユーザーのメールアドレスなどへのアクセス許可もあわせてほしくなる場面があります。こうした権限は通常 scope で要求します。しかし scope は文字列を並べるだけなので、住所は読み取りのみ・メールアドレスは読み書きの両方で許可するといった、どのリソースにどの操作を許すのかまでは細かく指定できません。そこで役立つのが、権限要求を JSON で構造化できる authorization_details です。

authorization_details: 権限を構造化して表現する

通常、ほしい権限は openid email のように、scope へ文字列を空白区切りで並べて表します。ただし、この並べ方で表せるのは大まかな範囲までです。どのリソースにどの操作を許すのか、といった細かい粒度までは表せません。

こうした細かい要求を構造化された JSON で表せるのが authorization_details です。これは RFC 9396(Rich Authorization Requests) として標準化されています。各要素は type で要求の種類を示し、type ごとに actions や対象リソースといったフィールドを持ちます。これにより、リソースと操作を要素単位で構造化して要求できます。

以上が仕様の話で、実際に使える type の値やサポート範囲は実装ごとに決まります。たとえば PocketSign Link v2 では、メールアドレス(klon/email_address)の読み取り権限を次のように要求します。

[
  {
    "type": "urn:klon:resource_access",
    "identifiers": ["klon/email_address"],
    "actions": ["read"],
    "required": true,
    "prefill": false
  }
]

各フィールドは、RFC 9396 の共通フィールドと Link v2 独自の指定に分かれます。

  • type(RFC 9396・必須): 要求の種類。Link v2 では urn:klon:resource_access の 1 種類をサポートする
  • actions(RFC 9396): 要求する操作。Link v2 では readwriteinvoke から指定する

Link v2独自のフィールドは以下の 3 つです。

  • identifiers(Link v2 独自): 対象リソースの定義 ID またはエイリアス
  • required(Link v2 独自): true なら、その権限がないと認可フローを継続できない必須要求になる
  • prefill(Link v2 独自): true なら、値が未登録のときに認可フロー内での入力を促す

指定方法の詳細はリソース権限要求にまとまっています。

単なる read ではなく、どのリソースに対する読み取りかを構造として渡せる点がポイントです。しかし、この JSON をそのまま認可 URL に載せると URL が一気に長くなります。 この点を解消するのが、次に説明する PAR です。

PAR: 認可リクエストを事前に登録する

PAR(Pushed Authorization Requests)は、RFC 9126 として標準化された仕組みです。認可リクエストのパラメータをブラウザ経由の URL に載せず、バックエンドから認可サーバーへ直接 POST して登録します。すると認可サーバーが request_uri を返すので、ブラウザへ渡す認可 URL には、その参照だけを載せれば済みます。

具体的には、認可リクエスト一式を PAR エンドポイントへ POST し、request_uri を受け取ります。

POST (PAR エンドポイント)
Content-Type: application/x-www-form-urlencoded

response_type=code&client_id=...&scope=openid+email
&code_challenge=...&code_challenge_method=S256
&authorization_details=%5B%7B...%7D%5D

レスポンスでは request_uri と有効期限が返ります。

{
  "request_uri": "urn:ietf:params:oauth:request_uri:...",
  "expires_in": 60
}

全体の流れは次のようになります。

sequenceDiagram
  autonumber
  participant RP as サービス
  participant Browser as ブラウザ
  participant IdP as KLON IdP
  RP->>IdP: POST /api/oidc/v1/par でリクエスト一式を登録
  IdP-->>RP: request_uri と expires_in を返す
  RP-->>Browser: client_id と request_uri だけの認可 URL へリダイレクト
  Browser->>IdP: GET /api/oidc/v1/authorize
  IdP-->>Browser: ログイン画面・同意画面
  Browser->>IdP: ユーザーが同意
  IdP-->>Browser: redirect_uri へ認可コードを返す
  Browser->>RP: 認可コードを受け渡し
  RP->>IdP: POST /api/oidc/v1/token でコード交換

PAR なしと PAR ありで、ブラウザに渡す認可 URL は次のように変わります。

# PAR なし: パラメータをすべて URL に載せる
GET /api/oidc/v1/authorize
  ?client_id=...
  &redirect_uri=...
  &scope=openid offline_access
  &state=...
  &nonce=...
  &code_challenge=...
  &code_challenge_method=S256
  &authorization_details=%5B%7B%22type%22%3A...%7D%5D
# PAR あり: 先に POST 済みなので request_uri だけ渡す
GET /api/oidc/v1/authorize?client_id=...&request_uri=urn:ietf:params:oauth:request_uri:...

利点 1: 認可 URL の肥大を防ぐ

認可リクエストはもともとパラメータが多く、そこに authorization_details の JSON が加わると URL がさらに膨らみます。複数のリソースへ権限を要求すれば、URL エンコードした JSON だけで数 KB に達することもあります。しかし URL やリクエストヘッダの長さには、経路上のさまざまな場所で上限があります。リバースプロキシやロードバランサー、WAF は、ヘッダ長を数 KB 程度に制限することが多いです。上限を超えたリクエストは 414 URI Too Long400 Bad Request で弾かれ、途中まで切り詰められてログに残ることもあります。

PAR なら、リクエスト本体をサーバー間通信で先に登録します。そのためブラウザに渡す URL は request_uri 中心の短いもので済み、長さ制限の問題を構造的に避けられます。

利点 2: 同意画面の前にリクエストを検証できる

通常の Authorization Code Flow では、認可サーバーがリクエストを最初に受け取るのはブラウザ経由のアクセス時で、その受け取りとログイン画面や同意画面の表示がほぼ同時に始まります。しかもクライアント認証はトークンエンドポイントまで行われないため、同意画面を出す段階では、クライアントの正当性すら確認できていません。

PAR では、バックエンドが PAR エンドポイントへリクエストを直接送り、このエンドポイントがクライアント認証を要求します。そのため認可サーバーは、クライアントを認証したうえでリクエストの中身を検証できます。redirect_uri の登録状況や scopeauthorization_details の妥当性といった検証項目を、ブラウザ遷移の前に確認できるわけです。しかも登録済みのリクエストは request_uri で参照するだけなので、ブラウザ上でパラメータを改ざんされる余地もありません。

標準化の流れ

PAR は、高いセキュリティ要件をもつ領域で採用が進んでいます。 たとえば FAPI 2.0 Security Profile は、認可リクエストの送信方法として PAR を必須としています。 PocketSign は公共や本人確認の基盤として、こうした仕様に沿う形で PAR へ対応しています。

実際に authorization_details + PAR を Link v2を使って実装してみます。

フロー全体

PAR を使う場合でも、基本は Authorization Code Flow と同じです。 違いは「認可エンドポイントへリダイレクトする前に PAR へ送る」という一手間だけです。

  1. サービス側で statenoncePKCE の値を生成する
  2. リソース権限も要求するなら authorization_details を組み立てる
  3. PAR エンドポイントへ認可リクエストを POST する
  4. レスポンスで request_uriexpires_in を受け取る
  5. 認可エンドポイントへ client_idrequest_uri を付けてリダイレクトする
  6. コールバックで認可コードを受け取る
  7. トークンエンドポイントで認可コードをトークンに交換する

Link v2 の各 OIDC エンドポイントは IdP オリジン配下にあります。 以下の例ではポケットサイン開発環境の https://id.mock.klon.you を使います。 リクエストとレスポンスの一連の例は、公式ドキュメントのリソースアクセス要求にもまとまっています。

PAR エンドポイントへ POST する

PAR エンドポイントにはクライアント認証が必要です。 Confidential Client なら client_secret_basic などで認証します。 以下は client_secret_basic(HTTP Basic 認証)の例です。

curl -X POST "https://id.mock.klon.you/api/oidc/v1/par" \
  -u "your-client-id:your-client-secret" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "client_id=your-client-id" \
  --data-urlencode "redirect_uri=https://rp.example.com/callback" \
  --data-urlencode "response_type=code" \
  --data-urlencode "scope=openid offline_access" \
  --data-urlencode "state=e341..." \
  --data-urlencode "nonce=84c0..." \
  --data-urlencode "code_challenge=<CODE_CHALLENGE>" \
  --data-urlencode "code_challenge_method=S256" \
  --data-urlencode 'authorization_details=[{"type":"urn:klon:resource_access","identifiers":["klon/email_address"],"actions":["read"],"required":true,"prefill":false}]'

レスポンスでは request_uriexpires_in が返ります。

{
  "request_uri": "urn:ietf:params:oauth:request_uri:...",
  "expires_in": 60
}

expires_inrequest_uri の有効期間(秒)です。具体的な秒数は発行ごとに返る値を使い、固定値としてハードコードしないでください。

認可エンドポイントへリダイレクトする

PAR を使う場合、ブラウザ遷移で送るのは原則として client_idrequest_uri の 2 つだけです。

GET /api/oidc/v1/authorize?client_id=your-client-id&request_uri=urn:ietf:params:oauth:request_uri:...

このあとはコールバックで認可コードを受け取り、トークンエンドポイントでトークンに交換します。

curl -X POST "https://id.mock.klon.you/api/oidc/v1/token" \
  -u "your-client-id:your-client-secret" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<AUTHORIZATION_CODE>" \
  -d "redirect_uri=https://rp.example.com/callback" \
  -d "code_verifier=<CODE_VERIFIER>"

SDK を使う場合

ここまで HTTP の動きを見やすくするため curl で示しましたが、実装では KLON SDK を使うと記述が簡潔になります。 TypeScript SDK では、createAuthorizationURLusePAR: true を渡すと PAR への POST まで SDK が処理します。 authorizationDetailstype も SDK が補完するので、identifiersactions を渡すだけで済みます。

import {
  createClient,
  Scopes,
  Resources,
} from "@pocketsign/klon-sdk-typescript";

const client = createClient({
  issuer: "https://id.mock.klon.you",
  clientId: "your-client-id",
  clientSecret: "your-client-secret",
  redirectUri: "https://rp.example.com/callback",
});

const { url, session } = await client.createAuthorizationURL({
  scopes: [Scopes.OPENID, Scopes.OFFLINE_ACCESS],
  authorizationDetails: [
    {
      identifiers: [Resources.EMAIL_ADDRESS],
      actions: ["read"],
      required: true,
    },
  ],
  usePAR: true,
});

// session を保存してから url へリダイレクトする
// コールバックでは client.exchangeCode(code, state, session) でトークンに交換する

Go SDK にも同じく CreateAuthorizationURLUsePAR オプションがあります。 SDK は statenonce、PKCE の生成と ID トークンの検証まで担います。

実装時の注意点

  • request_uri には有効期限がある。expires_in を過ぎると使えないので、取得後はすぐ認可エンドポイントへ進む
  • request_uri は使い捨てである。認可リクエストごとに新規発行し、一度だけ使う
  • PAR を使っても statenonce、PKCE は不要にならない。CSRF やリプレイ、認可コード横取りへの対策として引き続き必要になる

まとめ

authorization_details は権限を構造化して細かく表現する仕組みで、PAR はその認可リクエストを事前に認可サーバーへ登録して、URL を短くしつつ事前検証も可能にする仕組みです。独立した 2 つですが、組み合わせると効果的です。Link v2 では、PAR エンドポイントへ POST して受け取った request_uri を認可エンドポイントに渡すだけです。これで、複雑な権限要求と安全な認可リクエストの両方に対応できます。

参考