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

# はじめに

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

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

## この記事について

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

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

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

![caution.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/09b567f7-6f55-d827-4382-1b01ae784f1c.png)

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

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

## 記事の対象の方

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


## 構築内容

![frontserverとbackendserver.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/206a711a-04cd-3972-76ff-66cba149d960.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のデプロイ先にS３の静的ウェブサイトホスティングを利用
- あまり影響ないかもしれませんが、MacOS環境です

## AWS Secrets ManagerへanonkeyとURLを登録

Supabaseのプロジェクトを作成し、Project URLとAPI Keysの`anon` `public`となっている方をコピーしておきます。

![API_Settings___Supabase.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/3961ad6a-a626-564b-ba21-64d5a7a07708.png)

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

![新しいシークレットを保存する___Secrets_Manager___Secrets_Manager___ap-northeast-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/4d826dc8-addd-4813-40ba-955672b1056f.png)

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

![新しいシークレットを保存する.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/17310e01-b196-4b6a-83f0-3b8b723c1607.png)

このシークレットはAWS Lambdaから呼び出して使用します。

## AWS SAMを使い、node.jsのLambda Functionにsupabaseクライアントを設定

AWS SAMを利用して関数を作成していきます。

```
sam init --name lambda-supabase
```

```shell
❯ 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を選びました。

```files
❯ 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_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`モジュールをインストールします。

```shell
cd supabase-proxy 

npm i aws-sdk @supabase/supabase-js
```

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

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

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

```javascript: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のコンソールで実行します。


https://supabase.com/docs/guides/getting-started/quickstarts/vue


```SQL
  -- 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のサイトを作成します。


```bash
npm create vue@latest
```

```zsh
❯ 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を選択しています。


```shell: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を取得できるようにします。

```bash
cd vue-lambda
npm install
npm install axios
```

App.vueにaxiosを追加して、LambdaのAPIにリクエストを送ります。

```vuejs: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/ee100b8e-b2ba-9051-a4ec-ddb9b193021e.png)

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

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

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

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

https://qiita.com/murodon/items/b78d980c163e8cd7d649


### 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にしました。

```javascript: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を作成して、下記の内容にします。

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

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

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

```vuejs: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`を作成します。

```vuejs:/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>

```

```vuejs:/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` を利用しています。

```typescript:/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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/4f89dcd6-3826-2a11-3ed1-ca69323e7679.png)


![Cursor_と_ログイン_-_Google_アカウント.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/293695f5-44a3-1204-798e-b3f5dfb6e324.png)

![Cursor_と_Vite_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/942448eb-8783-9f90-8781-7cb3310ce151.png)

ダッシュボードに移動できました。Piniaにも`accessToken`や`refreshToken`、`sessionData`などが格納できています。

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

https://supabase.com/docs/reference/javascript/auth-getuser

ユーザー情報を取得する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
```

```javascript: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の`actionｓ`内に、ユーザーを取得する`getUser()`を追加します。

```typescript:/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()を呼び出すように修正します。

```vuejs:/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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/83df80fa-03c2-5307-dd2e-1261fe042254.png)

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

![Cursor_と_Vite_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/eaa63d63-b62a-96c6-306b-030ab6045338.png)

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

![Cursor_と_Vite_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/ee1cfe17-c3e5-c873-85b0-b6d8d21f3cf4.png)


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

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

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

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/HostingWebsiteOnS3Setup.html

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

![静的ウェブサイトホスティングを編集.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/c668f5ff-0dc9-ea66-b94a-cd28e403fb7d.png)

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

![mt-vue-supabase_-_S3_バケット___S3___ap-northeast-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/f4bd4ead-4fa3-3824-a8e9-53a14424d7fa.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が発行されます。

```bash
---------------------------------------------------------------------------------------------------------------------
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/0b960c4c-2087-4b11-d8cb-52355b0a20c8.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/9d7eac0c-5080-f047-f4f8-68a9ed220d28.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/70af92d6-23d7-812f-31b3-ca6e94813929.png)

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

![Cursor_と_ブロックパブリックアクセス設定を編集_-_S3_バケット_mt-vue-supabase___S3___ap-northeast-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/9b505bed-1a80-9c31-0b53-cb0b4bd8d4a1.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/ef1d5263-e370-da75-56ca-f29eed6730eb.png)

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



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

![Vite_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/9d1f5c5c-6431-0776-1171-406c22481800.png)


![ログイン_-_Google_アカウント.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/748b2a50-e137-9231-3163-ca4015cbc8d3.png)


![Cursor_と_Vite_App.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38388/6a34ec4d-4c10-4623-a308-12151cfe864f.png)

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

## anonkeyとProject URL

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

[Vercel](https://vercel.com/)へのデプロイも同様で、やはり`anonkey`と`Project URL`はjavascriptへ含まれます。

SupabaseのProject API keysは２種類あり、`anonkey`と`service_role　key`の２種類があり、このうち`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で、テストフライトし、改善しながら使ってみたいと思います。

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

# 参考サイト

https://zenn.dev/hrtk/articles/2a1492c9c2cc15

https://zenn.dev/k_log24/articles/ff1581de72b0aa

https://qiita.com/AK_React/items/f39c4309c0ac8b592956