AWS Lambda から Google スプレッドシートに OAuth 2.0 で行を追記してみた

AWS Lambda から Google スプレッドシートに OAuth 2.0 で行を追記してみた

AWS Lambda から Google スプレッドシートへ書き込みたいとき、組織の設定でサービスアカウントが使えない場合があります。その時は個人アカウントの OAuth 2.0 で解決できます。認証設定からリフレッシュトークンの管理まで、実装のポイントをまとめました。
2026.07.31

はじめに

みなさん AWS Lambda から Google スプレッドシートに書き込みたくなったことはないでしょうか。

私は定期実行しているバッチの集計結果を、チームで見ているスプレッドシートに 1 行ずつ記録する機会がありました。

最初はサービスアカウントを使うつもりでしたが、組織の Google Workspace の設定上スプレッドシートに共有できませんでした。最終的には個人アカウントの OAuth 2.0 で実装しています。

同じところで詰まる方がいそうなので、認証方式の選定から実装、リフレッシュトークンの再発行手順までをまとめます。

やりたかったこと

やりたいことは単純で、次の 2 点だけです。

  • 自分が編集権限を持っている共有ドライブ配下にスプレッドシートがある
  • そのスプレッドシートに Lambda から実行結果を追記したい

読み取りだけなら選択肢は広いのですが、今回は書き込みが必要でした。また将来メンバーが入れ替わる前提で、認証情報の切り替えが手間にならない構成を目指しました。

構成

全体としてはこのような流れになります。

Lambda が Parameter Store の認証情報で Google Sheets API に行を追記する構成図

  1. Lambda が Parameter Store からクライアント ID・クライアントシークレット・リフレッシュトークンを取得する
  2. リフレッシュトークンを使って Google のトークンエンドポイントからアクセストークンを取得する
  3. アクセストークンを付けて Sheets API の values.append を呼び、1 行追記する

Google Cloudのプロジェクトがあることが前提です。

Google Sheets API を有効化する

Google Cloud プロジェクトを用意したら、まず API を有効化します。「API とサービス」の「ライブラリ」から Google Sheets API を探して有効にするだけです。

Google Sheets API が有効になった状態の製品詳細画面

ここは有効化して終わりで、認証の設定とは独立しています。

OAuth 同意画面を設定する

次に OAuth 同意画面を設定します。クライアント ID を作る前に済ませておく必要があります。

コンソールのメニュー名は変わっていて、以前の「OAuth 同意画面」は現在「Google Auth Platform」にまとまっています。配下は次の 4 つです。

メニュー 内容
ブランディング アプリ名、ユーザーサポートメール
対象 ユーザータイプ(内部 / 外部)、公開ステータス
データアクセス 要求するスコープ
クライアント OAuth クライアント ID

未設定のプロジェクトでは「開始」ボタンからブランディング、対象、連絡先情報、完了の順に進む構成フローが用意されています。ユーザータイプを選ぶのは「対象」の画面です。

Google Auth Platform の対象画面でユーザーの種類が内部になっている状態

今回はユーザータイプ「内部」を選びました。組織内のアカウントだけが認可でき、Google の審査も不要です。なお「内部」は Google Workspace の組織アカウントでしか選べません。個人の Gmail アカウントで作ったプロジェクトでは「外部」しか出てこないので、その場合はテストユーザーの登録が必要になります。

ここで 1 つ注意点があります。公開ステータスを「テスト」のままにすると、発行したリフレッシュトークンが 7 日で失効します。バッチ用途では必ず公開状態にしておきましょう。

OAuth クライアントを作る

同意画面まで終わったら、Google Auth Platform の「クライアント」から「クライアントを作成」に進みます。設定するのは名前と承認済みのリダイレクト URI くらいで、スコープを指定する欄はここにはありません。

OAuth クライアント ID の作成画面でリダイレクト URI に localhost を登録する様子

種類は「ウェブ アプリケーション」を選び、承認済みのリダイレクト URI に http://localhost:8080 を登録しました。

なぜ localhost を登録するのか

認可コードは、同意した後にブラウザがリダイレクトされる先の URL へ ?code=... の形で付いて返ってきます。つまりコードの受け取り先を事前に 1 つ決めておく必要があります。

今回のように担当者が手元のブラウザで一度だけ認可する用途では、コードを受け取るためだけに公開された Web サーバーを用意したくありません。localhost にしておけば、リダイレクト先は手元のブラウザで完結します。

ローカルでサーバーを立てていないのでページ自体は表示エラーになりますが、アドレスバーに http://localhost:8080/?code=... と出ている値がほしいので、この URL をスクリプトに貼り付けてコードを取り出します。

リダイレクト URI は原則として HTTPS が必要ですが、localhost やループバックアドレスは例外として http が認められています。また redirect_uri は登録した値と完全一致していなければなりません。スキームや末尾のスラッシュが違うだけでもエラーになるので、認可 URL とトークン交換で同じ値を使います。

作成すると表示されるクライアント ID、クライアントシークレットを控えておきます。あとで Parameter Store に入れる値です。

リフレッシュトークンを取得する

ブラウザでのログインと同意は自動化できません。そこで認可 URL を生成して表示し、リダイレクト後の URL を貼り付けてもらう対話式のスクリプトを用意しました。

認可 URL のパラメータはこのようになります。

認可URLの組み立て
params = {
    "client_id": client_id,
    "redirect_uri": "http://localhost:8080",
    "response_type": "code",
    "scope": "https://www.googleapis.com/auth/spreadsheets",
    "access_type": "offline",   # リフレッシュトークンを返させる
    "prompt": "consent",        # 同意済みでも再発行させる
    "state": state,             # CSRF 対策。リダイレクト後に一致を検証する
}
url = "https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params)

スコープを指定するのはこの認可 URL です。今回は https://www.googleapis.com/auth/spreadsheets を使います。書き込みが必要なので readonly では足りません。ユーザータイプが「内部」なら同意画面側へ登録しなくても動きます。

同意すると http://localhost:8080/?code=... にリダイレクトされます。先ほど書いたとおり、表示されたページではなくアドレスバーの URL を使います。

取り出した code をトークンエンドポイントに渡すと、リフレッシュトークンが返ってきます。

scripts/rotate-refresh-token.sh
# リクエストボディは python 側で組み立てて標準入力から渡す
response=$(printf '%s' "$body" | curl -sS -X POST https://oauth2.googleapis.com/token \
  --connect-timeout 10 --max-time 30 \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-binary @- \
  -w $'\n%{http_code}')

今後の運用も考えて、これらの作業をスクリプト化しました。スクリプトの全文も置いておきます。スコープとリダイレクト URI を変えれば、Sheets 以外の API でもそのまま使えるはずです。

scripts/rotate-refresh-token.sh(クリックで展開)
scripts/rotate-refresh-token.sh
#!/bin/bash

# Google API の OAuth 2.0 リフレッシュトークンを取得する。
#
# ブラウザでのログイン・同意は自動化できないため、認可 URL を開いてもらい
# リダイレクト後の URL を貼り付けてもらう対話式にしている。
#
# Parameter Store への反映はこのスクリプトでは行わない。
# 出力されたリフレッシュトークンを手動で書き込むこと。
#
# 使い方:
#   scripts/rotate-refresh-token.sh <client_id>
#   (client_secret は実行後にプロンプトから入力する)

set -euo pipefail

redirect_uri="http://localhost:8080"
scope="https://www.googleapis.com/auth/spreadsheets"
parameter_name="/myapp/sheets/oauth/refresh-token"

client_id="${1:-}"

if [ -z "$client_id" ]; then
  echo "使い方: $0 <client_id>" >&2
  exit 1
fi

# client_secretはプロセス一覧やシェル履歴に残さないため、引数ではなく非エコー入力で受け取る
read -rsp "client_secretを入力してください: " client_secret
echo
if [ -z "$client_secret" ]; then
  echo "client_secretが入力されていません。" >&2
  exit 1
fi

function generate_state() {
  python3 -c 'import secrets; print(secrets.token_urlsafe(32), end="")'
}

function build_auth_url() {
  STATE="$1" CLIENT_ID="$client_id" REDIRECT_URI="$redirect_uri" SCOPE="$scope" python3 -c '
import os
from urllib.parse import urlencode

params = {
    "client_id": os.environ["CLIENT_ID"],
    "redirect_uri": os.environ["REDIRECT_URI"],
    "response_type": "code",
    "scope": os.environ["SCOPE"],
    "access_type": "offline",
    "prompt": "consent",
    "state": os.environ["STATE"],
}
print("https://accounts.google.com/o/oauth2/v2/auth?" + urlencode(params), end="")
'
}

# 貼り付けられたURLを検証してcodeのみを標準出力へ返す。
# codeは一度きりの値だがargvに載せないよう、URLは環境変数経由で渡す
function extract_code() {
  REDIRECTED_URL="$1" EXPECTED_STATE="$2" EXPECTED_REDIRECT_URI="$redirect_uri" python3 -c '
import os
import sys
from urllib.parse import parse_qs, urlparse

parsed = urlparse(os.environ["REDIRECTED_URL"])
expected = urlparse(os.environ["EXPECTED_REDIRECT_URI"])

if (parsed.scheme, parsed.hostname, parsed.port) != (expected.scheme, expected.hostname, expected.port):
    print(f"リダイレクト先が想定と異なります: {parsed.scheme}://{parsed.netloc}", file=sys.stderr)
    sys.exit(1)

qs = parse_qs(parsed.query)

error = qs.get("error", [""])[0]
if error:
    print(f"認可が拒否されました: {error}", file=sys.stderr)
    sys.exit(1)

# CSRF対策。認可URLに含めたstateと一致しないURLのcodeは信用しない
if qs.get("state", [""])[0] != os.environ["EXPECTED_STATE"]:
    print("stateが一致しません。認可URLを開き直してやり直してください。", file=sys.stderr)
    sys.exit(1)

code = qs.get("code", [""])[0]
if not code:
    print("URLからcodeを取得できませんでした。貼り付けたURLを確認してください。", file=sys.stderr)
    sys.exit(1)

print(code, end="")
'
}

function exchange_code_for_refresh_token() {
  local code="$1"
  local body response http_status response_body refresh_token

  # client_secret/codeをcurlのargvに載せないよう、リクエストボディを組み立てて標準入力から渡す
  body=$(CLIENT_ID="$client_id" CLIENT_SECRET="$client_secret" CODE="$code" REDIRECT_URI="$redirect_uri" python3 -c '
import os
from urllib.parse import urlencode

print(urlencode({
    "client_id": os.environ["CLIENT_ID"],
    "client_secret": os.environ["CLIENT_SECRET"],
    "code": os.environ["CODE"],
    "grant_type": "authorization_code",
    "redirect_uri": os.environ["REDIRECT_URI"],
}), end="")
')

  # codeは一度きりの値なので、通信停滞で無言のまま待ち続けないようタイムアウトを明示する
  response=$(printf '%s' "$body" | curl -sS -X POST https://oauth2.googleapis.com/token \
    --connect-timeout 10 --max-time 30 \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-binary @- \
    -w $'\n%{http_code}') || {
    echo "トークンエンドポイントへのリクエストに失敗しました。" >&2
    return 1
  }

  http_status="${response##*$'\n'}"
  response_body="${response%$'\n'*}"

  if [ "$http_status" != "200" ]; then
    # レスポンス全文にはaccess_tokenが含まれうるため、errorとerror_descriptionのみ出す
    echo "トークンエンドポイントがHTTP ${http_status}を返しました。" >&2
    RESPONSE_BODY="$response_body" python3 -c '
import json
import os
import sys

try:
    data = json.loads(os.environ["RESPONSE_BODY"])
except json.JSONDecodeError:
    print("(レスポンスをJSONとして解釈できませんでした)", file=sys.stderr)
    sys.exit(0)

error = data.get("error")
error_description = data.get("error_description")
print(f"error: {error}, error_description: {error_description}", file=sys.stderr)
'
    return 1
  fi

  refresh_token=$(RESPONSE_BODY="$response_body" python3 -c '
import json
import os

print(json.loads(os.environ["RESPONSE_BODY"]).get("refresh_token", ""), end="")
')

  if [ -z "$refresh_token" ]; then
    echo "レスポンスにrefresh_tokenが含まれていませんでした。" >&2
    echo "既に同意済みの場合、https://myaccount.google.com/permissions でこのアプリへのアクセスを一度取り消してから再実行してください。" >&2
    return 1
  fi

  printf '%s' "$refresh_token"
}

function main() {
  local state auth_url redirected_url code refresh_token

  state=$(generate_state)
  auth_url=$(build_auth_url "$state")

  echo "以下のURLをブラウザで開き、対象のGoogleアカウントでログイン・同意してください。"
  echo "----------------------------"
  echo "$auth_url"
  echo "----------------------------"
  echo

  echo "同意後にリダイレクトされるURL(http://localhost:8080/?code=... 、ページ自体は表示エラーで構わない)を貼り付けてください:"
  read -r redirected_url

  if ! code=$(extract_code "$redirected_url" "$state"); then
    exit 1
  fi

  # コマンド置換は関数のexit statusを自動では伝播しないため、明示的に判定する
  if ! refresh_token=$(exchange_code_for_refresh_token "$code"); then
    exit 1
  fi

  echo "----------------------------"
  echo "取得したrefresh_token:"
  echo "$refresh_token"
  echo "----------------------------"
  echo
  echo "Parameter Storeへの反映は行っていません。以下のコマンドで手動更新してください:"
  echo "  aws ssm put-parameter --name ${parameter_name} \\"
  echo "    --value \"<refresh_token>\" --type SecureString --overwrite"
}

main

認証情報を Parameter Store に保存する

取得したクライアント ID・クライアントシークレット・リフレッシュトークンを SecureString で保存します。

> aws ssm put-parameter --name /myapp/sheets/oauth/client-id --value "..." --type SecureString
> aws ssm put-parameter --name /myapp/sheets/oauth/client-secret --value "..." --type SecureString
> aws ssm put-parameter --name /myapp/sheets/oauth/refresh-token --value "..." --type SecureString

CDK では作成せず手動登録にしています。テンプレートやリポジトリに秘密の値が残るのを避けたかったためです。

3 つを別々のパラメータに分けているのは、メンバー交代のときに差し替える対象を 1 つに絞るためです。

資格情報 紐づき先 メンバー交代時
クライアント ID・シークレット Google Cloud プロジェクト 変更不要
リフレッシュトークン 認可した個人の Google アカウント 新しい担当者が再取得して差し替え
アクセストークン リフレッシュトークンから都度生成 保管しないので対応不要

保存先は Secrets Manager でも構いません。今回は自動ローテーションを使わないため、安い Parameter Store の SecureString にしました。

IAM は該当パス配下の ssm:GetParameters だけを許可します。

lib/resources/lambda-functions.ts
new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  actions: ['ssm:GetParameters'],
  resources: [`arn:aws:ssm:${region}:${account}:parameter/myapp/sheets/oauth/*`],
})

環境変数に渡すのはパラメータのパスとスプレッドシート ID、シート名だけです。秘密の値は渡しません。

Lambda から呼び出す

Lambda 側は Parameter Store から 3 つの値を取り出し、アクセストークンを取得して Sheets API を叩くだけです。追加ライブラリを入れていないので、urllib3 で 2 回 HTTP を投げる形になります。

追記のリクエストはこのようになります。

index.py
# named range との衝突を避けるためシート名をシングルクォートで囲む
a1_range = f"'{sheet_name}'!A:O"
query = urlencode({"valueInputOption": "USER_ENTERED", "insertDataOption": "INSERT_ROWS"})
url = f"https://sheets.googleapis.com/v4/spreadsheets/{spreadsheet_id}/values/{quote(a1_range, safe='')}:append?{query}"

resp = append_http.request(
    "POST",
    url,
    headers={"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"},
    body=json.dumps({"values": [row]}).encode("utf-8"),
)

クエリパラメータの意味は次のとおりです。

パラメータ 意味
valueInputOption USER_ENTERED 画面から入力したのと同じ解釈になる。日付や数値が自動で型変換される
insertDataOption INSERT_ROWS 既存の行を上書きせず行を挿入する

確認してみる

ステージング環境にデプロイして動作を確認しました。今回の Lambda は SNS のサブスクリプションから呼ばれる構成なので、トピックにサンプルのイベントを publish しています。

> aws sns publish --topic-arn <トピックの ARN> --message file://sample-event.json

SNS からの通知で Lambda が起動し、スプレッドシートに行が追加されるところまで確認できました。Sheets API は成功すると updatedRange を返してくるので、どの行に書けたかはレスポンスとログから追えます。

うまくいかないときは、まずトークンエンドポイントのレスポンスを見るのが早いです。invalid_grant ならリフレッシュトークンの失効、invalid_client ならクライアント ID かシークレットの設定ミスと切り分けられます。

リフレッシュトークンが失効したときの再発行手順

個人アカウントの OAuth なので、失効したときの手順も用意しておきます。失効する条件を整理すると次のとおりです。

イベント 影響
認可した人の退職・アカウント停止 失効する
認可した人のパスワード変更 Sheets スコープだけなら失効しない
6 か月間 API を呼ばなかった 失効する。日次や週次のバッチなら該当しない
同意画面が「テスト」のまま 7 日で失効する
認可した人がアクセス権を取り消した 失効する

失効するとトークンエンドポイントが invalid_grant を返すようになります。通常の運用で踏むのは担当者の交代くらいなので、頻度としては低めです。

手順は最初と同じスクリプトを新しい担当者のアカウントで実行し、出てきたリフレッシュトークンを上書きするだけです。クライアント ID とクライアントシークレットはそのまま使えます。

> scripts/rotate-refresh-token.sh <client_id>

以下の URL をブラウザで開き、対象の Google アカウントでログイン・同意してください。
----------------------------
https://accounts.google.com/o/oauth2/v2/auth?client_id=...
----------------------------

出力されたリフレッシュトークンを Parameter Store へ反映します。

> aws ssm put-parameter --name /myapp/sheets/oauth/refresh-token \
    --value "<refresh_token>" --type SecureString --overwrite

なお、旧担当者のトークンは Google アカウントのアクセス管理 から取り消せます。再発行のときにリフレッシュトークンが返ってこない場合も、ここで一度アクセスを取り消してから実行し直すと解決します。

他検討した方法

今回は採用しませんでしたが、検討した書き込み方法を残しておきます。

サービスアカウント

Google Sheets API を機械から叩くなら、普通はサービスアカウントが第一候補になります。

しかし外部共有を「信頼できるドメイン」の許可リストで制限している Google Workspace では、サービスアカウントのドメインを許可リストに登録できません。公式ドキュメントにも明記されています。

Google サービス アカウント(ドメイン名の末尾が gserviceaccount.com)を、信頼できるドメインにすることはできません。

つまりサービスアカウントは組織外のユーザー扱いになります。スプレッドシートを共有しようとしても、そもそも権限を付与する操作でエラーになります。

回避策として、自動化専用の Google アカウントを新しく発行する方法もあります。ただしこちらはアカウント発行の申請が必要で、共有ドライブへのアクセス権付与も別途依頼することになるので今回の用途としては過剰です。

一方 OAuth 2.0 なら、すでにスプレッドシートへアクセスできる個人アカウントの権限で書き込めます。追加の申請は要りません。

方式 可否 備考
サービスアカウント 使えない 外部共有の許可リストにドメインを登録できない
自動化専用の Google アカウント 可能 発行申請とライセンス費用、権限付与の依頼が必要
個人アカウントの OAuth 2.0 採用 追加申請が不要。認可した人に依存するのがトレードオフ

手軽さの観点からも OAuth 2.0 による認証を採用しました。

Claude の Google Drive コネクタ

やることが 1 行の追記だけなので、そもそも Lambda を書かずに済ませたい気持ちがありました。Claude のスケジュール実行と Google Drive コネクタを組み合わせれば、コードなしで完結するのではと考えました。

結論としては、Drive コネクタでは既存スプレッドシートへの書き込みができませんでした。コネクタが提供しているツールは次の 8 つで、いずれも新規作成・複製・読み取り・検索・メタデータ取得に限られます。

ツール 操作
create_file ファイル・フォルダの新規作成、アップロード
copy_file 既存ファイルの複製
read_file_content ファイル内容の読み取り
download_file_content ファイルのダウンロード
search_files / list_recent_files ファイルの検索・一覧
get_file_metadata / get_file_permissions メタデータ・権限の取得

create_file は mime type に application/vnd.google-apps.spreadsheet を指定すればスプレッドシートを新規作成できます。read_file_content はスプレッドシートの中身も読めます。

一方で、既存ファイルの内容を書き換えるツールがありません。Google Sheets API でいう values.updatevalues.append に相当する操作が接続されていません。つまり作成と読み取りはできるけれど編集はできない、という状態でした。毎回新しいシートを作る運用なら成立しますが、既存の表に行を積み上げたい今回は採用できません。

書き込み用のコネクタを自作する手もありますが、リモート MCP サーバーを作成し OAuth トークンを発行するのは、さすがに大げさだと思いやめました。

結果として、実行基盤と認証情報をチーム共有の AWS アカウント側に置ける Lambda 構成を選びました。

まとめ

サービスアカウントが使えない環境でも、個人アカウントの OAuth 2.0 で Lambda からスプレッドシートに書き込めました。 追加ライブラリなしで実装できるので、思っていたより手軽です。

差し替えるのはリフレッシュトークンだけで済む構成にしておくと、メンバーが代わったときの作業がぐっと軽くなります。

「サービスアカウントが共有できなくて詰まった」という場面があれば、OAuth のリフレッシュトークン方式を検討してみてください。

以上、鈴木純がお送りしました。

参考

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事