Lambda Authorizerを利用してAuth0から発行されたトークンを検証する

2020.06.12

API Gateway REST APIを作成する場合にLambda AuthorizerでAPIを保護するのはよくあるケースです.
Auth0を利用してREST APIを保護するのもよくあるパターンです.
なんとなくわかってはいるけど実際の実装すべき内容について理解したかったのでなので実際にAPIを保護してみることにしました.

実装内容について

クライアントと認可サーバであるAuth0の間でアクセストークンが発行済みで, アクセストークンをLambda Authorizerで検証するという内容を実装します.
つまりREST APIを保護されたリソースとして扱うために設定を行っていきます.
なのでLambda Authorizerでクライアントから送られたアクセストークンを検証してリソースへのアクセスを制御します.

今回はすでにAPI Gatewayの初期設定が終わっており, Auth0のアプリケーションができていることを前提として, Auth0でのAPI登録と実際のAPI保護をみていきます.

Auth0 の設定

まずはAuth0側でAPIの設定をしていきます.
Create APIから新規作成していきます.

img 01

Nameは自分の感じるままに付けて, identifierにはAPIのエンドポイントを書いておきましょう.

img 02

APIの作成が完了したら設定画面から諸々確認ができるようになります.

img 03

Lambda 関数の作成

次にLambda Authorizerで発火するをLambda 関数を作成します.
トークンの受け取り方などは通常のLambda関数と同様ですが, 今回はJWTが渡される想定なのでJWTの検証を行います.

TypeScriptで書くのと, JWTの検証のために諸々モジュールが必要なので準備を行います.

$ yarn init
$ yarn add jsonwebtoken jwks-rsa
$ yarn add -D @types/jsonwebtoken @types/aws-lambda @types/node typescript

まずはコードの全体像をみていきましょう.

index.ts

import {
  APIGatewayAuthorizerEvent,
  APIGatewayAuthorizerResult,
} from 'aws-lambda';
import {
  decode,
  JsonWebTokenError,
  TokenExpiredError,
  verify,
} from 'jsonwebtoken';
import * as jwks from 'jwks-rsa';
import { promisify } from 'util';

async function verifyToken(token: string): Promise<any> {
  const decoded = decode(token, { complete: true });
  if (!decoded || !decoded['header'] || !decoded['header'].kid) {
    throw new JsonWebTokenError('invalid token');
  }

  const client = jwks({ jwksUri: process.env.JWKS_URI });
  const getSigningKey = promisify(client.getSigningKey);

  const key = await getSigningKey(decoded['header'].kid);
  const signingKey = key['publicKey'] || key['rsaPublicKey'];

  try {
    const tokenInfo = await verify(token, signingKey, {
      audience: process.env.AUDIENCE,
      issuer: process.env.TOKEN_ISSUER,
    });
    return tokenInfo;
  } catch (err) {
    if (err instanceof TokenExpiredError) {
      throw new Error('token expired');
    }
    if (err instanceof JsonWebTokenError) {
      throw new Error('token is invalid');
    }
    throw err;
  }
}

function 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,
        },
      ],
    },
  };
}

async function handler(
  event: APIGatewayAuthorizerEvent
): Promise<APIGatewayAuthorizerResult> {
  if (
    !process.env.JWKS_URI ||
    !process.env.AUDIENCE ||
    !process.env.TOKEN_ISSUER
  ) {
    console.log(
      '[error] set environment value JWKS_URI, AUDIENCE, TOKEN_ISSUER'
    );
    return generatePolicy(null, 'Deny', event.methodArn);
  }
  if (event.type !== 'TOKEN') {
    console.log(`expected authorization type is TOKEN, got ${event.type}`);
    return generatePolicy(null, 'Deny', event.methodArn);
  }
  const token = event.authorizationToken;
  if (!token) {
    console.log('authorization token must not bet null');
    return generatePolicy(null, 'Deny', event.methodArn);
  }

  try {
    const res = await verifyToken(token);
    return generatePolicy(res.azp, 'Allow', event.methodArn);
  } catch (err) {
    console.log(`failed to verify token. error: ${err}`);
    return generatePolicy(null, 'Deny', event.methodArn);
  }
}

export { handler };

次のコードのコアとなる部分, トークンの検証の部分を中心にみていきます.
トークンのデコードをして, トークンが正しいかどうかを判断します.
JWKS_URIには「https://your_tenant.auth0.com/.well-known/jwks.json」を指定します. Auth0の場合は左のようなURLに公開鍵があるので, 環境変数で指定しておいて, 署名されたトークンを検証できるようにします.
また, AUDIENCEにはAPI作成時に指定したIdentityを, TOKEN_ISSUERにはAuth0テナントのエンドポイントを指定します.

これでトークンの検証ができるようになります.

async function verifyToken(token: string): Promise<any> {
  const decoded = decode(token, { complete: true });
  if (!decoded || !decoded['header'] || !decoded['header'].kid) {
    throw new JsonWebTokenError('invalid token');
  }

  const client = jwks({ jwksUri: process.env.JWKS_URI });
  const getSigningKey = promisify(client.getSigningKey);

  const key = await getSigningKey(decoded['header'].kid);
  const signingKey = key['publicKey'] || key['rsaPublicKey'];

  try {
    const tokenInfo = await verify(token, signingKey, {
      audience: process.env.AUDIENCE,
      issuer: process.env.TOKEN_ISSUER,
    });
    return tokenInfo;
  } catch (err) {
    if (err instanceof TokenExpiredError) {
      throw new Error('token expired');
    }
    if (err instanceof JsonWebTokenError) {
      throw new Error('token is invalid');
    }
    throw err;
  }
}

次の部分はLambda関数からAPI Gatewayに対して返すポリシードキュメントを作成する関数です.
許可したい場合はAllowを, 拒否したい場合はDenyを引数に渡してポリシーを生成します.

function 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,
        },
      ],
    },
  };
}

Lambda関数をデプロイします. その際に環境変数の設定を忘れずに行いましょう.

yarn install
yarn run tsc index.ts
rm -rf node_modules && yarn install --production
zip -r index.zip index.js node_modules

aws --region us-east-1 lambda create-function \
  --function-name sdx_auth0_handler \
  --runtime nodejs12.x \
  --role arn:aws:iam::123456789012:role/lambda-role \
  --handler index.handler \
  --environment "Variables={JWKS_URI='https://your_tenant.auth0.com/.well-known/jwks.json',AUDIENCE='https://xxxxxxxx.execute-api.us-east-1.amazonaws.com/v1',TOKEN_ISSUER='https://your_tenant.auth0. com/'}" \
  --zip-file fileb://index.zip

最後にRequest MethodにLambda Authorizerを紐付けてデプロイは終わりです.

aws --region us-east-1 lambda add-permission \
  --function-name sdx_auth0_handler \
  --action lambda:InvokeFunction \
  --statement-id sdx_api \
  --principal apigateway.amazonaws.com \
  --source-arn arn:aws:execute-api:us-east-1:123456789012:xxxxxxxx/authorizer/*

aws --region us-east-1 apigateway create-authorizer \
  --rest-api-id xxxxxxxx \
  --name auth0_authorizer \
  --type TOKEN \
  --identity-source 'method.request.header.Authorization' \
  --authorizer-uri 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:sdx_auth0_handler/invocations'
  
aws --region us-east-1 apigateway update-method \
  --rest-api-id xxxxxxxx \
  --resource-id xxxxxxxx \
  --http-method GET \
  --patch-operations "op=replace,path=/authorizationType,value=CUSTOM" "op=replace,path=/authorizerId,value=xxxxxxxx"

動作の確認

$ curl https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/v1/hello

{"message":"Unauthorized"}

$ curl -H "Authorization: xxxxxxxxxxxx" https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/v1/hello

hello

さいごに

Auth0を利用すると簡単に認証認可の基盤が作成でき, Lambda Authorizerを利用すると簡単に権限付与と実際の処理を分離できます. お役に立てたら幸いです.