🦊

【AWS】GoogleOAuthをSAMテンプレで作った

2024/05/17に公開

SAMってAWSが公式にサポートしてるわりには、ネット上にまとまった知見が少ない気がする

そう思ったのが、本記事公開の背景になります。
特に自分みたいなAWS初学者にとってはcloudFormationとかServerlessとか情報が錯綜して死にかける。
今回、もがきながらなんとか環境を作ったので、少しでもAWS初学者に貢献したくて公開しました。
(その割には色々雑ですが、わからないことはググれば、私より100億倍わかりやすい説明がもらえますので。)

前提

  • AWSについてやんわり知っている。以下の単語を知っている。

    • Lambda関数
    • APIGateway
    • SAM
  • Googleクラウドプラットフォーム(GCP)でプロジェクトを作成し、
    クライアントIDとクライアントシークレットを手元に控えている。

  • OAuthもやんわり知ってる
    トークン認証についてわかりやすくまとめていただいてる記事
    https://zenn.dev/tanaka_takeru/articles/3fe82159a045f7

  • APIとかCorsとかもやんわり知ってる

template.ymlでResourceを作成

作成するResource

  • DynamnoDB
    ユーザーの認証関連の情報を格納する(今回はDB操作は割愛)
  • APIGateway
    Googleの認証サーバーと自身のAPPとの通信を行う
  • OAuthのLambda
    * Googleの認証サーバーと自身のAPP間のパラメータの引き渡し
    * Googleの認証サーバーから受け取ったidトークン(jwtトークン)のデコード
    * ユーザーの認証周りのデータに関するDB操作
  • APIGatewayの認可Lambda
    * APIGatewayで使う認証処理

DynamoDB

今回は特に使わないが、一応。

# template.yaml
# ========================================================================================================
# DynamoDB
# ========================================================================================================
AuthTable:
 Type: AWS::DynamoDB::Table
 Properties:
   TableName: 'AuthTable'
   AttributeDefinitions:
     - AttributeName: uid
       AttributeType: S
     - AttributeName: datetime
       AttributeType: N
   KeySchema:
     - AttributeName: uid
       KeyType: HASH
     - AttributeName: datetime
       KeyType: RANGE
   ProvisionedThroughput:
     ReadCapacityUnits: 5
     WriteCapacityUnits: 5
   StreamSpecification:
     StreamViewType: NEW_IMAGE

APIGateway

認証にはトークンオーソライザーを使用している。
トークンオーソライザーのLambdaもSAMで定義している(後述に記載あり)
Corsも対策済み。

# template.yaml
# ========================================================================================================
# APIGateway
# ========================================================================================================
MyApiGateway:
 Type: AWS::Serverless::Api
 Properties:
   StageName: Dev
   EndpointConfiguration:
     Type: REGIONAL # リージョン最適化に設定
   Auth:
     DefaultAuthorizer: TokenAuthorizer # デフォルトの認可をトークンオーソライザーに設定
     AddApiKeyRequiredToCorsPreflight: false
     Authorizers:
       TokenAuthorizer:
         FunctionArn: !GetAtt TokenAuthorizerFunction.Arn 
     AddDefaultAuthorizerToCorsPreflight: false # Preflight requestの認可を除外するための設定
   Cors:
     AllowMethods: "'GET,OPTIONS,POST'"
     AllowHeaders: "'*'"
     AllowOrigin: "'*'"
 Metadata:
   SamResourceId: MyApiGateway

Cors対応が地味にハマったので注意。
APIGatewayのラムダ統合レスポンスはLambda内のhandlerで記述したレスポンス(headerとか)が優先される。(と認識してる。)
しかし、今回なぜかずっとCorsでエラーになっていてハマった。
『プリフライトは通るのだが、次のPOSTで弾かれる』という状況。
そして、最終的に落ち着いたのが、SAMに以下のプロパティを追加する作戦。

AddDefaultAuthorizerToCorsPreflight: false # Preflight requestの認可を除外するための設定
   Cors:
     AllowMethods: "'GET,OPTIONS,POST'"
     AllowHeaders: "'*'"
     AllowOrigin: "'*'"

これ系をワイルドカード(*)で指定するのはよくないので実際は適宜変えてね

OAuthのLambda

私の場合はフロントはReactで組んでいる。
OAuthの流れは以下になる。
① React → Googleの同意画面 → React
② React → Lambda
③ Lambda → Googleサーバのアクセストークン取得用API
④ Lambda → GoogleサーバのJWTトークンデコード用API

この②〜④を担っている。
実質これ一本でOAuth認証はできる。
実際は中身の処理をちゃんと作って責務で分けたほうがいいが、
今回は大枠を作る目的なので、大目に見て。。。

# template.yaml
# ========================================================================================================
# GoogleOAuth関数
# ========================================================================================================
GetGoogleAccessTokenFunction:
 Type: AWS::Serverless::Function
 Properties:
   CodeUri: aws-lambda/
   Handler: app.getGoogleAccessTokenHandler
   Runtime: nodejs18.x
   Architectures:
     - x86_64
   Events:
     GetGoogleAccessToken:
       Type: Api
       Properties:
         Path: /oauth/google
         Method: post
         RestApiId:
           Ref: MyApiGateway
   # Lambdaの環境変数
   Environment:
     Variables:
       RedirectUri: "http://localhost:3000/signIn" # GCPで設定したものを使用すること
       ClientSecret: "HOGEHOGE" # GCPで設定したものを使用すること
   Policies:
     - AmazonDynamoDBFullAccess
 Metadata:
   BuildMethod: esbuild
   BuildProperties:
     Format: esm
     Minify: false
     OutExtension: # 拡張子を.mjにすることでAWSのコンソールで弄れるので開発時はデバッグが楽
       - .js=.mjs
     Target: "es2020"
     UseNpmCi: true
     Sourcemap: true
     EntryPoints:
       - app.ts
     External: # 外部パッケージの追加(ビルドの際に除外される)
       - "https"

Metadata

今回はLambdaをTypeScriptで書いているので、
Metadata:配下には主にTypeScriptのビルド設定が入る。
外部ライブラリ(node_modules)を使う場合など、SAMには多くの工夫の余地がありそう。

OutExtension

これが地味に嬉しかった。
TypeScriptで書くと、トランスパイル後のファイルがLambda側にアップロードされるため、
生のjsで書いていた時のように、コンソール上で直で弄って実験したりできないと思っていた。
OutExtensionで拡張子を指定でき、綺麗なjsに変換されたものがコンソール上で見られた。
ソースの書き換えもjsでコンソール上で行えるので、デバッグやちょっとした確認などで重宝した。

APIGatewayの認可Lambda

APIGatewayの『認可』で設定するLambda。
トークン認証とIAM認証があるが、今回はトークン認証にしている。
ヘッダーに任意のキーとトークンを持たせ、それをこのLambdaがチェックするというもの。

Cors対応を行う場合には、このLambdaがAPIのOPTIONメソッドからのリクエストにレスポンスを返す際にもヘッダーにCorsの『Allow-〇〇』が必要なので気をつける。

# template.yaml
# ========================================================================================================
# オーソライズ関数
# ========================================================================================================
TokenAuthorizerFunction:
 Type: AWS::Serverless::Function
 Properties:
   CodeUri: aws-lambda/
   Handler: app.authorizerHandler
   Runtime: nodejs18.x
 Metadata:
   SamResourceId: TokenAuthorizerFunction
   BuildMethod: esbuild
   BuildProperties:
     Format: esm
     Minify: false
     OutExtension:
       - .js=.mjs
     Target: "es2020"
     Sourcemap: true
     EntryPoints:
       - app.ts
     External:
       - "@aws-sdk/client-dynamodb"

OAuthのLambdaのLambdaのhandler

先ほども出したOAuthの流れ。
① React → Googleの同意画面 → React
② React → Lambda
③ Lambda → Googleサーバのアクセストークン取得用API
④ Lambda → GoogleサーバのJWTトークンデコード用API

②〜④の処理が以下。(かなり雑だが大目に見てね。)

# app.ts
/**
 * Lambdaの引数
 */
interface LambdaEvent {
   body: string;
}

/**
 * AccessToken取得用のGoogleAPIが返してくれるレスポンス
 */
interface OAuthResponse {
   id_token: string
   access_token: string
   expires_in: number
   refresh_token: string
   scope: string
   token_type: string
}

/**
 * id_tokenデコード用のGoogleAPIが返してくれるレスポンス
 */
interface decodeJwtTokenResponseType {
   id: string // id
   email: string // email
   verified_email: true // sub email
   name: string // account name
   given_name: string // first name
   family_name: string // last name
   picture: string // image url
   locale: string // ja
}

/**
 * OAuthの流れ
 * ① React → Googleの同意画面 → React
 * ② React → Lambda
 * ③ Lambda → Googleサーバのアクセストークン取得用API
 * ④ Lambda → GoogleサーバのJWTトークンデコード用API
 *
 * ②〜④の処理
 * @param event
 */
export const getGoogleAccessTokenHandler = async (event: LambdaEvent): Promise<any> => {
   try {
       /****************************************************
        * ② React → Lambda
        * Googleの同意画面から送られてきたcodeとclient_idを取得する
        *****************************************************/
       const eventBody = JSON.parse(event.body);
       const code = eventBody.code;
       const clientId = eventBody.client_id;

       /****************************************************
        *  ③ Lambda → Googleサーバのアクセストークン取得用API
        *  send code from ResourceServer
        *  and, OAuthServer return access_token and other Info
        *****************************************************/
       const getTokenResponse: OAuthResponse = await new Promise((resolve, reject) => {
           const options: https.RequestOptions = {
               hostname: 'www.googleapis.com',
               port: 443,
               path: '/oauth2/v4/token',
               method: 'POST',
               headers: {
                   'Content-Type': 'application/x-www-form-urlencoded'
               }
           };

           const req = https.request(options, (res: any) => {
               let data = '';

               res.on('data', (chunk: any) => {
                   data += chunk;
               });

               res.on('end', () => {
                   resolve(JSON.parse(data));
               });
           });

           req.on('error', (error: any) => {
               reject(error);
           });

           // 送信データ
           const env_client_secret = process.env.ClientSecret
           const env_redirect_uri = process.env.RedirectUri
           if ((env_redirect_uri == undefined) || (env_client_secret == undefined)) {
               console.log('environment parameter is undefined.');
               return {
                   statusCode: 500,
                   body: JSON.stringify({
                       message: 'some error happened',
                   }),
               };
           }

           const postData = `grant_type=authorization_code&access_type=offline&redirect_uri=${env_redirect_uri}&client_secret=${env_client_secret}&client_id=${clientId}&code=${code}`;
           req.write(postData);

           req.end();
       });

       /****************************************************
        *  ④ Lambda → GoogleサーバのJWTトークンデコード用API
        *  send id_token from ResourceServer
        *  and, OAuthServer return GoogleUserInfo
        *****************************************************/
       const idToken = getTokenResponse.id_token;
       const accessToken = getTokenResponse.access_token;

       const decodedJwtTokenResponse: decodeJwtTokenResponseType = await new Promise((resolve, reject) => {
           const options: https.RequestOptions = {
               hostname: 'www.googleapis.com',
               path: `/oauth2/v1/userinfo?id_toke=${idToken}`,
               method: 'GET',
               headers: {
                   'Authorization': `Bearer ${accessToken}`
               }
           };

           const req = https.request(options, (res: any) => {
               let data = '';

               res.on('data', (chunk: any) => {
                   data += chunk;
               });

               res.on('end', () => {
                   resolve(JSON.parse(data));
               });
           });

           req.on('error', (error: any) => {
               reject(error);
           });

           req.end();
       });

       // TODO: DBをチェック
       // // DBクライアント
       // const client = new DynamoDBClient({ region: process.env.Region });
       //
       // // DBをチェック
       // const params = {
       //     TableName: process.env.AuthTable,
       // };
       // const command = new ScanCommand(params);
       // const data = await client.send(command);
       // console.log(data);

       // ユーザーが存在している場合はサインイン処理
       const isExistAccessToken = true; // TODO: DBをチェックした結果を代入する
       if(isExistAccessToken) {
           // HACK: access_tokenの更新
           // const refreshToken = getTokenResponse.refresh_token;

           return {
               statusCode: 200,
               body: JSON.stringify({
                   message: 'signIn is success.',
                   data: userData
               }),
               headers: {
                   'Content-Type': 'application/json',
                   'Access-Control-Allow-Origin': '*',
                   'Access-Control-Allow-Methods': 'OPTIONS,POST,GET',
                   "Access-Control-Allow-Headers": "Content-Type, Authorization"
               },
           };
       }

       // TODO: ユーザーが存在していない場合は新規作成処理
       const insertData = {
           uid: 'hogehogehogehogehogehogehogehogehogehoge',
           access_token: getTokenResponse.access_token,
           access_token_expires: getTokenResponse.expires_in,
           refresh_token: getTokenResponse.refresh_token
       }

       // TODO: DBへ格納する
       const createdData = insertData;

       return {
           statusCode: 200,
           body: JSON.stringify({
               message: 'success sign up',
               data: 'createdData'
           }),
           headers: {
               'Access-Control-Allow-Origin': '*',
               'Access-Control-Allow-Methods': 'OPTIONS,POST,GET',
               'Access-Control-Allow-Headers': 'Content-Type, Authorization'
           },
       };

   } catch (error) {
       return {
           statusCode: 500,
           body: JSON.stringify({ message: 'Internal Server Error' }),
           headers: {
               'Access-Control-Allow-Origin': '*',
               'Access-Control-Allow-Methods': 'OPTIONS,POST,GET',
               'Access-Control-Allow-Headers': 'Content-Type, Authorization'
           },
       };
   }
};

nodeのライブラリのhttpでPOSTをするのが地味にハマった。。。
公式もみんなの記事もgetばっかりで困った。

一応

この内容のtemplate.yamlを準備して、app.tsにこの処理書いて、
sam buildを実行して、
sam deployを実行したら、GoogleOAuthの認証処理の大枠が実装できる。

終わり

SAMとかCloudFormationとかややこしいけど、AWSは知れば知るほど魅力的だと思う、
もっと使いこなせるようになりたいッ!!
なんか、知らないなりに触りまくってたら、ちょっとずつAWSのノリがわかってきた気がする。
AWSの公式リファレンスとか、前より読みやすくなった(慣れただけ)気がするもんね。

Discussion