はじめに
AWS Lambda(node.js)でSupabaseのクライアントを作成し、S3にデプロイしたVue3から使う方法を解説していきます。
また前回の記事でも行ったGoogleをプロバイダーとしてOAuth認証もAWS Lambda越しに操作します。
この記事について
自分の理解度向上のために作成してますので、だいぶ長いです。Supabaseをサーバーレスで使う一例としてご参考になれば幸いです。
Supabaseに関する記事ですが、公式に則った利用方法ではありません。
実際の利用に関してはしっかりとRow Level Security
を設定し、アクセスポリシーも設定しセキュリティ対策をしたうえで使うのが正攻法です。
さもないと、怒られます!
こういうところがSupabaseのいいところ? (この記事の公開後にメールでRLSを有効にしなさいと、アラートが飛んできました。)
本題に戻ります。こんなRLSも設定しないで公開してしまう私なので、anonkeyも安全にならないので、隠して公開することにしました。
記事の対象の方
- SupabaseのanonkeyとProject URLを公開せずに使いたい方
- AWS LambadaでSupabaseを使う方法を知りたい方
構築内容
- AWS Secrets ManagerでSupabaseのanonkeyとProjectURLを管理
- AWS SAMを使い、node.jsのLambda Functionにsupabaseクライアントを設定
- Vue3で構築したクライアントから、Lambda上のSupabaseクライアントをAPI経由で利用
- Supabase AUTHを利用してGoogleアカウントでログイン
- Lambdaのデプロイと、VueをS3へデプロイ
前提
- AWS SAMを使用
- VUE3 Composition API + Typescript + VITE
- VUEのデプロイ先にS3の静的ウェブサイトホスティングを利用
- あまり影響ないかもしれませんが、MacOS環境です
AWS Secrets ManagerへanonkeyとURLを登録
Supabaseのプロジェクトを作成し、Project URLとAPI Keysのanon
public
となっている方をコピーしておきます。
AWSのコンソール画面でSecrets Managerへ先程のkeyとURLを登録します。
名前をつけて保存します。
このシークレットはAWS Lambdaから呼び出して使用します。
AWS SAMを使い、node.jsのLambda Functionにsupabaseクライアントを設定
AWS SAMを利用して関数を作成していきます。
sam init --name lambda-supabase
❯ sam init --name lambda-supabase
You can preselect a particular runtime or package type when using the `sam init` experience.
Call `sam init --help` to learn more.
Which template source would you like to use?
1 - AWS Quick Start Templates
2 - Custom Template Location
Choice: 1
Choose an AWS Quick Start application template
1 - Hello World Example
2 - Data processing
3 - Hello World Example with Powertools for AWS Lambda
4 - Multi-step workflow
5 - Scheduled task
6 - Standalone function
7 - Serverless API
8 - Infrastructure event management
9 - Lambda Response Streaming
10 - Serverless Connector Hello World Example
11 - Multi-step workflow with Connectors
12 - GraphQLApi Hello World Example
13 - Full Stack
14 - Lambda EFS example
15 - Hello World Example With Powertools for AWS Lambda
16 - DynamoDB Example
17 - Machine Learning
Template: 1
Use the most popular runtime and package type? (Python and zip) [y/N]: n
Which runtime would you like to use?
1 - aot.dotnet7 (provided.al2)
2 - dotnet6
3 - go1.x
4 - go (provided.al2)
5 - graalvm.java11 (provided.al2)
6 - graalvm.java17 (provided.al2)
7 - java17
8 - java11
9 - java8.al2
10 - java8
11 - nodejs18.x
12 - nodejs16.x
13 - nodejs14.x
14 - python3.9
15 - python3.8
16 - python3.7
17 - python3.11
18 - python3.10
19 - ruby3.2
20 - ruby2.7
21 - rust (provided.al2)
Runtime: 11
What package type would you like to use?
1 - Zip
2 - Image
Package type: 1
Based on your selections, the only dependency manager available is npm.
We will proceed copying the template using npm.
Select your starter template
1 - Hello World Example
2 - Hello World Example TypeScript
Template: 1
Would you like to enable X-Ray tracing on the function(s) in your application? [y/N]: n
Would you like to enable monitoring using CloudWatch Application Insights?
For more info, please view https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/cloudwatch-application-insights.html [y/N]: n
-----------------------
Generating application:
-----------------------
Name: lambda-supabase
Runtime: nodejs18.x
Architectures: x86_64
Dependency Manager: npm
Application Template: hello-world
Output Directory: .
Configuration file: lambda-supabase/samconfig.toml
Next steps can be found in the README file at lambda-supabase/README.md
node.jsは18.xを選びました。
❯ tree
.
├── README.md
├── events
│ └── event.json
├── samconfig.toml
├── supabase-proxy
│ ├── app.mjs
│ ├── package.json
│ └── tests
│ └── unit
│ └── test-handler.mjs
└── template.yaml
hello_worldフォルダをsupabase-proxyにリネームしました。
template.yamlに環境変数の設定と、API、Policy、Functionの内容を設定します。
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
lambda-supabase
Sample SAM Template for lambda-supabase
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
Function:
Timeout: 3
MemorySize: 128
Environment:
Variables:
SECRET_NAME: mt-supabase-secrets
REGION_NAME: ap-northeast-1
BASE_URL: "http://localhost:5173"
Resources:
SupabaseProxyApi:
Type: AWS::Serverless::Api
Properties:
StageName: 'Prod'
Cors:
AllowOrigin: "'*'"
AllowMethods: "'OPTIONS,POST,GET'"
AllowHeaders: "'Content-Type,X-CSRF-TOKEN,Authorization'"
SupabaseProxyFunctionPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: "Allow"
Action: "secretsmanager:GetSecretValue"
Resource: "****" #<-secret managerのARNを指定
SupabaseProxyFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: supabase-proxy/
Handler: app.lambdaHandler
Runtime: nodejs18.x
Policies:
- AWSLambdaBasicExecutionRole
- !Ref SupabaseProxyFunctionPolicy
Architectures:
- x86_64
Events:
Api:
Type: Api
Properties:
RestApiId: !Ref SupabaseProxyApi
Path: /data
Method: get
Outputs:
ApiUrl:
Description: "URL for the API"
Value: !Sub "https://${SupabaseProxyApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
SupabaseProxyFunctionArn:
Description: "ARN of the Supabase Proxy Function"
Value: !GetAtt SupabaseProxyFunction.Arn
SupabaseProxyFunctionName:
Description: "Name of the Supabase Proxy Function"
Value: !Ref SupabaseProxyFunction
SupabaseProxyApiId:
Description: "ID of the Supabase Proxy API"
Value: !Ref SupabaseProxyApi
Globalsに環境変数として、SECRET_NAME
、REGION_NAME
、BASE_URL
を追加しました。
SECRET_NAME
には作成したシークレットにつけた名前、REGION_NAME
にはシークレットを作成したリージョン、BASE_URL
には後ほどOAuth認証でリダイレクトに使用するURL(初めはローカルテストのため、http://localhost:5173
)を設定しました。
SupabaseProxyFunctionPolicy
へSecrets manager
を取得するポリシーと、作成したシークレットのARNをResourceに追加し、Functionから参照して使います。
ApigatewayはCORSの設定が必要なため、Functionとは別に定義しました。
つぎにsupabase-proxyフォルダでaws-sdk
とsupabase
モジュールをインストールします。
cd supabase-proxy
npm i aws-sdk @supabase/supabase-js
モジュールがインストールされました。
"dependencies": {
"@supabase/supabase-js": "^2.44.2",
"aws-sdk": "^2.1651.0",
"axios": ">=0.21.1"
}
app.mjsを下記の内容に変更します。
import AWS from 'aws-sdk';
import { createClient } from '@supabase/supabase-js';
const secretsManager = new AWS.SecretsManager();
// シークレットの名前を指定
const secretName = process.env.SECRET_NAME;
async function getSecrets() {
return new Promise((resolve, reject) => {
secretsManager.getSecretValue({ SecretId: secretName }, (err, data) => {
if (err) {
reject(err);
} else {
if ('SecretString' in data) {
resolve(JSON.parse(data.SecretString));
} else {
let buff = Buffer.from(data.SecretBinary, 'base64'); // Bufferの生成方法を修正
resolve(JSON.parse(buff.toString('ascii')));
}
}
});
});
}
function getCorsHeader(contentType) {
return {
'Content-Type': String(contentType),
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
}
async function initializeSupabase() {
const secrets = await getSecrets();
const supabaseUrl = secrets.SUPABASE_URL;
const supabaseKey = secrets.SUPABASE_KEY;
return createClient(supabaseUrl, supabaseKey);
}
export const lambdaHandler = async (event, context) => {
try {
const supabase = await initializeSupabase();
// Supabaseクライアントを使用してデータを取得
let { data, error } = await supabase
.from('countries')
.select('*');
if (error) {
throw error;
}
return {
statusCode: 200,
headers: getCorsHeader('application/json'),
body: JSON.stringify(data),
};
} catch (err) {
console.error(err);
return {
statusCode: 500,
headers: getCorsHeader('application/json'),
body: JSON.stringify({
message: 'Internal Server Error',
}),
};
}
};
initializeSupabase()
でgetSecrets()
を利用して、シークレットマネージャーに保存したapikey
とproject url
を読み込み、supabaseクライアントを生成します。
まずはSupabaseのチュートリアルでおなじみのcoutriesテーブルに登録された内容を取り出す内容の関数を作ってみます。
下記のチュートリアルにある、countriesテーブルを作成とデータを登録するSQLをSupabaseのコンソールで実行します。
-- Create the table
CREATE TABLE countries (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
-- Insert some sample data into the table
INSERT INTO countries (name) VALUES ('United States');
INSERT INTO countries (name) VALUES ('Canada');
INSERT INTO countries (name) VALUES ('Mexico');
Lambdaのapp.lambdaHandler
はsupabaseクライアントでcountries
テーブルからselectした結果をdataに格納し、bodyの値として返す関数です。
なお、データをブラウザから読み取って表示するためにはCORS対策が必要になるのでレスポンスヘッダーにgetCorsHeader(contentType)
で'Access-Control-Allow-Origin': '*'
などのヘッダーを付けています。
一度ここでビルドしておきます。
sam build --use-container
Build Succeeded
Built Artifacts : .aws-sam/build
Built Template : .aws-sam/build/template.yaml
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
ビルドが出来たらSAM CLIでローカルにAPIを立ち上げておきます。
sam local start-api
{{略}}
Mounting SupabaseProxyFunction at http://127.0.0.1:3000/data [GET, OPTIONS]
APIを立ち上げたので、リクエストを送るvueの作成に移ります。
Vue3で構築したクライアントから、Lambda上のSupabaseクライアントをAPI経由で利用
先ほど作成したLambdaからデータを呼び出すVueのサイトを作成します。
npm create vue@latest
❯ npm create vue@latest
Vue.js - The Progressive JavaScript Framework
✔ Project name: … vue-lambda
✔ Add TypeScript? … Yes
✔ Add JSX Support? … No
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit Testing? … No
✔ Add an End-to-End Testing Solution? › No
✔ Add ESLint for code quality? … No
✔ Add Vue DevTools 7 extension for debugging? (experimental) … No
Scaffolding project in /vue-supabase/vue-lambda...
Done. Now run:
cd vue-lambda
npm install
npm run dev
今回はTypeScriptとVue Router、Piniaを選択しています。
❯ tree
.
├── App.vue
├── assets
│ ├── base.css
│ ├── logo.svg
│ └── main.css
├── components
│ ├── HelloWorld.vue
│ ├── TheWelcome.vue
│ ├── WelcomeItem.vue
│ └── icons
├── main.ts
├── router
│ └── index.ts
├── stores
│ └── counter.ts
└── views
├── AboutView.vue
└── HomeView.vue
まずはシンプルにaxiosをインストールして、先程のLambdaのAPIを取得できるようにします。
cd vue-lambda
npm install
npm install axios
App.vueにaxiosを追加して、LambdaのAPIにリクエストを送ります。
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import axios from 'axios';
const items = ref([]);
onMounted(async () => {
await axios.get('http://127.0.0.1:3000/data')
.then((response) => {
items.value = response.data;
})
.catch((error) => {
console.error('Error fetching data:', error);
});
});
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<div>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</div>
<RouterView />
</template>
<style scoped>
省略
</style>
SAM CLIでテストするので、APIはhttp://127.0.0.1:3000/data
にしています。
いったんサイトを立ち上げてローカル同士でテストしてみます。
npm run dev
APIで読み込む分、すこし表示されるまでに時間がかかりますが、無事に表示できました。
では次にGoogleアカウントでログインする設定を追加していきます。
Supabase Authを利用してGoogleアカウントでログイン
まずは前回の記事で設定したSupabaseとGCPのアカウント連携を済ませておきます。
AWS SAMで関数を追加
次にAWS SAMのtemplate.yamlとapp.mjsにログイン用とログアウト用の関数を追加します。
SupabaseAuthFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: supabase-proxy/
Handler: app.authHandler
Runtime: nodejs18.x
Policies:
- AWSLambdaBasicExecutionRole
- !Ref SupabaseProxyFunctionPolicy
Architectures:
- x86_64
Events:
Api:
Type: Api
Properties:
RestApiId: !Ref SupabaseProxyApi
Path: /auth
Method: get
SupabaseAuthCallbackFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: supabase-proxy/
Handler: app.authCallbackHandler
Runtime: nodejs18.x
Policies:
- AWSLambdaBasicExecutionRole
- !Ref SupabaseProxyFunctionPolicy
Architectures:
- x86_64
Events:
Api:
Type: Api
Properties:
RestApiId: !Ref SupabaseProxyApi
Path: /auth/callback
Method: post
SupabaseSignOutFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: supabase-proxy/
Handler: app.signOutHandler
Runtime: nodejs18.x
Policies:
- AWSLambdaBasicExecutionRole
- !Ref SupabaseProxyFunctionPolicy
Architectures:
- x86_64
Events:
Api:
Type: Api
Properties:
RestApiId: !Ref SupabaseProxyApi
Path: /auth/signout
Method: post
GoogleのアカウントログインURLを取得するSupabaseAuthFunction
、取得したアクセストークンを利用してsession をセットするSupabaseAuthCallbackFunction
、ログアウトに利用するSupabaseSignOutFunction
を追加します。auth/callback
と/auth/signout
は認証情報を渡すのでPOSTにしました。
export const authHandler = async (event, context) => {
try {
const supabase = await initializeSupabase();
const baseUrl = process.env.BASE_URL;
console.log(baseUrl);
const response = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${baseUrl}/#/auth/callback`,
queryParams: {
prompt: 'select_account'
}
}
});
const authUrl = response.data.url;
return {
statusCode: 200,
headers: getCorsHeader('application/json'),
body: JSON.stringify({ auth_url: authUrl }),
};
} catch (err) {
console.error(`Error during OAuth sign-in: ${err}`);
return {
statusCode: 500,
headers: getCorsHeader('application/json'),
body: 'Internal server error'
};
}
};
export const authCallbackHandler = async (event, context) => {
try {
const supabase = await initializeSupabase();
const headers = getCorsHeader('text/plain');
// リクエストボディから認証コードを取得
const body = JSON.parse(event.body || '{}');
const accessToken = body.access_token;
const refreshToken = body.refresh_token;
if (!accessToken || !refreshToken) {
return {
statusCode: 400,
headers: headers,
body: 'Access token or refresh token is missing'
};
}
const res = await supabase.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken
});
return {
statusCode: 200,
headers: headers,
body: JSON.stringify(res)
};
} catch (error) {
console.error(`Error setting session: ${error}`);
return {
statusCode: 500,
headers: headers,
body: 'Internal server error'
};
}
};
export const signOutHandler = async (event, context) => {
const headers = getCorsHeader('application/json');
const token = event.headers['Authorization'] || event.headers['authorization'];
if (!token) {
return {
statusCode: 400,
headers: headers,
body: JSON.stringify({
error: 'Authorization token missing'
})
};
}
// Bearerトークンの前の "Bearer " を取り除く
const accessToken = token.split(' ')[1] || token;
try {
const supabase = await initializeSupabase();
const { error } = await supabase.auth.signOut(accessToken);
if (error) {
return {
statusCode: error.status || 400,
headers: headers,
body: JSON.stringify({
error: error.message || 'Sign out failed'
})
};
}
return {
statusCode: 200,
headers: headers,
body: JSON.stringify({
message: 'Successfully signed out'
})
};
} catch (err) {
console.error(`Error during sign out: ${err}`);
return {
statusCode: 500,
headers: headers,
body: JSON.stringify({
error: 'Internal server error'
})
};
}
};
authHandler
はAPIへリクエストを送ると、GoogleをOAuthプロバイダーとする認証用のURLをレスポンスとして返します。環境変数より認証後にリダイレクトするURLを${baseUrl}
で設定しています。
authCallbackHandler
はリクエストボディからアクセストークンとリフレッシュトークンを取得し、supabaseでセッションをsetし、その結果をvue側へ返します。
signOutHandler
はheaderにBearerトークンとして取得したアクセストークンを利用してsupabaseでサインアウトします。サインアウトは返却値がエラーのみなので、メッセージを返します。
構築したら再度buildしてapiを立ち上げておきます。
sam build --use-container
sam local start-api
VUEでログイン処理を作成
続いてVUE側も作成していきます。
認証情報はVue側のPiniaで管理しますが、ブラウザにセッション情報を保管するためにCookieを使用しますので、vue-cookiesモジュールをインストールします。
npm install vue-cookies --save
デプロイ後にリクエストするAPIも切り替わるため、ここでVUEのルートディレクトリに.env.localを用意し、後ほどAPIを変更できるようにします。
VITE_API_BASE_URL=http://localhost:3000
認証情報を保存し、認証情報をやり取りするための機能をpiniaで管理します。/stores/auth.jsを作成して、下記の内容にします。
import { defineStore } from 'pinia';
import axios from 'axios';
import VueCookies from 'vue-cookies';
// 環境変数からベースURLを取得
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
accessToken: VueCookies.get('accessToken') || null,
refreshToken: VueCookies.get('refreshToken') || null,
sessionData: null,
isAuthenticated: !!VueCookies.get('accessToken'),
}),
getters: {
isLoggedIn: (state) => state.user,
},
actions: {
signInWithGoogle() {
axios.get(`${API_BASE_URL}/auth`)
.then(response => {
const { auth_url } = response.data;
window.location.href = auth_url;
})
.catch(error => {
console.error('Error fetching auth URL:', error);
});
},
authCallback(accessToken, refreshToken,router) {
axios.post(`${API_BASE_URL}/auth/callback`, {
access_token: accessToken.value,
refresh_token: refreshToken.value
})
.then(response => {
// Piniaストアに保存
this.setAuthData(accessToken.value, refreshToken.value, response.data);
// 他のページへリダイレクト
router.push({ name: 'dashboard' });
})
.catch(error => {
console.error('認証エラー:', error.message);
router.push({ name: 'home' });
});
},
setAuthData(accessToken, refreshToken, sessionData) {
this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.sessionData = sessionData.data;
this.user = this.sessionData.user;
this.isAuthenticated = true;
VueCookies.set('accessToken', this.accessToken, '7d');
VueCookies.set('refreshToken', this.refreshToken, '7d');
},
clearAuthData() {
this.accessToken = null;
this.refreshToken = null;
this.sessionData = null;
this.user = null;
this.isAuthenticated = false;
VueCookies.remove('accessToken');
VueCookies.remove('refreshToken');
},
}
});
piniaのstateにaccsessToken
、refreshToken
,sessionData
,isAuthenticated
などセッション情報を格納する変数を作成し、初期値にCookieから読み込み、なければnullにするように設定しました。
またLambda上のSupabase Authへのリクエストも、actionにまとめました。
つぎに先ほど変更したApp.vueはテストを削除し、Googleへのログインボタンを追加します。
<script setup>
import { useRouter, RouterLink, RouterView } from 'vue-router';
import { useAuthStore } from './stores/auth';
const authStore = useAuthStore();
const router = useRouter();
</script>
<template>
<div>
<div v-if="authStore.isAuthenticated">
<p>Welcome! You are authenticated.</p>
<button @click="authStore.signOut(router)">Logout</button>
</div>
<div v-else>
<p>Please sign in.</p>
<button @click="authStore.signInWithGoogle">Sign in with Google</button>
</div>
</div>
<RouterView />
</template>
<style scoped></style>
Googleの認証でリダイレクト先となるAuthCallback.vue
と、ログイン後の遷移先DashBorad.vue
を作成します。
<script setup>
import { ref, onMounted } from 'vue';
import { useAuthStore } from '../stores/auth';
import { useRouter } from 'vue-router';
const router = useRouter();
const authStore = useAuthStore();
const accessToken = ref(null);
const refreshToken = ref(null);
onMounted(async () => {
// リダイレクト後アクセストークンとリフクレッシュトークンが付与されたURLからパラメータを含む'#'以降を取得
const hash = window.location.hash;
const params = new URLSearchParams(hash.substring(hash.indexOf('#') + 1)); // '#'を除外してパース
// /callback以降の#を取得
const callbackHash = hash.split('#')[2];
const callbackParams = new URLSearchParams(callbackHash);
accessToken.value = callbackParams.get('access_token');
refreshToken.value = callbackParams.get('refresh_token');
if (accessToken.value && refreshToken.value) {
// URLをクリーンアップ
window.history.replaceState(
window.history.state, // 既存のhistory.stateを保持
'',
window.location.pathname
);
authStore.authCallback(accessToken, refreshToken, router);
}
});
</script>
<template>
<div>
<h1>Authenticating...</h1>
</div>
</template>
<script setup>
import { onMounted, ref, onUnmounted } from 'vue';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
</script>
<template>
<h1>DashBoard</h1>
<p>Welcome to our service</p>
</template>
Vue Routerで作成したルーティングを設定します。S3で公開する際にリダイレクトできるようにcreateWebHashHistory
を利用しています。
import { createRouter, createWebHashHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import AuthCallback from '../views/AuthCallback.vue';
import { useAuthStore } from '../stores/auth'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL), // createWebHashHistoryを使用
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/auth/callback',
name: 'AuthCallback',
component: AuthCallback,
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashBoard.vue'), // ログイン後のダッシュボード
meta: {
requiresAuth: true,
},
},
]
})
router.beforeEach((to) => {
const auth = useAuthStore();
if (!auth.isLoggedIn && to.meta.requiresAuth) {
return { name: 'home' };
}
});
export default router
サイトを立ち上げてログインを試してみます。
ダッシュボードに移動できました。PiniaにもaccessToken
やrefreshToken
、sessionData
などが格納できています。
この時点でもすでにPiniaのuser
にSupabaseから取得したセッションユーザー情報は入っているのですが、DashBoardなどでセッションの状態を確認するにはsupabase.auth.getuser()を使うことが推奨されていますので、セッションの確認のためにget.userを使ってみます。(supabase.auth.getSession()はローカルストレージの内容をみるだけとのこと)
ユーザー情報を取得するAPIをLambdaに追加します。
SupabaseGetUserFunction:
Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
Properties:
CodeUri: supabase-proxy/
Handler: app.getUserHandler
Runtime: nodejs18.x
Policies:
- AWSLambdaBasicExecutionRole
- !Ref SupabaseProxyFunctionPolicy
Architectures:
- x86_64
Events:
Api:
Type: Api
Properties:
RestApiId: !Ref SupabaseProxyApi
Path: /data/user
Method: post
export const getUserHandler = async (event, context) => {
try {
const supabase = await initializeSupabase();
const token = event.headers['Authorization'] || event.headers['authorization'];
if (!token) {
return {
statusCode: 400,
headers: getCorsHeader('application/json'),
body: JSON.stringify({
error: 'Authorization token missing'
})
};
}
const accessToken = token.split(' ')[1] || token;
const res = await supabase.auth.getUser(accessToken);
return {
statusCode: 200,
headers: getCorsHeader('application/json'),
body: JSON.stringify(res.data),
};
} catch (err) {
console.error(err);
return {
statusCode: 500,
headers: getCorsHeader('application/json'),
body: JSON.stringify({
message: 'Internal Server Error',
}),
};
}
};
VUEはPiniaのactions
内に、ユーザーを取得するgetUser()
を追加します。
actions: {
<<省略>>
getUser() {
const accessToken = this.accessToken;
axios.post(`${API_BASE_URL}/data/user`, {}, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
}).then((response) => {
this.user = response.data.user;
}).catch((error) => {
console.error('Error fetching user:', error.message);
});
},
<<省略>>
}
DashBoard.vueでgetUser()を呼び出すように修正します。
<script setup>
import { onMounted, ref, onUnmounted } from 'vue';
import { useAuthStore } from '@/stores/auth';
const user = ref(null);
const authStore = useAuthStore();
onMounted(async () => {
await authStore.getUser();
user.value = authStore.user; // authStoreからuserを取得
});
</script>
<template>
<h1>DashBoard</h1>
<p>Welcome to our service</p>
<div v-if="user">
<p><strong>Name:</strong> {{ user.user_metadata.full_name }}</p>
<p><strong>Email:</strong> {{ user.email }}</p>
<img v-if="user.user_metadata.avatar_url" :src="user.user_metadata.avatar_url" alt="User Avatar">
</div>
</template>
再度、AWS SAMでbuildし、APIを開始します。VUE側もサイトを立ち上げて、ログインしてみます。
supabaseのユーザー情報を無事に取得できたようです。最後にサイン・アウトして、セッション情報をクリアしてみます。
サインアウトし、Piniaのセッション情報がクリアされました。Cookieも同様に削除されています。
Lambdaのデプロイと、VueをS3へデプロイ
ここまでローカル側で構築したAWS LambdaとVue3ですが、サーバーへデプロイしていきます。
最初はS3のホスティングを行います。
静的ウェブサイトホスティングを有効化します。
設定が完了すると、バケットのURLが発行されますので、コピーします。
AWS SAMの環境変数にコピーしたバケットのエンドポイントURLを設定して、buildし、AWSへデプロイします。
Globals:
Function:
Timeout: 3
MemorySize: 128
Environment:
Variables:
SECRET_NAME: mt-supabase-secrets
REGION_NAME: ap-northeast-1
BASE_URL: "http://*******.s3-website-ap-northeast-1.amazonaws.com" #ここに設定
sam build --use-container
sam deploy --guided
デプロイに成功すると、LambdaのAPIエンドポイントのURLが発行されます。
---------------------------------------------------------------------------------------------------------------------
Outputs
---------------------------------------------------------------------------------------------------------------------
Key ApiUrl
Description URL for the API
Value https://********.execute-api.ap-northeast-1.amazonaws.com/Prod/
このAPIへのアクセスを先程のS3からのみに制限したいので、AWSコンソールでAPI Gatewayの設定でリソースポリシーを設定します。
リソースポリシーには、S3のURLを設定します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": [
"execute-api:/*"
]
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "execute-api:Invoke",
"Resource": [
"execute-api:/*"
],
"Condition": {
"StringNotLike": {
"aws:Referer": "http://********.s3-website-ap-northeast-1.amazonaws.com/*"
}
}
}
]
}
execute-api
は保存すると、APIのARNに変換されます。ポリシーを変更したらAPIをコンソールでデプロイします。
VUEのルートフォルダに.env.production
ファイルを作成し、LambdaのエンドポイントURLを設定します。最後のスラッシュはつけません。
VITE_API_BASE_URL=https://*******.execute-api.ap-northeast-1.amazonaws.com/Prod
VUEをビルドします。package.jsonのscriptにビルド用コマンドを追加します。
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"build:prod": "vite build --mode production"
},
npm run build:prod
VUEのルートフォルダにdist
が構築されました。このフォルダにある、index.html
,favicon.ico
,assets
をS3へアップロードします。
S3のパブリックアクセスブロックを編集して、「パブリックアクセスをすべて ブロック」を解除します。
S3バケットポリシーを作成します。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::Bucket-Name/*"
]
}
]
}
supabaseのコンソールでリダイレクトのURLを登録
SupabaseのOAuthでリダイレクトに設定するURLは予めSupabaseのダッシュボードで登録が必要です。Authenticationメニュー内にあるURL Configrationを開き、Redirect URLsにVue3のエンドポイントを登録します。
ワイルドカードが使えますので、エンドポイントに/**
を追加して設定しておきます。
S3のエンドポイントをブラウザで開くと、作成したvueのサイトが表示されます。
ログインできました!
anonkeyとProject URL
vueでビルドしたパッケージにはすべてのjavascriptが一纏めになり、Supabaseクライアントもそこには含まれますが、anonkey
とProject URL
も接続情報としてパッケージに含まれす。そのため、サーバーへ公開すれば普通にソースコードからanonkey
とProject URL
はアクセスしたユーザーが取得出来ます。
Vercelへのデプロイも同様で、やはりanonkey
とProject URL
はjavascriptへ含まれます。
SupabaseのProject API keysは2種類あり、anonkey
とservice_role key
の2種類があり、このうちanonkey
は注釈として「このキーは、テーブルの行レベル・セキュリティ(Row Level Security
)を有効にし、ポリシーを設定していれば、ブラウザで使用しても安全です。」となっています。
Supabaseのデフォルト設定ではRow Level Securityは有効になっておらず、すべてのデーブルにアクセスが可能な状態になっています。そのため、セキュリティ対策がしっかりと施されていないsupabaseの環境は、anonkey
とProject URL
を入手できれば、第三者が読み書き出来てしまうことになります。
また、Supabaseのanonkeyは一般公開を前提として使用されるものであることを意味していると思いますが、gitレポジトリなどには含まれないように.envを作成して、そこからanonkey
とProject URL
を読み込む方法が推奨されています。
ということはやはり、anonkey
とProject URL
は公開前提であっても、他の人に見られたくない秘匿情報だということです。
anonkeyとProject URLを公開せずに使う
正直なところ、Row Level Securityを使わずに公開してしまうといったミスは、残念ながら私なら十分に起こり得る話なので、手間はかかりますが、APIでカプセル化しておくのが安全です。
まとめ
SupabaseがAWS Lambdaで操作できるようになったので、AWSの強力なサービスと簡単に連動できるようになりました。 SQSでキューイングもできるし、VPCに入れちゃえばIP制限のあるAPIとかも連携できちゃいますね。(本当はこれ狙い)
SSLの利用やCORSもしっかりと設定するとなってくると、AWS Cloudfront
も必要になりますね。
next.jsにはサーバーサイド用のライブラリもあるようですが、anonkeyの扱いはあまり変わりがなさそう?
セッションの管理はまだまだ実運用には迷う箇所があります。
SupabaseのAuthは他にも便利なAPIが多く、複数ユーザーでの利用を考えると、このコードでは物足りないので、ひとまず、S3にホスティングしたVUEで、テストフライトし、改善しながら使ってみたいと思います。
こうやったほうが効率的といったご指摘や、間違った説明などがありましたらコメントいただけると助かります。
参考サイト