Amazon API Gateway の Custom Authorizer を使い、OAuth アクセストークンで API を保護する

  • 106
    Like
  • 0
    Comment
More than 1 year has passed since last update.

1. Custom Authorizer とは?

2016 年 2 月 11 日に AWS Compute Blog の「Introducing custom authorizers in Amazon API Gateway」 という記事で、Amazon API GatewayCustom Authorizer という仕組みが導入されたことがアナウンスされました。

これにより、Amazon API Gateway で構築された API にクライアントアプリケーションが (OAuth や SAML 等の) Bearer トークンを提示してきたとき、そのトークンのバリデーションを外部の authorizer (認可者) に委譲することができるようになりました。下図はオンラインドキュメント「Enable Amazon API Gateway Custom Authorization」からの転載で、図中の上部にある「Lambda Auth function」というものが authorizer にあたります。API Gateway は、この authorizer にトークンのバリデーションを依頼します。

custom-auth-workflow.png

Amazon API Gateway 本体が OAuth サーバー機能を提供していない点はこれまでと変わりませんが、この仕組みを使えば、Amazon API Gateway 上に構築された API を OAuth アクセストークンで保護することが可能となります。

1.1. Custom Authorizer の登場以前

Custom Authorizer の仕組みができるまでは、Amazon API Gateway + AWS Lambda で OAuth による保護を実現しようとすると、Lambda Function の実装内でアクセストークンの情報取得とバリデーションを行う必要がありました。「Amazon API Gateway + AWS Lambda + OAuth」という文書では、その方法による実装例が説明されています。

2. 認可サーバーと通信する例が無い

オンラインドキュメントやブログに authorizer の実装例が載っていますが、まず、オンラインドキュメントの方の実装例は、コード例を単純化するためにトークンの値を allow, deny, unauthorized としているので、現実的な例とはなっていません。一方、ブログの方の実装例はトークンを JWT (RFC 7519) としており、現実的な例となっています。しかし、JWT はトークンそのものの中に情報を埋め込む形式であるため ※1、その場でデコードすればトークンに紐づく情報を取り出せることから、実装例には「どこか別のサーバーにトークン情報を問い合わせるコード」は含まれていません。

つまり、アクセストークンの情報を取得するために認可サーバーと通信をおこなうケース ※2 のコード例がありません。このケースのコード例が無いという状況は、(1) Authorizer を AWS Lambda Function として実装しなければならないこと、(2) AWS Lambda の実装言語としては node.js が一推し扱いとなっていること、(3) 非同期処理を得意とする node.js では逆に通信を同期的に処理するコードの標準的な書き方が定まっていないこと (私の知る限り)、を考えると、プログラマーにとっては情報不足の感があります。

    ※1: 「OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る」の「アクセストークン」をご参照ください。
    ※2: 「【第二弾】 OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る」の「2.3. アクセストークン情報の取得方法」をご参照ください。

3. 認可サーバーと通信する Authorizer の実装例

そこで、アクセストークンの情報を認可サーバーに問い合わせる authorizer の実装例 (node.js) をここに掲載します。

この例で叩いている認可サーバーの API は Authlete (オースリート) ※3イントロスペクション API であり、RFC 7662 (OAuth 2.0 Token Introspection)イントロスペクション API ※4 とは異なるため、その部分は Authlete 特化のコードになっていますが、次の点は参考にしていただけると思います。

  1. event.methodArn の値から、リクエストの HTTP メソッドとリソースパスを抽出する方法 (extract_method_and_path 関数内)
  2. event.authorizationToken の値から、RFC 6750, 2.1. 形式で埋め込まれているアクセストークンを抽出する方法 ※5 (extract_access_token 関数内)
  3. async モジュールの waterfall 関数を用いて、認可サーバーとの通信を exports.handler の中で同期的に完結させる方法
  4. request モジュールを使って認可サーバーの Web API と通信する方法 (introspect 関数内)
  5. Authorizer が API Gateway に返す応答を生成する方法 (generate_policy 関数内)
  6. リクエストを拒否する際に HTTP ステータスコードを指定する方法 (exports.handler 関数内)

    ※3: 「【第二弾】 OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る」の「3. Authlete」をご参照ください。
    ※4: 「【第二弾】 OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る」の「2.3. アクセストークン情報の取得方法」をご参照ください。
    ※5: 「【第二弾】 OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る」の「2.2. アクセストークンの受け取り方」をご参照ください。

// Authlete から発行された、API キーと API シークレット。 Authlete の
// イントロスペクション API を叩くために必要となる。
var API_KEY    = '{あなたのサービスの API キー}';
var API_SECRET = '{あなたのサービスの API シークレット}';

// Authorization ヘッダーからアクセストークンを取り出すための正規表現
var BEARER_TOKEN_PATTERN = /^Bearer[ ]+([^ ]+)[ ]*$/i;

// モジュール群
var async   = require('async');
var request = require('request');


// event.methodArn から HTTP メソッドとリソースパスを取り出す関数
function extract_method_and_path(arn)
{
  // 'arn' の値は次のフォーマットとなっている。
  //
  //   arn:aws:execute-api:<リージョンID>:<アカウントID>:<API ID>/<ステージ>/<メソッド>/<リソースパス>"
  //
  // 詳細は 'Enable Amazon API Gateway Custom Authorization' を参照のこと。
  //
  //   http://docs.aws.amazon.com/apigateway/latest/developerguide/use-custom-authorizer.html
  //

  // 念のため event.methodArn に値が設定されていないケースを処理する。
  if (!arn)
  {
    // HTTP メソッドもリソースパスも不明
    return [ null, null ];
  }

  var arn_elements      = arn.split(':', 6);
  var resource_elements = arn_elements[5].split('/', 4);
  var http_method       = resource_elements[2];
  var resource_path     = resource_elements[3];

  // HTTP メソッドとリソースパスを配列として返す。
  return [ http_method, resource_path ];
}


// Authorization ヘッダーからアクセストークンを取り出す関数
//
// この関数は、引数の値が "RFC 6750, 2.1. Authorization Request Header Field" に
// 従っていることを想定している。 例えば "Bearer 123" という値が渡された場合、"123" を返す。
function extract_access_token(authorization)
{
  // Authorization ヘッダーの値がない場合
  if (!authorization)
  {
    // アクセストークンはない。
    return null;
  }

  // "Bearer {アクセストークン}" というパターンに合致するか調べる。
  var result = BEARER_TOKEN_PATTERN.exec(authorization);

  // パターンにマッチしなかった。
  if (!result)
  {
    // アクセストークンはない。
    return null;
  }

  // アクセストークンを返す。
  return result[1];
}


// HTTP メソッドとリソースパスの組から、必要となるスコープ群のリストを文字列の配列として返す。
// 例えば ["profile", "email"] など。 空でない配列が返された場合、アクセストークンが
// それらのスコープ群を全てカバーしているかどうかのチェックが Authlete サーバー側で
// (= Authlete のイントロスペクション API の実装内で) 行われる。 この関数が null を
// 返した場合は、スコープ群に関するチェックはおこなわれない。
function get_required_scopes(http_method, resource_path)
{
  // 適宜カスタマイズする。
  return null;
}


// Authlete のイントロスペクション API を呼ぶ関数
//
// この関数は async モジュールの waterfall 関数のタスクとして使われる。 waterfall 関数の
// 詳細については https://github.com/caolan/async#user-content-waterfalltasks-callback
// を参照のこと。
//
//   * access_token (文字列) [必須]
//       アクセストークン
//
//   * scopes (文字列の配列) [オプショナル]
//       アクセストークンがカバーすべきスコープ群。 もしもアクセストークンが指定された
//       スコープ群をカバーしていない場合、Authlete のイントロスペクション API の
//       応答に含まれる action の値は FORBIDDEN になる。
//
//   * callback
//       async モジュールの waterfall がタスク関数に渡すコールバック関数
//
function introspect(access_token, scopes, callback)
{
  request({
    // Authlete のイントロスペクション API の URL
    url: 'https://api.authlete.com/api/auth/introspection',

    // HTTP メソッド
    method: 'POST',

    // ベーシック認証のための API キーと API シークレット
    auth: {
      username: API_KEY,
      pass: API_SECRET
    },

    // Authlete のイントロスペクション API に渡すリクエストパラメーター群
    json: true,
    body: {
      token: access_token,
      scopes: scopes
    },

    // Authlete のイントロスペクション API からの応答を UTF-8 文字列として解釈する。
    encoding: 'utf8'
  }, function(error, response, body) {
    if (error) {
      // Authlete のイントロスペクション API の呼び出しに失敗した。
      callback(error);
    }
    else if (response.statusCode != 200) {
      // Authlete のイントロスペクション API の応答は、何か良くないことが起こったことを示している。
      callback(response);
    }
    else {
      // waterfall の次のタスクを呼び出す。
      //
      // body は既に JSON オブジェクトになっている。これは、request モジュールによって
      // 行われている。 この JSON オブジェクトが持っているプロパティー群に関しては、
      // authlete-java-common の com.authlete.common.dto.IntrospectionResponse
      // クラスの JavaDoc を参照のこと。
      //
      //   http://authlete.github.io/authlete-java-common/com/authlete/common/dto/IntrospectionResponse.html
      //
      callback(null, body);
    }
  });
}


// Authorizer から API Gateway に返す応答を生成する関数
function generate_policy(principal_id, effect, resource)
{
  return {
    principalId: principal_id,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [{
        Action: 'execute-api:Invoke',
        Effect: effect,
        Resource: resource
      }]
    }
  };
}


// Authorizer の実装
exports.handler = function(event, context)
{
  // 起動しようとしている関数の情報を取得する。 event.methodArn から
  // リクエストの HTTP メソッドとリソースパスを取り出す。
  var elements = extract_method_and_path(event.methodArn);
  var http_method   = elements[0];
  var resource_path = elements[1];

  // event.authorizationToken からクライアントアプリケーションが提示した
  // アクセストークンを取り出す。
  var access_token = extract_access_token(event.authorizationToken);

  // クライアントアプリケーションからのリクエストにアクセストークンが含まれていない場合
  if (!access_token) {
    // ログを出力し、"401 Unauthorized" を返すように API Gateway に指示する。
    console.log("[" + http_method + "] " + resource_path + " -> No access token.");
    context.fail("Unauthorized");
    return;
  }

  // HTTP メソッドとリソースパスの組から、必要となるスコープ群のリストを求める。
  var required_scopes = get_required_scopes(http_method, resource_path);

  async.waterfall([
    function(callback) {
      // Authlete のイントロスペクション API を呼び、アクセストークンのバリデーションを行う。
      introspect(access_token, required_scopes, callback);
    },
    function(response, callback) {
      // アクセストークンのバリデーションの結果をログに書く。
      console.log("[" + http_method + "] " + resource_path + " -> " +
                  response.action + ":" + response.resultMessage);

      // Authlete のイントロスペクション API の応答に含まれる action プロパティーは、
      // 保護リソースエンドポイントの実装がクライアントアプリケーションに返すべき HTTP
      // ステータスを示している。 なので、ここでは action の値でディスパッチする。
      switch (response.action) {
        case 'OK':
          // アクセストークンは有効。 API Gateway に、リソースへのアクセスを許可することを伝える。
          // なお、Authlete のイントロスペクション API の応答に含まれる subject プロパティーは、
          // アクセストークンに紐づいているユーザーの一意識別子である。
          context.succeed(generate_policy(response.subject, 'Allow', event.methodArn));
          break;

        case 'BAD_REQUEST':
        case 'FORBIDDEN':
          // API Gateway に、リソースへのアクセスを拒否することを伝える。
          context.succeed(generate_policy(response.subject, 'Deny', event.methodArn));
          break;

        case 'UNAUTHORIZED':
          // "401 Unauthorized" をクライアントアプリケーションに返すようにと API Gateway に伝える。
          context.fail("Unauthorized");
          break;

        case 'INTERNAL_SERVER_ERROR':
        default:
          // Internal Server Error を返す。 context.fail() に渡す値が unauthorized
          // 以外の場合、"500 Internal Server Error" という扱いになる。
          context.fail("Internal Server Error");
          break;
      }

      callback(null);
    }
  ], function (error) {
    if (error) {
      // 何か良くないことが起こった。
      context.fail(error);
    }
  });
};

3.1. ラムダ関数デプロイメントパッケージの作成

紹介した Custom Authorizer のコードをラムダ関数デプロイメントパッケージ (Lambda Function Deployment Package) にする手順を説明します。

まず、次のどちらかを Gist からダウンロードします。

そのファイルを開き、API_KEYAPI_SECRET の値を実際の API キーと API シークレットの値で置き換えます。Authlete から発行された API クレデンシャルズを使用してください。API クレデンシャルズは、Authlete にアカウント登録すると一組発行されます。また、Authlete のサービスオーナーコンソールにログインしてサービスを追加すると、新たに一組発行されます。詳細は Authlete の Getting Started ドキュメントを参照してください。

次に、同ファイル内にある get_required_scopes 関数の実装を必要に応じて変更してください。この関数は、リクエストの HTTP メソッドとリソースパスに基づいて、必要とするスコープ群のリストを返す関数です。詳細は index.js 内のコメントを参照してください。

続けて、index.js を置いてあるディレクトリーに移動し、次のコマンドを実行して async モジュールと request モジュールをインストールします。

$ npm install async request

ここまでの作業で、index.js ファイルと node_modules ディレクトリができます。

$ ls -1F
index.js
node_modules/

最後に、これらを含む ZIP ファイルを作成します。その ZIP ファイルが、ラムダ関数デプロイメントパッケージとなります。それを AWS Lambda にアップロードしてください。

なお、Custom Authorizer の実装内で認可サーバーと通信するので、ラムダ関数のアップロード時に指定する Configuration では、タイムアウト値をデフォルト値よりも長めに設定したほうがよいでしょう。

3.2. Custom Authorizer を設定する

AWS Lambda にアップロードしたラムダ関数を Custom Authorizer の実装として利用する方法については、AWS のブログとオンラインドキュメントを参照してください。

4. 動作確認

ここでは、Amazon API Gateway のオンラインドキュメント「Walkthrough: Create API Gateway API for Lambda Functions」の手順を踏むことで生成される GET mydemoresource が Custom Authorizer で保護されているものとします。

まず、アクセストークン無しで mydemoresource にアクセスしてみます。 {your-api-id}{region-id} は適宜置き換えてください。

curl -v -s -k https://{your-api-id}.execute-api.{region-id}.amazonaws.com/test/mydemoresource

これを実行すると、"401 Unauthorized" という HTTP ステータスが返ってくると思います。

次に、アクセストークンを付けてアクセスしてみます。ここでは、puql0-wO_vwuxupctHgNem5-__b256tYgFcu_CXvc7w が有効なアクセストークンであるとします。

curl -v -s -k https://{your-api-id}.execute-api.{region-id}.amazonaws.com/test/mydemoresource \
     -H 'Authorization: Bearer puql0-wO_vwuxupctHgNem5-__b256tYgFcu_CXvc7w'

応答として、"200 OK" という HTTP ステータスと {"Hello":"World"} という JSON が返ってきたら成功です。

4.1. アクセストークンの発行方法

Authlete では、https://api.authlete.com/api/auth/authorization/direct/{service-api-key} という URL で認可エンドポイントのデフォルト実装を提供しています (デフォルトで利用可能になっています)。このエンドポイントを利用すれば、java-oauth-server といった認可サーバーを全く用意せずとも、アクセストークンの発行をおこなうことができます。

このエンドポイントに次のようにアクセスすれば、インプリシットフローでアクセストークンの発行を受けることができます。 {service-api-key}{client-id} は適宜置き換えてください。

https://api.authlete.com/api/auth/authorization/direct/{service-api-key}?client_id={client-id}&response_type=token

表示される認可画面のログインフォームには、あなたのサービスの API キーと API シークレットを入力してください。

詳細については、Authlete の Getting Started ドキュメントを参照してください。

さいごに

本文書では、Custom Authorizer を用いて API Gateway 上に構築された API を OAuth アクセストークンで保護する方法をご紹介致しました。最後までお読みいただき、ありがとうございました!