OAuth2の次に来ると言われる認可プロトコルGNAPとはなにか

Merpay Advant Calendar 2020、23日目の記事は、趣味で認証認可をやっている @nerocrux が送りいたします。

最近 GNAP という認可プロトコルのワーキンググループドラフトが出ていて頑張って細かく読みましたので、(次回はいい加減に仕事でやってることについてお話しますが)今回はその GNAP について紹介させてください。

GNAP とはなにか?

GNAP は Grant Negotiation and Authorization Protocol の略で、認可のプロトコルです。Justin Richerさんという方を中心に策定しています。作者によると、GNAP の発音は げなっぷ になります。

認可(Authorization)プロトコルと言えば、OAuth 2.0 (RFC6749) が広く知られ、運用されています。GNAP は OAuth 2 の後継として認知され、一時的に OAuth 3 と呼ばれた時期もありますが、OAuth 2 と互換性がないので、基本的に別物だと考えるとよいです。ただ、GNAP は OAuth 2 のさまざまな部分を参考にしていますので、完全に無関係ではないですし、GNAP の思想を理解するには結局 OAuth 2 とその拡張仕様を読むことになるでしょう(泥沼)。

認可プロトコルとOAuth 2

サービスはユーザ(Resource Owner)の同意の元、別のサービスやアプリケーション(Client または Resource Client)にメールアドレス、住所、残高などのリソース(Resource)をAPI経由で操作する権限を提供することで、サービスとサービスを連携させ、付加価値をユーザに提供できると期待します。

例えばメルカリユーザのAさんが、メルカリ以外のECサイト上で買い物するときに、メルカリIDとECサイトのIDを連携させたい場合を考えます。ECサイトに対して、メルカリに登録されているAさんの本人情報へのアクセス権限を与え、ECサイト上で商品を購入する際に、住所や電話番号の入力を省略できれば、より便利にECサイトを利用できます。

その際にやりたいのは

  • メルカリ上にあるリソースにアクセスする権限を安全に第三者に提供する
  • リソースをすべて提供するではなく、利用者が提供範囲の制御をできるようにする
  • ユーザのメルカリ ID / PW は当然ながら第三者のサービスに渡したくない(いくら信用できるとしても)

これらの要望を実現するために登場したのは、標準化にされた認可プロトコルの数々です。その中で一番広く利用されているのはおそらく OAuth 2 です。

OAuth 2 は策定されてからそろそろ 10 年が経過しようとしています。策定してから、その仕様の周りに膨大な仕様群が追加されています。新たな攻撃パターンが発覚すれば、それを補う形のセキュリティを向上するための仕組みや勧告もあれば、機能の拡張仕様も存在しています。

また、OpenID Connect という、OAuth 2の上に認証のレイヤーを構築した仕様も策定されていて、その仕様も広く採用され、数多くの周辺仕様が策定され続けています。

例えば最近では、バンキングの分野でOpenID Connectを利用するということで、FAPI というワーキンググループから、よりセキュア、より利便性の高い仕様を実現するために、RAR, PAR, JARM, CIBA などの拡張仕様が提案されていて、界隈の人たちが盛り上がっています。

OAuth 2 はより高機能に、よりセキュアになっていますが、大変複雑な仕様になっており、勉強コストが非常に高くなっています。またコア仕様に根本的にあまりセキュアじゃない、あくまでもベストプラクティスでカバーしようとしている部分があります(例えば Implicit Flow や、フロントチャンネルでの秘匿性のある情報の交換など)。それを改善するために、コア仕様の次のバージョン OAuth 2.1 も策定されています。

GNAP の歴史

2年前にJustinさんはIdentiverseというイベントの「What’s Wrong with OAuth2?」というセッションでOAuth 2 の問題点を指摘しました。そして同じ年に「そろそろOAuth 2から前進しませんか」という記事を投稿しました。その文章の中では、GNAP の根幹となる、後述する「フロントチャンネルでの秘匿性ある情報交換の最小化」の考え方や、「トランザクショナルの認可リクエスト」、「インタラクション」を行う方法などのアイディアについて紹介しました。

2018年末 ~ 2019年にJustin さんは xyz と呼ばれるプロジェクトを作り、実装で検証を行いながら仕様の基礎となる部分を策定しました。Identiverse 2019 (2019年7月), IETF 105 (2019年7月), IETF 106 (2019年11月) での発表を経て、IETF上に TxAuth というワーキンググループが作られ、ドラフト仕様が発表されました。

今年7月に、TxAuth WG が GNAP にリネームされ、そして10月にこのワーキンググループのコア仕様である draft-ietf-gnap-core-protocol の最初の revision がリリースされました。

本記事が公開される時点で、revision 2 が公開されており、PDFで読むとなんと、120ページ以上の超大作になっています。これを細かく読むにはそれなりの気力が必要ですし、作者の考え方を理解するには数多くの背景知識が必要で、色々と調べる必要があります。本記事では GNAP の基本的な概念を紹介し、実際のユースケースを通して GNAP がどのように動作するかをかんたんに説明します。元仕様は非常に膨大なのですべてをここで紹介できませんので、興味があればぜひ原文をお読みください。

GNAP の基本的な考え方

GNAP は最初から OAuth 2 との互換性を担保する形で策定されるわけではなく、完全に新しいプロトコルになります。

GNAP の設計の中で、以下3つのポイントが重要だと考えています。

  1. フロントチャンネルのセンシティブ情報交換の最小化
  2. JSON により表現された、しっかりと定義されたデータモデル
  3. トランザクションモデルと多様なインタラクション方式

1. フロントチャンネルのセンシティブ情報交換の最小化

OAuth 2 の仕様では、ブラウザリダイレクトなどを通して、複数のパーティの間でやり取りするリクエスト仕様が定められていて、ブラウザリダイレクトによる認可コードやトークンの受け渡しの仕組みもあり、攻撃を受けやすい部分がありました(そのため、数多くのセキュリティ向上のための拡張仕様が策定されました)。

GNAP ではブラウザリダイレクトをなるべく抑え、重要な秘匿性のあるデータのやり取りは基本的にバックチャンネル(server to server)で行っています。セキュリティを向上するだけでなく、クエリパラメータでは表現しづらい情報を JSON 形式で表現豊かにサーバ間でやり取りできます。

2. JSON により表現される、しっかりと定義されたデータモデル

OAuth 2 のコア仕様では Client、認可サーバ、リソースなどをどう表現するかは明確に定義されてなくて、拡張仕様中に定義されるケースがあります。

例えば Client のメタ情報は、OAuth 2.0 Dynamic Client Registration Protocol という RFC の中で定義されています。

GNAP では、認可に参加する各コンポネントをどのように表現するかを最初から明確に定義しようとしています。リクエスト・レスポンスに含まれるデータが JSON 形式によって表現されることで、これらの情報は定義しやすくなっています。

3. トランザクションモデルと多様なインタラクション方式

一つの認可セッションでは、開始から終了までに複数のパーティの間の複数のリクエストから構成されます。攻撃を防ぐために、すべてのリクエストの正当性を確かめ、かつ同一セッションに属することを担保するのが重要です。

これを実現するには、GNAP はフロントチャンネルでの通信回数を減らし、公開鍵暗号方式や、interact ハンドルなどの仕組みを利用することで、OAuth 2 に比べるとより柔軟に、よりセキュアにリクエストの正当性を担保できると考えます。これを基礎にして、GNAP のクライアントは自分の特性に合わせて、認可サーバとのやり取り回数や形が異なるインタラクション方式を自由に選択できます。

GNAP は OAuth 2 とは互換性がないものの、OAuth 2 が利用できるユースケースを GNAP でもカバーできるように設計されています。OAuth 2 のユースケースの中では、Client がブラウザに限らず、リダイレクトベースのインタラクションができないケース(例えば Device Flow)もあるし、権限を取得する Client は認可を行う端末と同じ端末じゃないケース(例えば CIBA)もあります。

異なるインタラクションを求める Client に対して、リクエストとレスポンスの形は標準化されたものを利用し、回数やパラメータを変えることで、さまざまなユースケースに対応できる柔軟性を GNAP は持っています。

※ 余談ですが、前述通り GNAP はさまざまな OAuth 2 の仕組みを意識していました。筆者の主観ではありますが、GNAP は以下の仕組みを参考にしていると思われます。

  1. OpenID Connect の認証レイヤー相当の機能
  2. CIBA や Device Flow のような Decoupled Authorization の機構
  3. UMA 2.0 のような Distributed Authorization の概念
  4. Front-channel 通信によって生じる問題を軽減できる PAR (Pushed Authorization Request) (複雑な認可パラメータを back-channel で予め認可サーバに "push" し、Front-channelで投げるパラメータを隠蔽することで安全性の向上をはかる)
  5. Scope を超えた、よりリッチな要求リソースの表現 RAR (Rich Authorization Request)
  6. リクエストやレスポンスの改ざんを防ぐための諸々(Detached JWS や PoP, DPoP など)

それでは、GNAP の仕様について細かく見てみましょう!

GNAP の登場人物

GNAP はプロトコルに参加するパーティを以下のロールに振り分けています。

  • Authorization Server (AS): 認可サーバ。RO に対する権限移譲情報を管理し、RC にトークンを発行します。Grant エンドポイントを持ち、JSON payload の HTTP POST リクエストを受け付けます。その他に、Interaction エンドポイントと User Code エンドポイントなども持っています。
  • Resource Client (RC / "client"): ASからトークンを取得し、RSにトークンを利用します。ユニークキーを持ち、ASから識別され、特定の認可ポリシーが適用されます。
  • Resource Server (RS / "API"): RCからトークンを受付、ROの代理として、許可されたリソースの提供を行います。
  • Resource Owner (RO): RCからRSへのリクエストを許可する側で、ASとインタラクションします。
  • Requesting Party (RQ / "user"): RCを操作するユーザで、RCとインタラクションします。

OAuth 2 のコア仕様では、Requesting Party と Resource Owner は同じ人物で、Resource Owner の概念しか存在しませんでしたが、OAuth 2の拡張である UMA 2.0 では、Resource Owner に異なる人物が Resource Owner のリソースへのアクセス権限を要求することができるため、Requesting Party という概念が導入されています。GNAP では OAuth 2 の拡張機能も参考し、互換性が無いものの、同等な機能を提供できるように設計していると考えます。

ケーススタディ: リダイレクトベースインタラクションによる認可の場合

前述のように、GNAP は Client が自分の特性に合わせて、インタラクション方式を選択することができます。これによって OAuth 2 / OpenID Connect でいう Authorization Code Grant、Client Credential Grant、Device Flow、CIBA などのフローが実現可能になります。

本記事では、最もユースケースの広いと思われる Authorization Code Grant に相当する認可プロセスの GNAP での実現方法を紹介します。実例を通して GNAP の雰囲気をつかめたらよいかと思います。

まず全体的な流れは下図のようになります。

Step 1. RQ(ユーザ)がRC(アプリケーション)を操作し、認証→認可のフローを開始させます。

リダイレクトベースのインタラクションモードでは、認可を要求するRQとASと認証・認可のインタラクションは同じパーティ(人物)なので、基本的にRQとROは同じ人物です。

このステップでは、ユーザが RC 上で「〇〇サービスと連携する」のようなボタンを押すことで、認可フローを開始します。

Step 2. RC は該当ユーザのリソースを操作する権限を AS に対して要求します。

この際、RC は Access Request を AS に送信します。リクエストの例は以下になります。

POST /authorize HTTP/1.1
Host: auth.example.com
Content-type: application/json

{
    "resources": [
        {
            "type": "photo-api",
            "actions": [
                "read",
                "write",
                "dolphin"
            ],
            "locations": [
                "https://server.example.net/",
                "https://resource.local/other"
            ],
            "datatypes": [
                "metadata",
                "images"
            ]
        },
        "dolphin-metadata"
    ],
    "client": {
      "display": {
        "name": "My Client Display Name",
        "uri": "https://example.net/client"
      },
      "key": {
        "proof": "jwsd",
        "jwk": {
                    "kty": "RSA",
                    "e": "AQAB",
                    "kid": "xyz-1",
                    "alg": "RS256",
                    "n": "kOB5rR4Jv0GMeL...."
        }
      }
    },
    "interact": {
        "redirect": true,
        "callback": {
            "method": "redirect",
            "uri": "https://client.example.net/return/123455",
            "nonce": "LKLTI25DK82FX4T4QFZC",
            "hash_method": "sha3"
        }
    },
    "capabilities": ["ext1", "ext2"],
    "subject": {
        "sub_ids": ["iss-sub", "email"],
        "assertions": ["id_token"]
    }
}

このリクエストの中に、主に以下の部分が含まれています。

name what
resources 権限要求の対象となるリソース。
client AS に提示する RC 自分自身のアイデンティティ及びその証明。
interact RC が実行可能な、ユーザとのインタラクション方式。
capabilities (まだ詳細未定義) RC が持つ capabiliry(能力)。
現時点ではまだはっきりと定義されていない(将来的にはエクステンションとして定義可能となる)。
subject ユーザ情報も要求する場合、このフィールドを設定する。

リクエストの各パーツを細かく見てみましょう。

(1) resources

このフィールドでは、RC が要求するリソースについて記載しています。

type, actions, locations などのフィールド名をみると、Digital Identity 界隈の方はすぐにRARのauthorization_detailsを思い出すでしょう。

OAuth 2 では、要求するリソースにアクセスできる権限を scope で表現しますが、1 scope = 1 文字列なので、表現力が貧しく感じるユースケースが存在します。

GNAP のリクエストボディは JSON 形式になってますので、RAR を鑑みてより豊かな表現形式を採用しています。

  • type: リソースの種類
  • actions: 操作の種類
  • locations: RS の URI
  • datatypes: RC に対して、RS が提供できるデータの種類

ただ、GNAP は OAuth 2 の scope に相当する表現方法もサポートしていて、AS が解釈できれば、例にある dolphin-metadata のような記述方法でもよいです。

(2) client

AS 上には RC の公開鍵が事前に登録されています(登録方法はdraftに特に言及していません。また複数登録やローテーションの仕組みもあっていいはずだが、定義されていません)。

RC が Access Request を送信するために、自分の公開鍵を clientフィールドを通して AS に提示し、自分のアイデンティティを示します。また、後述の キーバインディング の仕組みを利用して、リクエストに含まれたアイデンティティの有効性を証明します。

この例では、jwsd(key.proofフィールド)方式によって、RC から AS に送信するリクエストが提示された鍵(key.jwkフィールド)にひも付くことを示しています。AS はこの公開鍵を利用して RC からのリクエストを検証します。

(3) interact

このフィールドのなかでは、 RC は「自分が対処可能なインタラクションモード」を記載します。draft にインタラクションモードいくつかが定義されていますが、次のパートで詳しく説明します。

この例では、RC が redirectcallback モードを対応可能を示しています。

  • redirect: 任意のURLへのリダイレクトが可能であることを示す。
  • callback: ユーザインタラクション後に、AS からのコールバックを受信できることを示し、AS → RC にredirect方式でuriへ遷移する、その中に指定されたnonceを含めてくださいと指示しています。

(4) capabilities

draft 上ではまだ TBD なので、省略します。

(5) subject

RC が RQ のアイデンティティを知っていれば、subject フィールドにユーザのアサーションを入れて AS に提示できます。これは OpenID Connect でいう login_hint または id_token_hint に近いものだと理解しています。

この情報を AS がどう取り扱うかは AS 側の自由ですが、例えば AS 側で認証を行う際(Step 5)、アサーションに提示されたユーザをデフォルトで表示し認証してもらったり、異なるユーザで認証した場合 AS が Access Request を拒否したりするなどが考えられます。

Step 3. AS が Grant Response を RC に返却し、インタラクションを要求します。

AS が Access Request を処理し、インタラクションに必要な情報を RC に返却します。今回の例では、RC は redirect インタラクションモードを可能とし、AS がそれを選択したとすると、以下のようなレスポンスが AS から RC に返されます。

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
    "interact": {
        "redirect": "https://server.example.com/interact/4CF492MLVMSW9MKMXKHQ",
        "callback": "MBDOFXG4Y5CVJCX821LH"
    },
    "continue": {
        "access_token": {
            "value": "80UPRY5NM33OMUKMKSKU",
            "key": true
        },
        "uri": "https://server.example.com/tx"
    }
}

interact フィールドには、リダイレクトベースのインタラクションに必要な redirect URL (redirectフィールド)や、コールバック時に不正防止のための nonce 値(callbackフィールド)が含まれています。

continue フィールドには、インタラクションが終わった後に、RC から継続的にリクエストを AS に送る際に必要な情報が含まれています。 この例では、継続リクエストの中に含める必要があるアクセストークン(access_token.valueフィールド)が含まれており、継続リクエストの送信先(uriフィールド)が提示されています。また、継続リクエストでは、アクセストークンを含めたリクエスト全体をキーバインディングの仕組みによって保証される必要性があると示しています(access_token.keyフィールド)。

Step 4. インタラクションを行うため RC が RQ を AS 指定の URL にリダイレクトします。

インタラクションモードはredirectなので、RC が RQ を AS のレスポンスに指定されている URL (interact.redirectフィールド)にリダイレクトします。

リクエスト例

HTTP/1.1 302 Found
Location: https://server.example.com/interact/4CF492MLVMSW9MKMXKHQ

Step 5. 認証

AS にリダイレクトされ、認可を行う前に、まず RQ(RO)のアイデンティティを判明するために認証を行います。具体的にどのように認証されるかは仕様の範囲外になりますが、ID/PW、out-of-bound、FIDO などの認証手段(または組み合わせたMFA)を利用して認証を行います。

Step 6. 認可

RQ(RO)のアイデンティティを確認したあとに、AS は同意画面のようなものを RQ(RO)に提示します。画面上に RC が要求する & AS が付与してよいと判断する権限が表示されます。

ユーザはその権限を RC に付与してよいと判断するなら、同意ボタンを押します。

Step 7. インタラクション完了、コールバックします。

ユーザと AS の間に、認証認可の作業が完了したあとに、AS から RC にリダイレクトバックします。

ブラウザリダイレクトを行うのは、 Step 2 の Access Request の interact.callback フィールドに redirect と指定し AS が受け入れたからです。

Access Request の interact フィールド(再掲)

...
    "interact": {
        "redirect": true,
        "callback": {
            "method": "redirect",
            "uri": "https://client.example.net/return/123455",
            "nonce": "LKLTI25DK82FX4T4QFZC"
        }
    },
...

リダイレクトバック先の URL は、この callback フィールドに指定している URI であり、RC が指定する nonce 値もリクエストに含まれます。

リクエスト例

HTTP/1.1 302 Found
Location: https://client.example.net/return/123455
  ?hash=p28jsq0Y2KK3WS__a42tavNC64ldGTBroywsWxT4md_jZQ1R2HZT8BOWYHcLmObM7XHPAdJzTZMtKBsaraJ64A
  &interact_ref=4IFWWIKYBC2PQ6U56NL1

このリクエストの中では、interaction_refhashが含まれます。それぞれの役割は以下となります。

  • interact_ref: インタラクションリファランス値。ここではまだトークンを発行しませんので、RC が AS に次に送る、継続リクエスト(Continue Request)にこの値が利用されます(複数のリクエストが一つのトランザクションのようなものになる形にはこの値が使われます)。
  • hash: このリダイレクトを進行中のトランザクションにひも付き、RC をセッション固定/インジェクションなどの攻撃から守ります。

hash 値は、以下のステップによって計算します。

  1. 以下3つの値を改行でつなぎます。padding や whitespace が含まれません。

a. RC が AS に送る interact に含まれる nonce の値 b. AS が Grant Response に入れた nonce 値(interact.callbackフィールド) c. interact_ref

LKLTI25DK82FX4T4QFZC
MBDOFXG4Y5CVJCX821LH
4IFWWIKYBC2PQ6U56NL1
  1. 上記値を RC が指定する hash_methodinteract.callback.hash_methodフィールド)でハッシュ化します。

本記事の例では、sha3としているため、入力を 512-bit SHA3 アルゴリズム でハッシュ化し、URLセーフなBase64(paddingなし)でエンコーディングします。

そのほかには、sha2(512-bit SHA2 アルゴリズム)もサポートされています。Access Request 送信時に hash_method 値は省略可能で、デフォルトのsha3になります。

Step 8. Access Request の継続

AS からのリダイレクトを受けた後に、RC は hash値を検証し、継続リクエストを AS に送信します。

送信リクエストは RC の秘密鍵によって署名され、AS からもらった interact_ref も含まれます。これによって、一連のリクエストが「トランザクショナル」の形になり、一つの認可セッションとなります。

リクエスト例

POST /continue HTTP/1.1
Host: server.example.com
Content-type: application/json
Detached-JWS: ejy0...

{
  "interact_ref": "4IFWWIKYBC2PQ6U56NL1"
}

Step 9. Access Request 成功、アクセストークン発行します。

AS が RC からの継続リクエストを受信したあとに、AS がリクエストの有効性(署名検証など)を確認したあとに、interact_refによってトランザクションの状況を確認します。

詳細は定義されてませんが、例えば認証済み(Step 5)のユーザが当 RC に対して認可(Step 6)していて、かつ一連のリクエストは有効なものであることを AS が確認取れているなら、AS は RC にアクセストークンを発行してよいと判断し、実施します。

レスポンス例

HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
    "access_token": {
        "value": "OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0",
        "key": false,
        “expires_in”: 3600,
        "manage": "https://server.example.com/token/PRY5NM33OM4TB8N6BW7OZB8CDFONP219RP1L"
    },
    "subject": {
        "sub_ids": [ {
           "subject_type": "email",
           "email": "user@example.com",
        } ]
    }
}

レスポンスの中に、access_token フィールドにトークン値(valueフィールド)と、RS 提示時にキーバインディング必須かどうかのフラグ(keyフィールド、後述)と、トークンを管理するrevoke, rotate などに利用する専用のAPI(manageフィールド)が含まれます。

また、subject フィールドには、RO のユーザ情報が含まれます。

Step 10. RC から RS にアクセストークン付きでリクエストを送信し、リソースを要求します。

RC は Step 9 にて受信したアクセストークンを RS に提示し、リソースを要求します。

アクセストークンは、Authorization ヘッダーに含まれ、スキームに(おなじみのbearerではなく) GNAP prefix が付きます。

リクエスト例

POST /authorize HTTP/1.1
Host: auth.example.com
Content-type: application/json
Authorization: GNAP OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0
Detached-JWS: eyj0....

...

Step 9 の Grant Response にある access_token.key の値が true になっていれば、RC は AS と通信するときに使っていたキーバインディングの仕組みを利用して、RS に送信するリクエストを署名しなければなりません。RS は introspect endpoint を通して AS から RC の公開鍵を取得し、署名検証を行います。

introspect リクエスト例

POST /introspect HTTP/1.1
Host: auth.example.com
Content-type: application/json
Detached-JWS: ejy0...

{
    "access_token": "OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0",
}

introspect レスポンス例

Content-type: application/json

{
    "active": true,
    "resources": [
        "dolphin-metadata", "some other thing"
    ],
    "client": {
      "key": {
        "proof": "httpsig",
        "jwk": {
                    "kty": "RSA",
                    "e": "AQAB",
                    "kid": "xyz-1",
                    "alg": "RS256",
                    "n": "kOB5rR4Jv0GMeL...."
        }
      }
    }
}

Step 11. RS サーバからリソースを返却します。

RS が RC から提示されたアクセストークンを検証し、問題なければ要求された操作を実行します。

リクエストが署名されている場合、RS が AS と同様に、RC の公開鍵を用いて署名を検証しなければなりません。

GNAP の仕様

インタラクションモード

リダイレクトベースインタラクションの事例では、redirectcallback の2つのインタラクションモードを紹介しました。

この2つのモード以外に、GNAP が以下 2 種類のインタラクションモードを定義しています。

  • アプリケーション起動(Access Request の interact 値は app
  • ユーザコード(Access Request の interact 値は user_code

app モードでは、RC が URL によってアプリケーションを起動し、インタラクションを行うと定義しています。アプリケーションを起動するなら redirectモードでもできそうですが、モードを分けるのは web ベースのインタラクションと app (native) ベースのインタラクションを明示的に区別したいだと思われます(おそらく app2app のようなユースケースをカバーするイメージ)。

user_code モードでは、AS が RC に簡易なコードを返却し、RC がそれをユーザに表示します。ユーザは別の端末上で AS にアクセスし該当コードを入力することでインタラクションを行います。このモードについて、後ほどの実例で説明します。

これらのインタラクションモードをトランザクションを各フェーズでうまく利用することで、さまざまな認可シナリオを実現できます。

キーバインディング

GNAP は公開鍵暗号方式を利用したキーバインディング(Binding Keys)の仕組みを定義しています。

RCは、秘密鍵を利用してHTTPリクエストを署名し、公開鍵と一緒にASあるいはRSに提示します。受信側は公開鍵で署名を検証してリクエストは特定のRCインスタンスからのものであることを検証します。

GNAP の draft では、6種類のキーバインディングの仕組みを定義しています。

  1. Detached JWS
  2. Attached JWS
  3. Mutual TLS
  4. DPoP
  5. HTTP Signing
  6. OAuth PoP

一つの方式が一つのRFCに対応しているので記事では紹介しきれないですが、雰囲気をつかめるために、ここでは Detached JWS を例として実際のリクエストを見てみましょう。

リクエスト例

POST /tx HTTP/1.1
Host: server.example.com
Content-Type: application/json
Detached-JWS: eyJiNjQiOmZhbHNlLCJhbGciOiJSUzI1NiIsImtpZCI6Inh5ei0xIn0.
  .Y287HMtaY0EegEjoTd_04a4GC6qV48GgVbGKOhHdJnDtD0VuUlVjLfwne8AuUY3U7e8
  9zUWwXLnAYK_BiS84M8EsrFvmv8yDLWzqveeIpcN5_ysveQnYt9Dqi32w6IOtAywkNUD
  ZeJEdc3z5s9Ei8qrYFN2fxcu28YS4e8e_cHTK57003WJu-wFn2TJUmAbHuqvUsyTb-nz
  YOKxuCKlqQItJF7E-cwSb_xULu-3f77BEU_vGbNYo5ZBa2B7UHO-kWNMSgbW2yeNNLbL
  C18Kv80GF22Y7SbZt0e2TwnR2Aa2zksuUbntQ5c7a1-gxtnXzuIKa34OekrnyqE1hmVW
  peQ

{
    "resources": [
        "dolphin-metadata"
    ],
    "interact": {
        "redirect": true,
        "callback": {
            "method": "redirect",
            "uri": "https://client.foo",
            "nonce": "VJLO6A4CAYLBXHTR0KRO"
        }
    },
    "client": {
      "proof": "jwsd",
      "key": {
        "jwk": {
                    "kty": "RSA",
                    "e": "AQAB",
                    "kid": "xyz-1",
                    "alg": "RS256",
                    "n": "kOB5rR4Jv0GMeLaY6_It_r3ORwdf8ci_JtffXyaSx8
xYJCNaOKNJn_Oz0YhdHbXTeWO5AoyspDWJbN5w_7bdWDxgpD-y6jnD1u9YhBOCWObNPF
vpkTM8LC7SdXGRKx2k8Me2r_GssYlyRpqvpBlY5-ejCywKRBfctRcnhTTGNztbbDBUyD
SWmFMVCHe5mXT4cL0BwrZC6S-uu-LAx06aKwQOPwYOGOslK8WPm1yGdkaA1uF_FpS6LS
63WYPHi_Ap2B7_8Wbw4ttzbMS_doJvuDagW8A1Ip3fXFAHtRAcKw7rdI4_Xln66hJxFe
kpdfWdiPQddQ6Y1cK2U3obvUg7w"
        }
      }
      "display": {
        "name": "My Client Display Name",
        "uri": "https://example.net/client"
      },
    }
}

Detached-JWS ヘッダーに含まれている JWS は、以下の手順で作られます。

  1. 通常のRFC7515のやり方でJWSを作成します。
    • Header の部分は最初の Access Request に AS に提示した鍵を利用して署名することを示します。
    • Payload の部分は、このHTTPリクエストのボディを利用します。
    • Header と Payload を Base64 URL でエンコーディングし、.をくつけて、秘密鍵で署名します。
  2. JWS の payload 部分は、HTTPリクエストのボディ部分として送信されますので、JWS から消します。

検証側(AS)は、HTTPボディを Base64 URL をエンコーディングし、Detached-JWS ヘッダーにある JWS の payload 部分としてセットしてから、通常の JWS として検証します。

また、署名用の鍵は、正当な鍵であることを検証します。

このようなキーバインディングの仕組みを利用して、一つのトランザクションのすべてのリクエストが正当性のある同じ RC インスタンスから送信されることを担保します。

ハンドル

GNAP には、ハンドルと呼ばれている仕組みがあります。AS が特定のデータオブジェクトに紐づくOpaqueな文字列を発行し、RC がその値をデータオブジェクトの代わりに利用することで、front-channel で送信したくないデータを隠蔽したり、毎回デカいリクエストを組み立てなくて済むなど、利便性の向上にも繋がります。

仕様によれば resource, user, display, key など、さまざまなデータモデルをハンドル化することができます。

その他

その他にも GNAP がいろんな機能を定義していて、例えばトークンのローテーションをやりやすくするために、一つのGrant Response で複数のアクセストークンを発行する機能もありますし、すでに送信済みの Access Request の情報を更新する仕組みも存在しています。記事はすでにだいぶ長くなっているので、詳しい解説を割愛します。

その他の利用事例紹介

GNAP の draft には、先程紹介したリダイレクトベースインタラクションの事例以外に、さまざまなユースケースを紹介していますので、最後にこれを見てみましょう。

1. ユーザコードインタラクション

このインタラクションモードは、OAuth 2 でいう「Device Flow」に相当するものだと思われます。RC は「入力インターフェースがない、あるいはあまり利便性が高くない」デバイスを想定しています(テレビや、ディスプレイしかないIoTデバイスなど)。

全体の流れは以下になります。

このインタラクションモードを要求するには、RC は interact 値が user_code のリクエストを AS に送信します。

"interact": {
    "user_code": true
}

こうすると AS からの Grant Response の中では、以下の形でユーザに入力させるためのコードが含まれます。また、コード入力するための URL も含まれます。

    "interact": {
        "user_code": {
            "code": "A1BC-3DFF",
            "url": "https://srv.ex/device"
        }
    }

この URL は二次元バーコードや短縮URLなどの形で、code と合わせてユーザに提示されます。ユーザはスマホなど入力インターフェースのあるデバイスで URL を開き、認証→コード入力→認可の順番で AS 上でインタラクションを行います。

ユーザが認可完了するまで、RC が AS に対して継続リクエストを一定間隔(ASが指定可能)でポーリングで投げ続けて、ユーザが認可した次の Grant Response で AS がトークンを発行し RC に返します。

2. 非同期認可

このユースケースでは、RO が RQ とは別のパーティで、RO が直接に RC とインタラクションしないことを想定しています。これは CIBA のような decoupled authorization のユースケースを意識しているではないかと考えています。

例えば、子供がコンビニで親のクレカで買い物しようとしますが、子供には暗証番号を教えずに、代わりに親が自分のスマホを利用して、リモートからこのトランザクションを承認するようなシナリオが考えられます。この際に、このインタラクションモードが利用できるではないかと思います。

シーケンスは、ユーザコードインタラクションに近いわかりやすいと思いますので、詳しい説明を割愛しますが、完全に余談として、CIBA と比較してみたいです。CIBA では、Poll, Ping, Push の3つのモードを定義していまして、この例では CIBA の Poll モードに近い形になっていますが、Access Request の中の callbackinteract 方式を push (ref)に指定すれば、CIBA の Push モードと同じ形でトークンを受信できるのではないかと想像しています。

3. ソフトウェアオンリーの認可

この例では、RO(RQ)のコンテキストがなく、RC が AS との間に直接に認可を行うパターンです。これは OAuth 2 でいう Client Credentials Grant に相当するものだと考えられます。

Access Request の中にクライアントのアイデンティティが検証され、RC が要求しているリソースへのアクセス権限は、ユーザインタラクションを介さずに RC に付与してよいと AS が判断すれば、そのままトークンを払い出します。

4. 有効期限切れのアクセストークンの更新

GNAP では OAuth 2 と違って、リフレッシュトークンが存在しません。しかしアクセストークンに有効期限の概念はあってもよいので、期限切れになったらなにかしらの方法で新しいトークンを取得しなければならない点は、OAuth 2 と変わらないです。

GNAP のアクセストークンが発行されたときに、通常そのトークン専用の Token Management URL がトークンと一緒に RC に渡されます(access_token.manageフィールド)。このエンドポイントに既存のアクセストークンを投げると、新しいトークンが発行されます(GNAP はこれをリフレッシュではなく "ローテート(rotate)" と呼んでいます)。

リクエスト例

POST /token/PRY5NM33OM4TB8N6BW7OZB8CDFONP219RP1L HTTP/1.1
Host: server.example.com
Authorization: GNAP OS9M2PMHKUR64TB8N6BW7OZB8CDFONP219RP1LT0
Detached-JWS: eyj0....

レスポンス例

{
    "access_token": {
        "value": "FP6A8H6HY37MH13CK76LBZ6Y1UADG6VEUPEER5H2",
        "key": false,
        “expires_in”: 3600,
        "manage": "https://server.example.com/token/PRY5NM33OM4TB8N6BW7OZB8CDFONP219RP1L",
        "resources": [
            {
                "type": "photo-api",
                "actions": [
                    "read",
                    "write",
                    "dolphin"
                ],
                "locations": [
                    "https://server.example.net/",
                    "https://resource.local/other"
                ],
                "datatypes": [
                    "metadata",
                    "images"
                ]
            },
            "read", "dolphin-metadata"
        ]
    }
}

ちなみに、トークンをRevokeする際にも、Token Management URL を利用します。

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

終わりに

ここまでお読みいただきありがとうございます。そして、お疲れさまでした。

GNAP は WG Draft になったとはいえ、この膨大な仕様はまだ初期策定段階だと感じています。Draft 本文に editor's note という形で作者の注釈が大量に記載されていて、その中に作者自身がまだ迷っているところ、疑問と感じているところ、コミュニティメンバーと相談したいところなどが記載されています。また、github 上に仕様に関する Issue は100件を超えています。仕様が広くadoptされるのはもちろん、RFC化になるまでにももうちょっと時間がかかりそうですね。

OAuth2/OIDC は非常に難しく多大な勉強コストが必要です。クライアント側はともかく、OAuth 2/OIDC を利用した認証認可基盤を構築するときに、仕様的な難しさが実感されます。その難しさは、リクエスト・レスポンスパラメータが多い、セキュリティへの配慮が大変、周辺の関連仕様のが複雑などのところは当然ありますが、もっと難しいのはこれらの仕様を正しく応用し、セッション管理など仕様が定義されてない部分を含めて基盤を正しく設計することだと感じています。 GNAP は OAuth 2に比べると、コア仕様の複雑度が更に高く、全部理解しきって正しく応用できるまでそれなりに時間がかかりそうな気がします。しかしGNAP は最初から OAuth 2 の拡張が解決しようとする問題点をコア仕様の中で考慮して設計するので、拡張仕様が大量に出てこないことを期待しています。また、複数のユースケースを一つの(一貫性のある)仕様でカバーできるので、勉強・実装コストが OAuth 2 より低い気がしています。

One More Thing

今年も認証認可アドベントカレンダーにも参加しています。そこの記事では、GNAP Draft の読書メモを公開しています。興味があればチェックしてみてください。読書メモと言っても、ほぼ翻訳になってしまってますが、適当に感想を入れたりしてますので、参考になれたら幸いです。

また、メルペイではミッション・バリューに共感できるBackendエンジニアを募集しています。一緒に働ける仲間をお待ちしております。 ソフトウェアエンジニア (Backend)

明日のMerpay Advent Calendar 2020 執筆担当は、iOS Engineer の takeshi さんです。引き続きお楽しみください。