GAS（GoogleAppsScript）から認証付きのCloud Functionsをサービスアカウントを使用して呼び出す

## はじめに
今回の記事では、スプレッドシートのGoogle Apps Script（以降GASと表記します）からHTTPトリガーで作成した認証付きのCloud Functionsを、サービスアカウントを使用して呼び出すための方法を紹介します。

## 背景
弊社ではGoogle Cloud,Google Workspaceを使用しており、スプレッドシートを業務内で使用することが多々あります。
スプレッドシートを使用していて自動化などをしようと思うとGASが第一の選択肢に上がると思いますが、ちょっと凝ったことをしたい時に制約が大きく弊社ではCloud Functionsを使用してPythonで処理を実装することがあります。
以前はCloud Functionsの処理はPub/Sub経由でCloud Schedulerから定期実行させていたのですが、先日HTTPトリガーをGASから呼び出すように実装し直し、GASからの実行もCloud Schedulerによる定期実行も両方可能な仕組みに変更したので、その方法を紹介させていただきます。

## システム構成
今回実装した構成は以下のようなものになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/de502db3-142f-d91c-1970-f9f2f21bb5eb.png)

テンプレートのスプレッドシートを作成しておいて案件ごとにコピーして使用する、ということはよくあることだと思いますが、今回の実装ではそのような使い方を想定しております。
Cloud Functionsを1つのスプレッドシートのGASから実行するというだけであれば以下の記事のように実装すれば良かったのですが、以下の記事の方法だとスプレッドシートをコピーするたびにGASのプロジェクトの設定やCloud Functionsの再デプロイが必要となることが分かり断念しました。

https://qiita.com/kyhei_0727/items/d6b1cb33ce90b99e8129

検討の末、以下のような方針で実装することにしました。
1. スプレッドシートに付属するコンテナバインド型のGASではなく、スタンドアロン型のGASでCloud Functionsを呼び出す処理を実装する。
2. スタンドアロン型のGASには、スクリプトプロパティ（GASの環境変数のようなもの）でサービスアカウントの情報を設定する。
3. 上記のスタンドアロン型のGASを、スプレッドシートに付属するコンテナバインド型のGASで呼び出して使用する。

サービスアカウントを使用してCloud Functionsを呼び出す処理は、以下の記事や公式ドキュメントなどを参考にしました。なお、今回はCloud Functionsは第一世代を使用しています。

https://ximix.niandc.co.jp/blog/request-from-gas-to-gae-under-iap

https://cloud.google.com/functions/docs/securing/authenticating?hl=ja#authenticating_function_to_function_calls

※GASのスタンドアロン型・コンテナバインド型については、以下の記事などを参照ください。

https://qiita.com/kakakaori830/items/ec405148a9a850149a56

## 事前準備
### サービスアカウントの作成
サービスアカウントの作成方法については[公式ドキュメント](https://cloud.google.com/iam/docs/service-accounts-create?hl=ja)などを参考に実施ください。
[Cloud Functionsを実施するために必要なロール](https://cloud.google.com/functions/docs/securing/authenticating?hl=ja)は以下の通りです。
+ 第1世代：Cloud Functions 起動元(cloudfunctions.functions.invoke)
+ 第2世代：Cloud Run 起動元(run.routes.invoke)

### Cloud Functionsの作成
この記事の本筋ではないため詳細は[公式ドキュメント](https://cloud.google.com/functions/docs/deploy?hl=ja)をご確認いただければと思いますが、今回は以下の前提でデプロイすることを想定しています。
+ HTTPトリガー
+ 認証を必要とする
+ HTTPS必須

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/f658514d-beb0-b33a-6812-3368b2269ccd.png)

## サンプルコード
### Cloud Functionsを呼び出すGAS（スタンドアロン型）
サンプルコードは以下となります。
システム構成の画像の通り、案件ごとのスプレッドシートからCloud Functionsを呼び出して使用しているため、シートから取得した案件名やスプレッドシートのIDをPOSTリクエストで送信しています。

```js
const FUNCTIONS_URL = '<Cloud Functionsのhttpエンドポイント>';
const TARGET_SHEET = '<案件名を取得したいシート名>';
const TARGET_CELL = '<案件名を記載したセル番号>';

// Cloud Functionsを実際に呼び出している関数
function run() {
    const idToken = getIdToken_();
    const spreadSheet = SpreadsheetApp.getActiveSpreadsheet();
    const spreadSheetId = spreadSheet.getId();
    const projectName = spreadSheet
        .getSheetByName(TARGET_SHEET)
        .getRange(TARGET_CELL)
        .getValue();
    const data = {
        'ProjectName': projectName,
        'GspreadKey': spreadSheetId
    };
    const options = {
        'method': 'post',
        'headers': { Authorization: "Bearer ".concat(idToken) },
        'contentType': 'application/json',
        'payload': JSON.stringify(data)
    };
    UrlFetchApp.fetch(FUNCTIONS_URL, options);
    console.log('success');
}

// 以下2つの関数はトークン取得で使用
function getIdToken_() {
    const options = {
        'method': 'post',
        'payload': {
            'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion': getAssertion_()
        }
    };
    const responseContent = UrlFetchApp.fetch('https://oauth2.googleapis.com/token', options).getContentText();
    const response = JSON.parse(responseContent);
    return response.id_token;
}

function getAssertion_() {
    const privateKey = PropertiesService.getScriptProperties().getProperty('PRIVATE_KEY').replace(/\\n/g, '\n');
    const header = {
        alg: 'RS256',
        typ: 'JWT'
    };
    const now = new Date();
    const claimSet = {
        iss: PropertiesService.getScriptProperties().getProperty('CLIENT_EMAIL'),
        sub: PropertiesService.getScriptProperties().getProperty('CLIENT_EMAIL'),
        aud: 'https://www.googleapis.com/oauth2/v4/token',
        exp: (now.getTime() / 1000) + 3000,
        iat: now.getTime() / 1000,
        target_audience: FUNCTIONS_URL
    };
    let toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' + Utilities.base64EncodeWebSafe(JSON.stringify(claimSet));
    toSign = toSign.replace(/=+$/, '');
    const signatureBytes = Utilities.computeRsaSha256Signature(toSign, privateKey);
    let signature = Utilities.base64EncodeWebSafe(signatureBytes);
    signature = signature.replace(/=+$/, '');
    return toSign + '.' + signature;
}

```

#### サンプルコードの詳細
以下、コードの詳細を解説します。
```js
function getIdToken_() {
    const options = {
        'method': 'post',
        'payload': {
            'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
            'assertion': getAssertion_()
        }
    };
    const responseContent = UrlFetchApp.fetch('https://oauth2.googleapis.com/token', options).getContentText();
    const response = JSON.parse(responseContent);
    return response.id_token;
}

function getAssertion_() {
    const privateKey = PropertiesService.getScriptProperties().getProperty('PRIVATE_KEY').replace(/\\n/g, '\n');
    const header = {
        alg: 'RS256',
        typ: 'JWT'
    };
    const now = new Date();
    const claimSet = {
        iss: PropertiesService.getScriptProperties().getProperty('CLIENT_EMAIL'),
        sub: PropertiesService.getScriptProperties().getProperty('CLIENT_EMAIL'),
        aud: 'https://www.googleapis.com/oauth2/v4/token',
        exp: (now.getTime() / 1000) + 3000,
        iat: now.getTime() / 1000,
        target_audience: FUNCTIONS_URL
    };
    const toSign = Utilities.base64EncodeWebSafe(JSON.stringify(header)) + '.' + Utilities.base64EncodeWebSafe(JSON.stringify(claimSet));
    toSign = toSign.replace(/=+$/, '');
    const signatureBytes = Utilities.computeRsaSha256Signature(toSign, privateKey);
    const signature = Utilities.base64EncodeWebSafe(signatureBytes);
    signature = signature.replace(/=+$/, '');
    return toSign + '.' + signature;
}
```
こちらの2つの関数はサービスアカウントからトークンを取得するためのものです。具体的な内容は以下の記事を参考にしていただければと思いますが、

https://ximix.niandc.co.jp/blog/request-from-gas-to-gae-under-iap

上記記事はGAE想定のものですので、以下の点をCloud Functions用で修正しています。なお、関数名の最後に_をつけているのは[このGASからのみこれらの関数を呼べるようにするため](https://developers.google.com/apps-script/guides/libraries?hl=ja#best_practices)です。
+ getIdToken_()は修正なし。
+ getAssertion_()はclaimSetの内容を修正。
  + iss,subにサービスアカウントのメールアドレスを設定します。（スクリプトプロパティから取得）
  + target_audienceにはCloud FunctionsのエンドポイントURLを指定します。
  + パラメータの設定内容は[こちら](https://cloud.google.com/functions/docs/securing/authenticating#generate_tokens_manually)を参照ください。
  :::note warn
  上記Google公式ドキュメントの日本語版のサイトは、2024年4月時点ではパラメータに何を設定すれば良いのか記載が分かりづらいため、英語のサイトを確認することをお勧めします。
  :::

スクリプトプロパティからの値取得は、以下の書き方で実装できます。（スクリプトプロパティの設定方法は後述します。）
```js
PropertiesService.getScriptProperties().getProperty('<スクリプトプロパティ名>')
```

続いてrun()についての解説です。
```js
const idToken = getIdToken_();
const spreadSheet = SpreadsheetApp.getActiveSpreadsheet();
const spreadSheetId = spreadSheet.getId();
const projectName = spreadSheet
    .getSheetByName(TARGET_SHEET)
    .getRange(TARGET_CELL)
    .getValue();
```
先ほどのgetIdToken_()を使用してのトークン取得、スプレッドシートIDとシートに記載した案件名を取得しています。

:::note info
このGASはスタンドアロン型なのでスプレッドシートに紐づくものではありませんが、このGASをスプレッドシートに紐づくコンテナバインド型のGASからライブラリとして呼び出すと、呼び出したスプレッドシートの情報を取得することができます。
:::

```js
const data = {
    'ProjectName': projectName,
    'GspreadKey': spreadSheetId
};
const options = {
    'method': 'post',
    'headers': { Authorization: "Bearer ".concat(idToken) },
    'contentType': 'application/json',
    'payload': JSON.stringify(data)
};
UrlFetchApp.fetch(FUNCTIONS_URL, options);
```
ここでは、まず先ほど取得した案件名やスプレッドシートIDのdata変数に設定しています。
options変数にCloud Functionsへのリクエストに必要な情報を記載します。data変数に設定した内容は'payload'パラメータに設定します。また、認証用のトークンは'headers'に組み込みます。
最後にCloud FunctionsのURLに対してリクエストを投げています。

#### スクリプトプロパティの設定
サービスアカウントの情報をスクリプトプロパティに登録する必要がありますので、[こちら](https://zenn.dev/u1e2k/articles/2cbdeb9db4b3cc)を参考に設定してください。
今回設定しているスクリプトプロパティは以下の2つです。
+ CLIENT_EMAIL：サービスアカウントのメールアドレス
+ PRIVATE_KEY：サービスアカウントの秘密鍵

以上でCloud Functionsを呼び出すGASの作成は完了です。

### スタンドアロン型GASを呼び出すGAS（コンテナバインド型）
続いて、先ほど作成したGASを呼び出すための簡単なGASを作成します。
サンプルコードは以下となります。呼び出し方はいくつかあると思いますが、今回はonOpen関数を使って呼び出していきます。
```js
function onOpen() {
    var ui = SpreadsheetApp.getActiveSpreadsheet().addMenu("<タブに追加したい任意の名称>", [
        {
            name: "実行",
            functionName: "<ライブラリに追加した名称>.run"
        },
    ]);
}
```
※呼び出し方については以下の記事などを参照ください。

https://qiita.com/kakakaori830/items/bd41342e0a7a6defa06f

スタンドアロン型のGASをライブラリとして呼び出す必要がありますので、以下の手順でライブラリに追加します。
1. ライブラリの横の＋をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/7a0a0234-454a-527e-b4a7-b790fcff9521.png)

2. 先に作成したスタンドアロン型GASの「スクリプトID」を入力して検索し、任意のID（デフォルトでプロジェクト名が入りますを指定、「追加」をクリックします。
ここのIDで指定した名称を先ほどのGASの<ライブラリに追加した名称>に設定してください。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/4d483d5f-e61b-38de-8d00-1ec28700bbbd.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2598043/4d70c3e1-f523-4ed0-b0b8-5d528d25b7b0.png)

詳細は[公式ドキュメント](https://developers.google.com/apps-script/guides/libraries?hl=ja#add_a_library_to_your_script_project)を参照ください。

## まとめ
今回はGASからHTTPトリガーで作成した認証付きのCloud Functionsを、サービスアカウントを使用して呼び出す方法を紹介させていただきました。
何か皆様の参考になることがあれば嬉しく思います。ありがとうございました。