msksgm’s blog

msksgm’s blog

Webエンジニアです.日々の勉強,読書,映画観賞,美術観賞の記録を載せます.

OAuthについてまとめてみる3 OAuthのトークン(JWT、JOSE、Token Introspection、TokenRevocation)について編

OAuth の勉強会のために「OAuth 徹底入門 セキュアな認可システムを適用するための原則と実践」 を読んでいます。

まとめた資料を記事にしました。

今回は、OAuth のトークンについて記述していきます。

OAuth におけるトークンとは何か?

トークンはすべての OAuth のやりとりにおいて中核となるもの。
クライントは認可サーバーからトークンを取得して、保護対象リソースに渡す。
認可サーバーはトークンを生成して、クライアントに渡す。その際に、認可サーバーはリソース所有者からの権限委譲とトークンに紐づけられたクライアントの許可を管理する。
保護対象リソースはクライアントからトークンを受け取り、付与された権限とクライアントがリクエストしている権限を一致するのかを確認することで、トークンを検証する。

トークンは委譲の行為による結果(リソース所有者、クライアント、認可サーバー、保護対象リソース、スコープなど認可の決定に関わるすべてのこと)を表す(?)。

OAuth トークンの中身の定義

OAuth ではトークンの中身については定義していない。
しかし、クライアントはトークンについて知る必要はない(取得方法と使い方のみ知る必要がある)が、認可サーバーとリソース・サーバーは検証のためトークンについて理解していないといけない
それでも、OAuth2.0 の仕様で定義していないのは、状況に応じて様々な機能(有効期限の設定、取り消し、組み合わせ)をつけられるようにするためである。
この柔軟性によって、ほかの包括的なセキュリティ・プロトコル(WS-*、SAML、Kerberos など、トークンの定義がされており、システムの構成要素すべてが仕様について理解する必要があるプロトコル)では実現が難しかったケースにも適用できる。

トークンの実装方法は主に、JWT(JSON Web Toke: JWT)とトークン・イントロスペクションの2つがある。

JWT はトークン自体に情報をもたせ、トークン・イントロスペクションはトークンから、認可サーバーに問い合わせ情報を取得するするイメージ。

1. JWT(JSON Web Token)

JWT の構造

JWT は jot(ジョット)と読む。
以下のようなトークンとなっている。

eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.

ドット「.」によって3つのセクションに分割することができる。
Base64 のデコードをおこなうと、それぞれオブジェクトが現れる。

eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9
.

1つめのセクション

JWT のヘッダーとなるセクション

{
  "typ": "JWT",
  "alg": "none"
}

typヘッダーはアプリケーションがトークンの残りセクションを処理する際に、次のセクションであるペイロードが何であるかを伝えるもの。
この場合は、JWT であることを示している。

algヘッダーには、値「none」が指定されており、署名がされていないトークンであることを示している。

2つめのセクション

トークン自体のペイロード。この例では、ユーザーに関するデータを持つようになっている。

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true
}

3つめのセクション

JWT の署名についてのセクション。上記の例では空になっている。詳細は後述

JWT クレーム

JWT のクレームとは、異なるアプリケーションで使えるようにするためのフィールド。
JWT のようなトークンを使うサービスをサポートするために使われるもの。
必須ではないが、サービスによっては必須項目として定義することもできる。

クレーム名 クレームの内容
iss トークンの発行者(ISSuer)。誰がこのトークンを生成したのか。
sub トークンの対象者(SUBobject)。このトークンが誰の権限を表しているのか。
aud トークンの受け手(AUDience)。誰がトークンを受け取ることを想定しているのか。
exp トークンの有効期限(ExPiration)。いつトークンの有効期限がくるのか。
nbf トークンの有効開始時(Not-BeFore)。いつからトークンが有効になるのか。UNIX 秒。
iat トークンの発行時タイムスタンプ(Issued-AT)。いつトークンが生成されたのか。UNIX 秒。
jti トークンの一意の識別子(JwT Id)。発行者によって生成されたトークンごとに一意になる値。

また、上述の例(2つめのセクション)では、自身のアプリケーションに必要となるもの(ユーザー名を表す「name」、管理者かどうかを表す「admin」)を表すフィールドを付け加えることもできる。これらのフィールドの値は有効な JSON の値なら何でもよい。

また、JWT のフィールド名は JSON で有効な文字列ならなんでも良いが、JWT の仕様(RFC7519)では JWT を採用したシステム間での衝突を避けるために、いくつかのガイダンスを示している。
このガイダンスは、JWT がセキュリティ・ドメインをまたいで利用されることを想定している場合に特に効果的で、逆にこのような決まりがないと、同じ名前で違う意味のフィールドや違う名前で同じ意味のフィールドが定義される可能性がある。

サーバーでの JWT の実装

ソースコードhttps://github.com/oauthinaction/oauth-in-action-code )のexercises/ch-11-ex-1を参照

JWT の生成(認可サーバー)

該当箇所のリンク

var header = {
  typ: "JWT",
  alg: "none",
};
var payload = {
  ias: "http://localhost:9001/",
  sub: code.user ? code.user.sub : undefined,
  aud: "http://localhost:9002/",
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 5 * 60,
  jti: randomstring.generate(8),
};

// jwtの生成
var access_token =
  base64url.encode(JSON.stringify(header)) + // header
  "." +
  base64url.encode(JSON.stringify(payload)) + // payload
  ".";

payload の中身

{
  "ias": "http://localhost:9001/",
  "sub": "9XE3-JI34-00132A",
  "aud": "http://localhost:9002/",
  "iat": 1631430877,
  "exp": 1631431177,
  "jti": "j1g8q8Hr"
}

JWT の結果(説明のため改行、本当は一行で表現される)

eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0
.
eyJpYXMiOiJodHRwOi8vbG9jYWxob3N0OjkwMDEvIiwic3ViIjoiOVhFMy1KSTM0LTAwMTMyQSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMi8iLCJpYXQiOjE2MzE0MzA4NzcsImV4cCI6MTYzMTQzMTE3NywianRpIjoiajFnOHE4SHIifQ
.

JWT から情報を取得(保護対象リソース)

該当箇所のリンク

// inToken = JWT

// JWTの分割
var tokenParts = inToken.split(".");
// payloadのデコード
var payload = JSON.parse(base64url.decode(tokenParts[1]));
console.log("Payload", payload);
// iss(トークンの対象者)の検証
if (payload.iss == "http://localhost:9001/") {
  console.log("issuer OK");
  // aud(トークンの受け手)の検証
  if (
    (Array.isArray(payload.aud) &&
      __.contains(payload.aud, "http://localhost:9002/")) ||
    payload.aud == "http://localhost:9002/"
  ) {
    console.log("Audience OK");

    var now = Math.floor(Date.now() / 1000);

    // iat(トークンの発行時タイムスタンプ)の検証
    if (payload.iat <= now) {
      console.log("issued-at OK");
      // exp(トークンの有効期限)の検証
      if (payload.exp >= now) {
        console.log("expiration OK");

        console.log("Token valid!");

        req.access_token = payload;
      }
    }
  }
}

補足

ポイント

  1. JWT のペイロードJSON オブジェクトなので、保護対象リソースはリクエスト・オブジェクトから直接その情報にアクセスできる
  2. 上述の実装では、クライアントのソースコードはなにも記述していない。これはクライアントからすると何も知る必要がない状態になっていることを示してる。

2. JOSE (JSON Object Signing and Encryption)

これまでのやりかたでは、保護されていないため、クライアントはクライアント自身がトークンが持つ情報を操作や、偽造をできてしまう。
JSON Object Signing and Encryption(JOSE: ホセ、ホゼ)はトークンを保護するための仕様(RFC7515RFC7516RFC7517RFC7518)について言及しているもの。
この仕様とは、JSON を基盤としたデータ・モデルとして使った署名(JSON Web Signatures: JWS)、暗号化(JSON Web Encryption: JWE)、鍵の格納フォーマット(JSON Web Keys: JWK)を提供している。

いままでの例は、「署名なし JWT」であり、「特殊な場合の JSON によるペイロードを持った署名無し JWS オブジェクト」だった。

よくあるケースは以下の2つ

  1. HMAC による署名機構を使った対象アルゴリズムによる署名と検証のケース
  2. RSA による署名機構を使った非対称アルゴリズムによる署名と検証のケース

HS256 を使った対称アルゴリズムを使った署名

HS256 は、対称アルゴリズムであり、2 つの当事者間で共有される 1 つの秘密鍵(共有秘密鍵: Shared Secret)のみを使用するアルゴリズム
今回は認可サーバーと保護対象リソースでも、トークンを生成することが可能なことを意味している。
共有秘密鍵をもつ。

以下、認可サーバーの実装 該当箇所のリンク

var jose = require("jsrsasign");

// 中略

var sharedTokenSecret = "shared OAuth token secret!"; // 共有秘密鍵

// 中略

var header = { typ: "JWT", alg: "HS256" }; // algをHS256にする
var payload = {
  iss: "http://localhost:9001/",
  sub: code.user ? code.user.sub : undefined,
  aud: "http://localhost:9002/",
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 5 * 60,
  jti: randomstring.generate(8),
};

var access_token = jose.jws.JWS.sign(
  header.alg,
  JSON.stringify(header),
  JSON.stringify(payload),
  new Buffer(sharedTokenSecret).toString("hex")
);

生成したアクセストークンは以下のようになる。
3つ目のセクションが生成されていることがわかる。(説明のため改行、本当は一行で表現される)

yJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
.
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDEvIiwic3ViIjoiOVhFMy1KSTM0LTAwMTMyQSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMi8iLCJpYXQiOjE2MzE0ODMyMjEsImV4cCI6MTYzMTQ4MzUyMSwianRpIjoiSEN3WU1TZWwifQ
.
OnYvHa7JYZqhWUBhGqFmitksT1mGkFIlWa7LoG5Zdoc

以下、保護対象リソースの実装。該当箇所のリンク

var jose = require("jsrsasign");

// 中略

var sharedTokenSecret = "shared OAuth token secret!"; // 共有秘密鍵

// 中略

// 署名の検証
if (
  jose.jws.JWS.verify(inToken, Buffer.from(sharedTokenSecret).toString("hex"), [
    header.alg,
  ])
) {
  console.log("Signature validated");

  // 中略
}

RSA256 を使った非対称アルゴリズムによる署名

RS256 は非対称アルゴリズムであり、公開鍵/秘密鍵のペアを使用する。
共有鍵暗号では、署名の生成や検証をするのに両方のシステムで同じ鍵が必要になった。これは、認可サーバーでも保護対象リソースでも、トークンを生成することが可能なことを意味する。
公開鍵暗号の場合、認可サーバーは公開鍵と秘密鍵の両方を持っており、2つを使ってトークンを生成できる一方、保護対象リソースは公開鍵にのみもてばよく、その公開鍵だけを使って検証できればよい。つまり、保護対象リソースはトークンを検証できるが、生成する手段がない状態である。

以下、認可サーバーの実装

var jose = require("jsrsasign");
// 中略
var rsaKey = {
  alg: "RS256",
  d:
    "ZXFizvaQ0RzWRbMExStaS_-yVnjtSQ9YslYQF1kkuIoTwFuiEQ2OywBfuyXhTvVQxIiJqPNnUyZR6kXAhyj__wS_Px1EH8zv7BHVt1N5TjJGlubt1dhAFCZQmgz0D-PfmATdf6KLL4HIijGrE8iYOPYIPF_FL8ddaxx5rsziRRnkRMX_fIHxuSQVCe401hSS3QBZOgwVdWEb1JuODT7KUk7xPpMTw5RYCeUoCYTRQ_KO8_NQMURi3GLvbgQGQgk7fmDcug3MwutmWbpe58GoSCkmExUS0U-KEkHtFiC8L6fN2jXh1whPeRCa9eoIK8nsIY05gnLKxXTn5-aPQzSy6Q", // 秘密鍵
  e: "AQAB",
  n:
    "p8eP5gL1H_H9UNzCuQS-vNRVz3NWxZTHYk1tG9VpkfFjWNKG3MFTNZJ1l5g_COMm2_2i_YhQNH8MJ_nQ4exKMXrWJB4tyVZohovUxfw-eLgu1XQ8oYcVYW8ym6Um-BkqwwWL6CXZ70X81YyIMrnsGTyTV6M8gBPun8g2L8KbDbXR1lDfOOWiZ2ss1CRLrmNM-GRp3Gj-ECG7_3Nx9n_s5to2ZtwJ1GS1maGjrSZ9GRAYLrHhndrL_8ie_9DS2T-ML7QNQtNkg2RvLv4f0dpjRYI23djxVtAylYK4oiT_uEMgSkc4dxwKwGuBxSO0g9JOobgfy0--FUHHYtRi0dOFZw", // 公開鍵
  kty: "RSA",
  kid: "authserver",
};

// 中略

var header = {
  typ: "JWT",
  alg: rsaKey.alg,
  kid: rsaKey.kid,
};
var payload = {
  iss: "http://localhost:9001/",
  sub: code.user ? code.user.sub : undefined,
  aud: "http://localhost:9002/",
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + 5 * 60,
  jti: randomstring.generate(8),
};

var privateKey = jose.KEYUTIL.getKey(rsaKey);
var access_token = jose.jws.JWS.sign(
  header.alg,
  JSON.stringify(header),
  JSON.stringify(payload),
  privateKey
);

以下のような access_token が生成される。(説明のため改行、本当は一行で表現される)

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImF1dGhzZXJ2ZXIifQ
.
eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkwMDEvIiwic3ViIjoiOVhFMy1KSTM0LTAwMTMyQSIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTAwMi8iLCJpYXQiOjE2MzE1Njc0NDgsImV4cCI6MTYzMTU2Nzc0OCwianRpIjoiemVBRWo0YTkifQ
.
lgyCSfTarQJ1LAFpq-lksSkBmbD8B0btWlzTDoeztqn3JjV84-CQEZMxPIL4bgi0kaFgPfnBO8Wo2wt7ntpTqZNCNIkuryJxeGiwcyNIDKXiXh4H73d9w3c33Wlnevb6xlP4E4M0XjERmtZnsB0S9yEipu4NgfZxyGaKJBgoHNcUVlwA1Qa65hJlMzoKGzzRJU-GkzKqsfiWhV1cX6Ghbw2B2clMtfXv2uGgoCDLmsMn-lFBaP6i1hkUuN9pkjGBw5Ya4tP3sQnCVSLkf0ysALn-lvvJIY6kms4XRWnOyJjtJ0PO5QbySVAHrFz_MWd6ws31O6oD4CV2QS8kwvcSTQ

以下、保護対象リソースの実装。
公開鍵だけを使って検証していることがわかる。

var jose = require("jsrsasign");
// 中略
var rsaKey = {
  alg: "RS256",
  e: "AQAB",
  n:
    "p8eP5gL1H_H9UNzCuQS-vNRVz3NWxZTHYk1tG9VpkfFjWNKG3MFTNZJ1l5g_COMm2_2i_YhQNH8MJ_nQ4exKMXrWJB4tyVZohovUxfw-eLgu1XQ8oYcVYW8ym6Um-BkqwwWL6CXZ70X81YyIMrnsGTyTV6M8gBPun8g2L8KbDbXR1lDfOOWiZ2ss1CRLrmNM-GRp3Gj-ECG7_3Nx9n_s5to2ZtwJ1GS1maGjrSZ9GRAYLrHhndrL_8ie_9DS2T-ML7QNQtNkg2RvLv4f0dpjRYI23djxVtAylYK4oiT_uEMgSkc4dxwKwGuBxSO0g9JOobgfy0--FUHHYtRi0dOFZw", // 公開鍵
  kty: "RSA",
  kid: "authserver",
};
// 中略
var publicKey = jose.KEYUTIL.getKey(rsaKey);

if (jose.jws.JWS.verify(inToken, publicKey, [header.alg])) {
  // 略
}

3. トークン・イントロスペクション(Token Introspection)

JWT のような、トークンについての情報をトークン自体の中にいれる方法の欠点として以下の2点がある。

  • すべての必要とされるクレームやそのクレームを保護するために必要となる暗号学的構造を組み込んでいくにつれ、トークン自体が大きくなってしまう
  • 保護対象リソースがトークン自体に格納されている情報にのみ依存している場合、トークンを生成して外部に送信してしまうと、そのアクティブなトークンを取り消すことが絶望的に難しくなってしまう

トークン・イントロスペクションのプロトコル

トークン・イントロスペクションに関するプロトコルは、保護対象リソースが認可サーバーに対してトークンの状態について能動的に検索するための仕組みが定義されている。
認可サーバーはクライアントにトークンを発行し、クライアントはそのトークンを保護対象リソースに提示し、保護対象リソースはそのトークンを認可サーバーに送って、そのトークンの情報を検証する。

手順

  1. 保護対象リソースがトークン(JWT じゃなくてもよい)をうけとる
  2. 保護対象リソースは、認可サーバーのトークン・イントロスペクション・エンドポイントに POST メソッドで送る。
  3. 保護対象リソースは「このトークンを誰かから受け取ったのですが、何のアクションに対して有効ですか?」と認可サーバーに問い合わせる。
  4. 認可サーバーが情報を返す(このとき、保護対象リソースは自身の認証(Basic 認証などで)をしているので、認可サーバーは誰がこの問い合わせをしているのかを識別できる。そして、誰が問い合わせているのかによって異なるレスポンスを返す場合もある。)

トークン・イントロスペクションのレスポンス

トークン・イントロスペクションのレスポンスはトークンについての情報を提供する JSON ドキュメントである。
その中で定義されているものは JWT のペイロードのようなもので、JWT で有効なクレームならレスポンスの一部として使うことも可能である。
JWT で定義されたクレームに加えていくつかの独自クレームを定義しており、その中でも「active」が最も重要なクレームとなっている。
「active」は保護対象リソースに今使っているトークンが認可サーバーでアクティブになっているのかどうかを伝えるものであり、唯一、返すことが必須になっている。

レスポンスの例

トークンが有効」時

{
  "active": true,
  "iss": "http://localhost:9001/",
  "aud": "http://localhost:9002/",
  "sub": "9XE3-JI34-00132A",
  "username": "alice",
  "scope": "foo bar",
  "client_id": "oauth-client-1"
}

トークンが無効」時(「トークンが無効である」ということだけ伝える)

{
  "active": false
}

JWT との組み合わせ

ここまでで、認可サーバーと保護対象リソースのあいだの情報を伝達するための2つの異なる方法として、JWT(構造化したトークン)とイントロスペクションについて紹介した。
2つ一緒に使うことは可能であり、さらなる成果を得られるようになる。

JWT は核となる情報(有効期限、一意の識別子、発行者の情報など)を運搬するのに使われる。 そこから、保護対象リソースはトークン・イントロスペクションを行い、さらに詳しいトークンの情報(トークンを認可したユーザー、トークンが発行されたクライアント、そのトークンに関する情報)などをもとに判断を下す。

これは、保護対象リソースがさまざまな認可サーバーからアクセス・トークンを受け取るような設定になっている場合にとくに効果がある。

要約すると、保護対象リソースは JWT を解析してどの認可サーバーがトークンを発行したのかを見つけ出し、さらなる情報を見つけ出すため、正当な認可サーバーに対してそのトークンに対するトークン・イントロスペクションをおこなう。

4. トークン取り消し(Token Revocation)

Token Revocation とは、クライアントが認可サーバーに有効なトークンを取り消すように依頼するための仕組みの仕様である。
クライアントが能動的にトークンのライフサイクルをおこなえるようにすることで、発行されたトークンが使われるべきではないことを、クライアントが認可サーバーに伝えるようにするもの。
たとえば、ネイティブ・アプリをデバイスからアンインストールする場合、クライアントがユーザーにクライアントとの連携を終わらせる UI を持っている場合、クライアントのふるまいが疑わしい場合などが想定されている。

認可サーバーのトークン取り消しのエンドポイントは、正常に処理を完了したと伝えるレスポンス以外を返さない。 エラーを返すと攻撃者に情報を与える可能性があるため。

まとめ

  • OAuth トークンは、認可サーバーと保護対象リソースが理解できるフォーマットであるかぎり、どのようなフォーマットでも採用できる
    • OAuth の仕様にトークンについての定義は存在しない
  • OAuth クライアントは決してトークンの内容を解釈してはならない(それを行おうとすること自体すべきではない)
    • 偽装や漏洩ができてしまうため
  • JWT はトークンを構造化し、その中に情報を格納するための手段を定義している
    • 「.」(ピリオド)で3つのセクション(ヘッダー、ペイロード、署名)に区切られている
  • JOSE は暗号学を用いてトークン内の情報を保護するための方法を提供している
    • 共通秘密鍵(HS256)と公開鍵(RS256)の他にも暗号方式が使用できる
  • トークン・イントロスペクションは保護対象リソースが実行時にトークンの状態を検索できるようにするための機能である
    • トークン自体に情報を持ったトークン(JWT など)の「情報が増えると暗号化によるファイルが肥大化する」と「アクティブなトークンの取り消しが困難」という欠点をもたない
    • JWT と組み合わせることにより、さらに優れた使い方ができる
  • 取り消し(Revocation)はトークンが発行されたあとに不要となったトークンを破棄することをクライアントが認可サーバーに依頼するためのものであり、そうすることで、そのトークンのライフサイクルを終わらせる