GAS × OAuth2.0:実践で使える実行可能API構築の手順

Google Apps Script × OAuth2.0:実践で使える実行可能API構築の手順

先日、久しぶりにGoogle App Script(GAS)で作成したコードのメンテナンスのために、GASのエディタを開いた龍ちゃんです。既存のコードが動いているからと言って、自分がメンテしていない外部APIを使用していると、知らないうちに更新が入る可能性もありますよね。やはり失敗を検知する仕組みは必要ですね。

今回は「GASの実行可能API」についてのお話です。GASの公開方法としては「ウェブアプリ」「実行可能API」の二つがあります。実行可能APIとしてデプロイされたアプリを実行するためには、Google OAuth2.0を利用して認可を行い、アクセストークンを取得する必要があります。

実際にアクセストークンを取得する流れをステップバイステップで解説していきます。

前提条件

バックエンドはnest.jsを使用して作成しています。具体的なコードに関しては、ブログの最後に記載しておきます。

デプロイ先

想定している環境としては、クライアントをAzure Static Web App(SWA)、バックエンドをAzure App Seriveで構築しています。SWAとApp ServiceはAPIリンク(Bring Your Own Functions方式)で接続しています。APIリンクの場合は、バックエンドを/apiにルーティングして同一ホストとして処理することができます。

ソースコードはGitに上がっています。

実行可能APIを作成する(Google App Script)

検証のために以下の簡易的な関数を作成してデプロイをします。

  • String : “Hello World!!”を返答する関数
  • Google Sheetに書き込まれている情報をすべて取得・返答する
  • Google Sheetに一行追加する

実行可能APIとしてデプロイする前にGASのエディタ上で実行しておくと、デバッグすることができます。

Health Check Function:Hello Worldを返答する関数

非常に単純な関数です。

function healthCheckFunction() {
  return "Hello World!";
}

getSheetAllData:Google Sheetに書き込まれている情報をすべて返答

GASからGoogle Sheetを操作して、Sheet内にあるすべての情報を取得する関数になります。GASからGoogle Sheetを操作する方法に関しては、こちらの「Google Apps Script スプレッドシート編 初心者向け」でまとめています。

function getSheetAllData() {

	// SHEET_ID・SHEET_NAMEの情報を更新してください
  const file = SpreadsheetApp.openById("SHEET_ID")
  const sheet = file.getSheetByName("SHEET_NAME")

  const lastRow = sheet.getLastRow()
  // 情報がなければスルー
  if (lastRow == 1) return[]
  
  const itemList = sheet.getRange(2, 1, lastRow - 1, 2).getValues()
  return itemList
}

insertDataToTargetSheet:Google Sheetに一行情報を追記する

こちらはGoogle Sheetの一番最後の行に情報を追記します。こちらも同様に「Google Apps Script スプレッドシート編 初心者向け」でまとめています。

function insertDataToTargetSheet(url = "test") {
	// SHEET_ID・SHEET_NAMEの情報を更新してください
  const file = SpreadsheetApp.openById("SHEET_ID")
  const sheet = file.getSheetByName("SHEET_NAME")
  
  const lastRow = sheet.getLastRow()
  sheet.getRange(lastRow + 1, 1).setValue(url)
}

引数でテキスト情報(URL)を受け取り、その情報をSheetに記入しています。

実行可能APIとしてデプロイする

実行可能APIとしてデプロイする前にプロジェクトをGCPと接続する必要があります。これはGASをAPI経由で実行する際、GCPプロジェクトから認可を発行して権限を確認するためです。

あとは、デプロイを作成しましょう。

GASが使用しているOAuthスコープを確認する

プロジェクトの概要に移動するとGASプロジェクトが使用しているOAuthスコープを確認することができます。こちらは、実行可能APIとしてデプロイ後、外部からAPIをたたく際に取得するアクセストークンのスコープに収める必要があります。

今回であれば、以下のスコープになります。

スコープ概要説明
https://www.googleapis.com/auth/script.external_requestGASから外部のAPIへアクセスする際に必要(UrlFetchApp)
https://www.googleapis.com/auth/spreadsheetsGASからGoogle Sheetを操作するのに必要

Google Script Run実行

構築に必要なエンドポイントは4つになります。フロントエンド側から実行する順番に解説をしていきます。

  1. 有効なトークンを保持しているか検証エンドポイント /api/google-auth/verify
    1. 200の場合はトークン発行済み
    2. 401の場合は認可フロー開始:URL発行
  2. Google認可フロー
    1. OAuth2.0認可用URL発行エンドポイント /api/google-auth
    2. OAuth2.0 Callbackエンドポイント /api/google-auth/callback
  3. Google Script Run実行用エンドポイント /api/google-auth/test

ソースコードは長くなるので、最後にまとめて記載します。Gitのリポジトリとしては、こちらを参照してください。

Google認可プロバイダー設定

Googleの認可プロバイダーの設定をする必要があります。「承認済みのJavaScript生成元」「承認済みのリダイレクトURI」は適宜設定してください。

ローカルで検証する場合は、以下の値を設定していました。赤枠の値は後で必要になるので、値をコピーしておいてください。

プロパティ
承認済みのJavaScript生成元http://localhost:5000
承認済みのリダイレクトURIhttp://localhost:5000/api/google-auth/callback

次にAPI Libraryにアクセスして必要になるAPIを有効化させます。Apps Script APIを有効にしています。

nest.jsで開発するためにはGoogle Auth Libraryを導入する必要があります。

npm install google-auth-library

クライアントの作成には赤枠から情報を取得した情報を使用する必要があります。

import { OAuth2Client } from 'google-auth-library';

const client = new OAuth2Client({
  clientId: "CLIENT ID",
  clientSecret: "CLIENT SECRET",
  redirectUri: "REDIRECT URI",
});

環境変数としてはConfigurationを使用して保存しておけばアクセスがしやすくなります。

1. トークンを取得済みか検証する

こちらのエンドポイントでは、Cookiesにトークンが保持されているかを確認します。Cookieに保存されているトークンを検証して、期限切れの場合は認可用のURLを発行して認可フローへ誘導します。

実装パターンとしては、401のエラーメッセージを拡張して認可用URLを埋め込んで返答しています。クライアント側で一度/api/google-auth/veify を叩くことで認可まで一気に進めることができます。

2. Google認可フロー

認可フロー開始からアクセストークン取得までを一気に解説します。認可用URLにリダイレクトするとGoogleの画面が入るのでアカウント情報を入力すると、リダイレクトURIに設定したパスに認可コード付き(クエリ)でコールバックが返ってきます。

認可コードからIDトークンとアクセストークンを取得することができ、Cookiesに情報を保持します。Cookiesの保存期間としては1時間を保存期間としています。

最終的に好きな画面にリダイレクトさせれば完了です。

3. Google Script Run実行フロー

実行可能APIを外部から実行するためには、実行可能APIのスクリプトID・アクセストークン・実行したい関数名が必要になります。公式リファレンスとしてはこちらになります

ヘッダーにアクセストークンを挿入して、URLはスクリプトIDを挿入したURLになります。

  const response = await fetch(`https://script.googleapis.com/v1/scripts/${scriptId}:run`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      function: functionName,
      parameters: parameters || [],
    }),
  });

送信するBodyの中身としては、functionで実行したい関数名を指定して、引数はparametersの配列に収めて送信することで渡すことができます。型定義としては以下になります。

{
  "function": string,
  "parameters": [
    value
  ],
}

検証用フロントエンド画面

クライアントで検証するために簡易的な画面を作成します。ソースコードの原文としては、こちらに上がっています。

useGoogleOAuth:認可処理用カスタムHook

"use client";

import { useEffect, useState } from "react";

export const useGoogleOAuth = () => {
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const verify = async () => {
      const res = await fetch("/api/google-auth/verify");
      if (res.status === 200) {
        setIsLoading(false);
      } else if (res.status === 401) {
        const data = await res.json();
        console.log(data);
        window.location.href = data.url;
      } else {
        const data = await res.json();
        alert(`認証に失敗しました。:${data.message}`);
      }
    };

    if (isLoading && typeof window !== "undefined") {
      verify();
    }
  }, [isLoading]);

  return { isLoading };
};

処理は単純です。/api/google-auth/verifyにアクセスして、401が出たら認可用URIに遷移します。認可が完了するまではisLoadingで状態を管理します。

検証ページ

"use client";

import { useActionState } from "react";

import { LoadingMainComponent } from "@/components/LoadingMainComponent";
import { useGoogleOAuth } from "@/hooks/useGoogleOAuth";

export default function GooglePage() {
  const { isLoading } = useGoogleOAuth();
  if (isLoading) return <LoadingMainComponent />;

  const onClickRead = async (
    action: "healthCheckFunction" | "getSheetAllData"
  ) => {
    const res = await fetch("/api/google-auth/test", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ functionName: action }),
    });
    if (res.status === 200) {
      const data = await res.json();
      console.log(data);
    } else {
      const data = await res.json();
      alert(`Error: ${data.message}`);
    }
  };

  return (
    <>
      <main className="flex w-full flex-col gap-2">
        <div className="flex max-w-xl flex-col gap-2 p-4">
          <Button
            label={"Hello World!"}
            onClick={() => onClickRead("healthCheckFunction")}
          />
          <Button
            label={"Get Sheet All Data"}
            onClick={() => onClickRead("getSheetAllData")}
          />
          <FormComponent />
        </div>
      </main>
    </>
  );
}

type ButtonProps = {
  label: string;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

const Button = (props: ButtonProps) => {
  const { label, onClick } = props;
  return (
    <button
      onClick={onClick}
      className="flex items-center justify-center rounded-lg bg-white px-8 py-2 shadow transition-all hover:-translate-x-1 hover:-translate-y-1 hover:cursor-pointer hover:shadow-md"
    >
      {label}
    </button>
  );
};

type FormType = {
  url: string;
};
type PrevFormDataType = {
  value: FormType;
  validationError: { url: Error | null };
  apiError: Error | null;
};
const FormComponent = () => {
  const initialFormData: PrevFormDataType = {
    value: { url: "" },
    validationError: { url: null },
    apiError: null,
  };

  const validationUrl = (url: string) => {
    try {
      new URL(url);
      return null; // URLが有効な場合はエラーなし
    } catch (e) {
      return new Error(`Invalid URL format ${e}`); // 無効なURLの場合はエラーを返す
    }
  };

  const [formData, action, isPending] = useActionState<
    PrevFormDataType,
    FormData
  >(async (_: PrevFormDataType, formData: FormData) => {
    // FormDataをobjectに変換
    const _formData = Object.fromEntries(formData.entries());
    const data: FormType = {
      url: _formData.url as string,
    };

    // validationを掛ける いい感じのライブラリがあれば参考にする
    const urlError = validationUrl(data.url);

    if (urlError) {
      return {
        value: { url: data.url },
        validationError: {
          url: urlError,
        },
        apiError: null,
      };
    }

    // ここでAPI処理を実装・今回は2秒待ってエラーを返す
    const res = await fetch("/api/google-auth/test", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        functionName: "insertDataToTargetSheet",
        params: [data.url],
      }),
    });
    if (res.status === 200) {
      alert("Data submitted successfully!");
      return {
        value: { url: "" },
        validationError: { url: null },
        apiError: null,
      };
    }

    const apiError = new Error("Failed to submit data");

    return {
      value: { url: data.url },
      validationError: {
        url: urlError,
      },
      apiError: apiError,
    };
  }, initialFormData);

  return (
    <>
      <form
        action={action}
        className="flex w-full max-w-xl flex-col gap-2 rounded-md p-4 shadow"
      >
        <label className="flex flex-col">
          <div className="flex flex-row text-xl">
            <span className="w-1/3">名前:</span>
            <input
              className="w-full border p-1 text-right"
              type="text"
              name="url"
              defaultValue={formData.value.url}
            />
          </div>
          <span className="h-4 text-xs text-red-500">
            {formData.validationError.url && (
              <>{formData.validationError.url.message}</>
            )}
          </span>
        </label>
        <button
          className={
            "w-full rounded-md py-4 text-lg text-white" +
            (isPending ? " bg-gray-400" : " bg-blue-500")
          }
          type="submit"
          formAction={action}
          disabled={isPending}
        >
          送信{isPending && "中"}
        </button>
        <span className="h-4 text-xs text-red-500">
          {formData.apiError && <p>{formData.apiError.message}</p>}
        </span>
      </form>
    </>
  );
};

実行可能APIの検証のために3つのパターンで/api/google-auth/testにリクエストを送信しています。

ソースコード

環境変数吸出し用env service

import { MessagingApiClient } from '@line/bot-sdk/dist/messaging-api/api';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

import { OAuth2Client } from 'google-auth-library';

@Injectable()
export class EnvironmentsService {
  constructor(private configService: ConfigService) {}

  get GoogleClientID(): string {
    return this.configService.get('GOOGLE_CLIENT_ID');
  }
  get GoogleClientSecret(): string {
    return this.configService.get('GOOGLE_CLIENT_SECRET');
  }
  get GoogleRedirectUri(): string {
    return this.configService.get('GOOGLE_CALLBACK_URL');
  }

  GoogleOAuth2Client() {
    const client = new OAuth2Client({
      clientId: this.GoogleClientID,
      clientSecret: this.GoogleClientSecret,
      redirectUri: this.GoogleRedirectUri,
    });
    return client;
  }

  get GoogleScriptURL(): string {
    return this.configService.get('GAS_SCRIPT_URL');
  }

  get isProduction(): boolean {
    const env: string = this.configService.get('ENV');
    if (env === 'development') {
      return false;
    } else {
      return true;
    }
  }
}

Guards

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { EnvironmentsService } from 'src/config/enviroments.service';
import { Request } from 'express';

@Injectable()
export class IsGoogleIdTokenVerifyGuard implements CanActivate {
  constructor(private readonly env: EnvironmentsService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();
    const idToken = request.cookies['id_token'];

    console.log('verify idToken', ':come on');

    if (!idToken) return false;
    const isValid = await this.verfyIdToken(idToken);
    if (!isValid) return false;
    return true;
  }

  private async verfyIdToken(idToken: string): Promise<any> {
    const client = this.env.GoogleOAuth2Client();
    try {
      const ticket = await client.verifyIdToken({
        idToken: idToken,
        audience: this.env.GoogleClientID,
      });
      const payload = ticket.getPayload();
      const now = Math.floor(Date.now() / 1000); // 現在時刻(秒単位)
      if (payload && payload.exp && payload.exp > now) {
        return true; // トークンは有効
      } else {
        return false; // トークンは無効または期限切れ
      }
    } catch (error) {
      return false; // トークンが無効の場合
    }
  }
}

Controller

import { Body, Controller, Get, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import { IsGoogleIdTokenVerifyGuard } from 'src/common/guard/is-google-id-token-verify/is-google-id-token-verify.guard';
import { EnvironmentsService } from 'src/config/enviroments.service';
import { GoogleAuthService } from './google-auth.service';
import { RequestScriptRunDto } from './dto/request.dto';

@Controller('/api/google-auth/')
export class GoogleAuthController {
  constructor(
    private readonly googleAuthService: GoogleAuthService,
    private readonly env: EnvironmentsService,
  ) {}

  // Google認証のURLを取得する
  @Get()
  async getGoogleAuthUrl(@Res() res): Promise<void> {
    const authUrl = await this.googleAuthService.getGoogleAuthUrl();
    res.redirect(authUrl);
  }

  // Google認証のコールバックURL
  @Get('callback')
  async getGoogleAuthCallback(@Query('code') code: string, @Res() res): Promise<void> {
    const tokens = await this.googleAuthService.getToken(code);

    res.cookie('id_token', tokens.id_token, {
      httpOnly: this.env.isProduction,
      secure: this.env.isProduction,
      sameSite: 'Strict',
      maxAge: 3600 * 1000, // 1時間
    });

    // 環境によってbooleanを切り替える
    res.cookie('access_token', tokens.access_token, {
      httpOnly: this.env.isProduction,
      secure: this.env.isProduction,
      sameSite: 'Strict',
      maxAge: 3600 * 1000, // 1時間
    });

    res.redirect('/community/google/');
  }

  // Google認証のトークンを検証する
  @Get('verify')
  async verifyIdToken(@Req() req, @Res() res): Promise<void> {
    const idToken = req.cookies['id_token'];

    if (!idToken) {
      const authUrl = await this.googleAuthService.getGoogleAuthUrl();
      res.status(401).json({ message: 'No id_token', url: authUrl });
    }

    // token validation
    const isValid = await this.googleAuthService.verfyIdToken(idToken);

    if (isValid) {
      res.status(200).json({ message: 'Valid access token' });
    } else {
      const authUrl = await this.googleAuthService.getGoogleAuthUrl();
      res.status(401).json({ message: 'No id_token', url: authUrl });
    }
  }

  @Post('test')
  @UseGuards(IsGoogleIdTokenVerifyGuard)
  async test(
    @Req() req,
    @Body() body: RequestScriptRunDto,
    @Res() res,
  ): Promise<string | undefined | { url: string; content: string }[]> {
    const accessToken = req.cookies['access_token'];
    console.log('accessToken', req);
    const result = await this.googleAuthService.runScript(accessToken, body.functionName, body.params);

    return res.status(200).json(result);
  }
}

Service

import { Injectable } from '@nestjs/common';
import { Credentials } from 'google-auth-library';
import { EnvironmentsService } from 'src/config/enviroments.service';

@Injectable()
export class GoogleAuthService {
  constructor(private readonly env: EnvironmentsService) {}

  async getGoogleAuthUrl(): Promise<string> {
    const client = this.env.GoogleOAuth2Client();

    const authUrl = client.generateAuthUrl({
      scope: [
        '<https://www.googleapis.com/auth/userinfo.profile>',
        '<https://www.googleapis.com/auth/script.scriptapp>',
        '<https://www.googleapis.com/auth/script.external_request>',
        '<https://www.googleapis.com/auth/spreadsheets>',
      ],
      redirect_uri: this.env.GoogleRedirectUri,
    });
    return authUrl;
  }

  async verfyIdToken(idToken: string): Promise<any> {
    const client = this.env.GoogleOAuth2Client();
    try {
      const ticket = await client.verifyIdToken({
        idToken: idToken,
        audience: this.env.GoogleClientID,
      });
      const payload = ticket.getPayload();
      const now = Math.floor(Date.now() / 1000); // 現在時刻(秒単位)
      if (payload && payload.exp && payload.exp > now) {
        return true; // トークンは有効
      } else {
        return false; // トークンは無効または期限切れ
      }
    } catch (error) {
      console.error('Error verifying access token:', error);
      return false; // トークンが無効の場合
    }
  }

  async getToken(code: string): Promise<Credentials> {
    const client = this.env.GoogleOAuth2Client();
    const tmp = await client.getToken(code);
    console.log(tmp);
    const { tokens } = tmp;
    return tokens;
  }

  // <https://developers.google.com/apps-script/api/reference/rest/v1/scripts/run?hl=ja>
  async runScript(
    accessToken: string,
    functionName: 'healthCheckFunction' | 'getSheetAllData' | 'insertDataToTargetSheet',
    parameters: (string | number)[] | undefined,
  ): Promise<string | undefined | { url: string; content: string }[]> {
    const url = this.env.GoogleScriptURL;
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        function: functionName,
        parameters: parameters || [],
      }),
    });
    const data = await response.json();
    if (response.status !== 200 || data.error) {
      console.error('Error calling Google Apps Script:', data.error);
      throw new Error(`Error: ${data.error.message}`);
    }

    const result = data.response.result;

    if (typeof result === 'undefined') return;
    if (typeof result === 'string') return result;
    if (Array.isArray(result)) {
      const temp = result.map((item: { url: string; content: string }) => {
        return {
          url: item.url || '',
          content: item.content || '',
        };
      });
      return temp;
    }
    return result;
  }
}

おわり

GASを実行可能APIとして公開し、OAuth2.0による認証を実装することで、セキュアなAPIエンドポイントを作成することができました。今回実装したコードは、Google Sheetsとの連携も含めて、実際のプロダクションで使用可能なレベルのものとなっています。

今後は、エラーハンドリングやログ機能の追加など、より堅牢な実装に向けて改善を進めていきたいと思います。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

1人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です