[後編] AWS CDKで API Gateway + Lambda 構成のREST APIを構築して Auth0 + Lambda Authorizerの認可機能を導入してみた

2023.03.14

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

CX事業本部Delivery部のアベシです。

こちらの記事では、API Gateway + Lambda のREST APIに Auth0 + Lambda Authorizerの認可を導入する方法について紹介します。 前回公開した前編の後編となります。 前編はこちら⇣

後編の内容

後編では以下の内容を紹介したいと思います。

構成と認可の流れ

前編のおさらいで認可の流れを記載した構成図を再度掲載します。

① クライアントがAuth0に認可をリクエストする。
② 認可されたらAuth0がアクセストークンを返す。
③ クライアントがAPIコールする。その際にアクセストークンをヘッダーとしてAPI Gatewayに渡す。
④ API GatewayがLambda Authorizerにアクセストークンを渡して関数を実行する。
⑤ Lambda Authorizerはアクセストークンを用いてAuth0へ公開鍵の取得をリクエストする。
⑥ Auth0が公開鍵をLambda Authorizerに返し、アクセストークンの RS256 署名の検証を実行する。
⑦ 検証が成功したらAPIを実行するために必要なポリシーを作成しAPI Gatewayに渡す。
⑧ API GatewayがLambda関数を実行する。


Auth0の設定

今回アクセストークンにはCLIを用いてcurlで入手しますが、このような認証にはM2M(MACHINE TO MACHINE認証)を利用できます。

まず、Auth0にM2M認証するためのアプリケーションを作成します。 APIを作成すると自動的にアプリケーションも作成されますのでその流れでやっていきます。

Auth0のご自分のアカウントにログインします。 左のペインのAPIsをクリックします。
次にCREATE APIをクリックします。
するとモーダルウィンドウが立ち上がります。 Identifierには前編で作成したAPI GatewayのURLを記入します。
Sining Algorithmにはアクセストークンの署名方式を指定します。今回はデフォルトのRS256署名を選択します。

左のペインのApplicationsをクリックするとアプリケーションが作成されているのが確認できます。

curlでアクセストークンを取得する方法

Auth0に対して以下のcurlコマンドでリクエストしてアクセストークンを取得してみます。

  • Domain : テナントのエンドポイント
  • Client ID
  • Client Secret
    • 上記の3つはAuth0のアプリケーションの画面に記載されています。
  • Identifier
    • API作成時に指定したAPIGatewayのURL
  • content-type
    • x-www-form-urlencodedを指定
$ curl -X POST \
  'https://<Domain>/oauth/token' \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d 'audience=<Identifier>&grant_type=client_credentials&client_id=<Client ID>&client_secret=<Client Secret>'


このリクエストに対してレスポンスでアクセストークンが返却されます。

{
  "access_token": "******************************************",
  "expires_in":86400,
  "token_type": "Bearer"
}

 

AWS側の設定

Lambda Authorizer用のLambda関数

コードは以下のページを参考にTypeScriptで書きました。

構築環境

以下の環境で構築と動作確認しています。

項目名 バージョン
mac OS Ventura 13.2
typeScript 4.9.5
jwks-rsa 3.0.1
jsonwebtoken 9.0.0

src/lambda/handlers/lambda-authorizer.ts

import {
  APIGatewayAuthorizerEvent,
  APIGatewayAuthorizerResult,
} from 'aws-lambda';
import * as jwt from 'jsonwebtoken';
import * as util from 'util';
import * as jwks from 'jwks-rsa';
import ms from 'ms';

// event内のアクセストークンを取得
const getToken = (event: APIGatewayAuthorizerEvent) => { // event内のアクセストークンを取得
  if (event.type !== 'TOKEN') {
    throw new Error(`event.type parameter must have value TOKEN , but actual value is ${event.type}`);
  }
  const token = event.authorizationToken;
  if (!token) {
    throw new Error("event.authorizationToken parameter must be set, but got null");
  }
  return token
};

// アクセストークンの検証
const verifyToken = async(token: string): Promise<string | jwt.JwtPayload> => {
  // jwt形式のアクセストークンをデコード
  const decodedToken = jwt.decode(token, { complete: true });
  if (!decodedToken || !decodedToken.header || !decodedToken.header.kid) {
    throw new jwt.JsonWebTokenError('invalid token');
  }

  const client = new jwks.JwksClient({
    cache: true,
    cacheMaxAge:ms('1h') ,
    jwksUri: process.env.JWKS_URI as string,
  });

  try {
    const getSigningKey = util.promisify(client.getSigningKey);
    const key = await getSigningKey(decodedToken.header.kid);
    const signingKey =
      (key as jwks.CertSigningKey).publicKey ||
      (key as jwks.RsaSigningKey).rsaPublicKey;
    const tokenInfo = jwt.verify(token, signingKey, {
      audience: process.env.AUDIENCE,
      issuer: process.env.TOKEN_ISSUER,
    });
    return tokenInfo;
  } catch (err) {
    if (err instanceof jwt.TokenExpiredError) {
      throw new Error('token expired');
    }
    if (err instanceof jwt.JsonWebTokenError) {
      throw new Error('token is invalid');
    }
    throw err;
  }
}

// 認可ポリシーの生成
const generatePolicy = (
  principal: string,
  effect: 'Allow' | 'Deny',
  resource: string,
): APIGatewayAuthorizerResult =>{
  return {
    principalId: principal,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke', // API Gateway にデプロイした API を呼び出しするアクション
          Effect: effect,
          Resource: resource,
        },
      ],
    },
  };
}

export const handler = async (
  event: APIGatewayAuthorizerEvent,
): Promise<APIGatewayAuthorizerResult> => {
  console.log('event', JSON.stringify(event, undefined, 2));
  try {
    const token = getToken(event);
    const res = await verifyToken(token);
    return generatePolicy(res.sub as string, 'Allow', event.methodArn); //methodARNにリクエストのメソッドとリソースパスが入っている
  } catch (error) {
    console.log(error);
    return generatePolicy(" ", 'Deny', event.methodArn);
  }
};

コードの解説

eventの中身

API GatewayからLambdaに渡されるeventは以下の要素を持ちます。 authorizationTokenがアクセストークンですね

{
  "type": "TOKEN",
  "methodArn": "arn:aws:execute-api:ap-northeast-1:***********:**********/v1/GET/hello_world",
  "authorizationToken": "********************************************************"
}

トークン取得

以下の構文でアクセストークンをevent要素の中から取得します。

const getToken = (event: APIGatewayAuthorizerEvent) => { // event内のアクセストークンを取得
  if (event.type !== 'TOKEN') {
    throw new Error(`event.type parameter must have value TOKEN , but actual value is ${event.type}`);
  }
  const token = event.authorizationToken;
  if (!token) {
    throw new Error("event.authorizationToken parameter must be set, but got null");
  }
  return token
};

トークンの検証

JWTのトークンをデコードします

  const decodedToken = jwt.decode(token, { complete: true });
  if (!decodedToken || !decodedToken.header || !decodedToken.header.kid) {
    throw new jwt.JsonWebTokenError('invalid token');
  }

base64でデコードしたトークンは以下のクレームを持っております。

{
  iss: 'https://integrate-lambda-authorizer-test.jp.auth0.com/',
  sub: '**********************************@clients',
  aud: 'https://**********.execute-api.ap-northeast-1.amazonaws.com',
  iat: 1678755454,
  exp: 1678841854,
  azp: '*****************************',
  gty: 'client-credentials'
}
  • subクレーム
    • ユーザーの一意識別子が入っており、後に認可ポリシーを作成する際にプリンシパルとして利用します。
  • audクレーム
    • Audienceの事でAPIの作成の際にIdentifierに記載したAPI GatewayのURLが入ってます。

jwtトークンのクレームについては以下のページの説明が非常に参考になりました。

次にデコードしたアクセストークンを検証します。

Lambda関数の環境変数に登録したJWKS_URIAuth0が公開しているJWK取得先のエンドポイントになります。
こちらからJWK取得し、その公開鍵を使ってアクセストークンの署名を検証する流れとなります。
JWKJWTの発行元から提供される公開鍵情報です。

こちらの公開鍵ですが、クライアントからのリクエストのたびにエンドポイントから取得すると、Lambdaの稼働時間が増えることによりコストの上昇やパフォーマンス低下が発生します。
その回避方法としてjwks-rsaモジュールのJwksClientクラスの公開鍵をキャッシュする機能が使えます。
リクエストの際に渡されたアクセストークンのkidの値とキャッシュした鍵のそれが一致する場合、キャッシュから鍵が返されます。 kid (key ID) パラメータは特定の鍵を識別するために用いられます。
有効にするにはプロパティのcacheの値をtrueとします。
chachMaxAgeプロパティを使用してキャッシュする時間の設定が可能です。
より詳しい使い方は以下のページを参照していただければと思います・

const client = new jwks.JwksClient({
  cache: true,
  cacheMaxAge:ms('1h') ,
  jwksUri: process.env.JWKS_URI as string,
});

  try {
    const getSigningKey = util.promisify(client.getSigningKey);
    const key = await getSigningKey(decodedToken.header.kid);
    const signingKey =
      (key as jwks.CertSigningKey).publicKey ||
      (key as jwks.RsaSigningKey).rsaPublicKey;

jsonwebtokenモジュールのverify関数を使用してトークンを検証します。 その際にLambda関数の環境変数に登録したAUDIENCETOKEN_ISSUERを使用します。 AUDIENCEにはAPI作成時に指定したIdentity, TOKEN_ISSUERにはAuth0のテナントのエンドポイントを指定します。 テナントのエンドポイントは先程のcurlコマンドで指定したDomainの値です。

    const tokenInfo = jwt.verify(token, signingKey, {
      audience: process.env.AUDIENCE,
      issuer: process.env.TOKEN_ISSUER,
    });
    return tokenInfo;

認可トークンの生成部分

アクセストークンの検証が無事に完了すると、以下のコードに定義したポリシーを生成します。
ハンドラーはこのポリシーをAPI Gatewayにコールバックします。
各要素については以下のとなります。

  • principalId
    • トークン内のsubクレームを渡します。
  • ActionにはAPI Gateway
    • デプロイした API を呼び出しするアクションのexecute-api:Invokeを指定します。
  • Effect
    • 今回は許可する権限を生成したいのでAllowを渡します。
  • Resource
    • event内のmethodArnを指定します。methodArnにはAPI Gateway にデプロイした APIのメソッドとリソースパスが含まれています。
const generatePolicy = (
  principal: string,
  effect: 'Allow' | 'Deny',
  resource: string,
): APIGatewayAuthorizerResult =>{
  return {
    principalId: principal,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Effect: effect,
          Resource: resource,
        },
      ],
    },
  };
}

Authorizer用のLambda関数の解説は以上です。 後は前編で作成したCDKをデプロイします。

動作確認

デプロイされたAPIのリソースの認可方法がlambdaAuthorizerになっています。
Authorizersの画面を確認します。作成したLambda Authorizer用のLambda関数名が割当てられています。
それでは以下のcurlコマンドでAPIを叩いてみます。 ヘッダーに先程のcurlコマンドで取得したアクセストークンを指定します。

curl -X GET \
  "https://**********.execute-api.ap-northeast-1.amazonaws.com/v1/hello_world" \
  -H "Authorization: <アクセストークン>"

レスポンス

Hello World!!

無事にAPI Gatewayの後続のLambdaをInvokeして正常なレスポンスを受け取ることができました。