LoginSignup
0
0
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Vue3(S3)とSupabase(AWS Lambda)で作るGoogleアカウント認証サイト

Last updated at Posted at 2024-07-02

はじめに

AWS Lambda(node.js)でSupabaseのクライアントを作成し、S3にデプロイしたVue3から使う方法を解説していきます。

また前回の記事でも行ったGoogleをプロバイダーとしてOAuth認証もAWS Lambda越しに操作します。

この記事について

自分の理解度向上のために作成してますので、だいぶ長いです。Supabaseをサーバーレスで使う一例としてご参考になれば幸いです。

Supabaseに関する記事ですが、公式に則った利用方法ではありません。
実際の利用に関してはしっかりとRow Level Securityを設定し、アクセスポリシーも設定しセキュリティ対策をしたうえで使うのが正攻法です。

さもないと、怒られます!:flushed:

caution.png

こういうところがSupabaseのいいところ? (この記事の公開後にメールでRLSを有効にしなさいと、アラートが飛んできました。)

本題に戻ります。こんなRLSも設定しないで公開してしまう私なので、anonkeyも安全にならないので、隠して公開することにしました。

記事の対象の方

  • SupabaseのanonkeyとProject URLを公開せずに使いたい方
  • AWS LambadaでSupabaseを使う方法を知りたい方

構築内容

frontserverとbackendserver.png

  • 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となっている方をコピーしておきます。

API_Settings___Supabase.png

AWSのコンソール画面でSecrets Managerへ先程のkeyとURLを登録します。

新しいシークレットを保存する___Secrets_Manager___Secrets_Manager___ap-northeast-1.png

名前をつけて保存します。

新しいシークレットを保存する.png

このシークレットは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の内容を設定します。

template.yaml
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_NAMEREGION_NAMEBASE_URLを追加しました。

SECRET_NAMEには作成したシークレットにつけた名前、REGION_NAMEにはシークレットを作成したリージョン、BASE_URLには後ほどOAuth認証でリダイレクトに使用するURL(初めはローカルテストのため、http://localhost:5173)を設定しました。

SupabaseProxyFunctionPolicySecrets managerを取得するポリシーと、作成したシークレットのARNをResourceに追加し、Functionから参照して使います。

ApigatewayはCORSの設定が必要なため、Functionとは別に定義しました。

つぎにsupabase-proxyフォルダでaws-sdksupabaseモジュールをインストールします。

cd supabase-proxy 

npm i aws-sdk @supabase/supabase-js

モジュールがインストールされました。

package.json
  "dependencies": {
    "@supabase/supabase-js": "^2.44.2",
    "aws-sdk": "^2.1651.0",
    "axios": ">=0.21.1"
  }

app.mjsを下記の内容に変更します。

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()を利用して、シークレットマネージャーに保存したapikeyproject 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を選択しています。

src以下のフォルダ
❯ 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にリクエストを送ります。

App.vue
<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

Vite_App.png

APIで読み込む分、すこし表示されるまでに時間がかかりますが、無事に表示できました。

では次にGoogleアカウントでログインする設定を追加していきます。

Supabase Authを利用してGoogleアカウントでログイン

まずは前回の記事で設定したSupabaseとGCPのアカウント連携を済ませておきます。

AWS SAMで関数を追加

次にAWS SAMのtemplate.yamlとapp.mjsにログイン用とログアウト用の関数を追加します。

template.yaml
  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にしました。

app.mjs
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を変更できるようにします。

.env.local
VITE_API_BASE_URL=http://localhost:3000

認証情報を保存し、認証情報をやり取りするための機能をpiniaで管理します。/stores/auth.jsを作成して、下記の内容にします。

/src/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にaccsessTokenrefreshToken,sessionData,isAuthenticatedなどセッション情報を格納する変数を作成し、初期値にCookieから読み込み、なければnullにするように設定しました。

またLambda上のSupabase Authへのリクエストも、actionにまとめました。

つぎに先ほど変更したApp.vueはテストを削除し、Googleへのログインボタンを追加します。

App.vue
<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を作成します。

/src/view/AuthCallback.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>

/src/view/DashBoard.vue
<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 を利用しています。

/src/router/index.ts
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

サイトを立ち上げてログインを試してみます。

Cursor_と_Vite_App.png

Cursor_と_ログイン_-_Google_アカウント.png

Cursor_と_Vite_App.png

ダッシュボードに移動できました。PiniaにもaccessTokenrefreshTokensessionDataなどが格納できています。

この時点でもすでにPiniaのuserにSupabaseから取得したセッションユーザー情報は入っているのですが、DashBoardなどでセッションの状態を確認するにはsupabase.auth.getuser()を使うことが推奨されていますので、セッションの確認のためにget.userを使ってみます。(supabase.auth.getSession()はローカルストレージの内容をみるだけとのこと)

ユーザー情報を取得するAPIをLambdaに追加します。

template.yaml
  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
app.mjs
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()を追加します。

/src/stores/auth.js
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()を呼び出すように修正します。

/src/view/DashBoard.vue
<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側もサイトを立ち上げて、ログインしてみます。

Vite_App.png

supabaseのユーザー情報を無事に取得できたようです。最後にサイン・アウトして、セッション情報をクリアしてみます。

Cursor_と_Vite_App.png

サインアウトし、Piniaのセッション情報がクリアされました。Cookieも同様に削除されています。

Cursor_と_Vite_App.png

Lambdaのデプロイと、VueをS3へデプロイ

ここまでローカル側で構築したAWS LambdaとVue3ですが、サーバーへデプロイしていきます。

最初はS3のホスティングを行います。

静的ウェブサイトホスティングを有効化します。

静的ウェブサイトホスティングを編集.png

設定が完了すると、バケットのURLが発行されますので、コピーします。

mt-vue-supabase_-_S3_バケット___S3___ap-northeast-1.png

AWS SAMの環境変数にコピーしたバケットのエンドポイントURLを設定して、buildし、AWSへデプロイします。

template.yaml
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の設定でリソースポリシーを設定します。

Cursor_と_API_Gateway_-_リソースポリシーを編集.png

リソースポリシーには、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をコンソールでデプロイします。

API_Gateway_-_リソース.png

VUEのルートフォルダに.env.productionファイルを作成し、LambdaのエンドポイントURLを設定します。最後のスラッシュはつけません。

.env.production
VITE_API_BASE_URL=https://*******.execute-api.ap-northeast-1.amazonaws.com/Prod

VUEをビルドします。package.jsonのscriptにビルド用コマンドを追加します。

package.json
  "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へアップロードします。

Cursor_と_mt-vue-supabase_-_S3_バケット___S3___ap-northeast-1.png

S3のパブリックアクセスブロックを編集して、「パブリックアクセスをすべて ブロック」を解除します。

Cursor_と_ブロックパブリックアクセス設定を編集_-_S3_バケット_mt-vue-supabase___S3___ap-northeast-1.png

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のエンドポイントを登録します。

Cursor_と_Authentication___Supabase.png

ワイルドカードが使えますので、エンドポイントに/**を追加して設定しておきます。

S3のエンドポイントをブラウザで開くと、作成したvueのサイトが表示されます。

Vite_App.png

ログイン_-_Google_アカウント.png

Cursor_と_Vite_App.png

ログインできました!:laughing:

anonkeyとProject URL

vueでビルドしたパッケージにはすべてのjavascriptが一纏めになり、Supabaseクライアントもそこには含まれますが、anonkeyProject URLも接続情報としてパッケージに含まれす。そのため、サーバーへ公開すれば普通にソースコードからanonkeyProject URLはアクセスしたユーザーが取得出来ます。

Vercelへのデプロイも同様で、やはりanonkeyProject URLはjavascriptへ含まれます。

SupabaseのProject API keysは2種類あり、anonkeyservice_role keyの2種類があり、このうちanonkeyは注釈として「このキーは、テーブルの行レベル・セキュリティ(Row Level Security)を有効にし、ポリシーを設定していれば、ブラウザで使用しても安全です。」となっています。

Supabaseのデフォルト設定ではRow Level Securityは有効になっておらず、すべてのデーブルにアクセスが可能な状態になっています。そのため、セキュリティ対策がしっかりと施されていないsupabaseの環境は、anonkeyProject URLを入手できれば、第三者が読み書き出来てしまうことになります。

また、Supabaseのanonkeyは一般公開を前提として使用されるものであることを意味していると思いますが、gitレポジトリなどには含まれないように.envを作成して、そこからanonkeyProject URLを読み込む方法が推奨されています。

ということはやはり、anonkeyProject 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で、テストフライトし、改善しながら使ってみたいと思います。

こうやったほうが効率的といったご指摘や、間違った説明などがありましたらコメントいただけると助かります。

参考サイト

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0