【AWS】GoogleOAuthをSAMテンプレで作った
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