INFRASTRUCTURE NOTE

GCP プロジェクトと OAuth の構造

「API を有効にしたら、既存のログインにも影響が出るのでは」——
この不安は、プロジェクト・API・クライアント ID・スコープ・同意画面が、それぞれ何を担当しているのかが分かれば消える。

Gmail 監視を nzw.jp に足すときに整理した内容を残す。

対象プロジェクト番号 331995618405(AI ワークスペース/Gmail 監視で共用)
書いた理由Gmail 監視の追加時に「既存の認証が壊れないか」を判断する必要があったため
最終更新2026-10-03
関連ノートGoogle OAuth 同意画面を本番公開する(公開ステータスと審査の実務)
TL;DR

ひとことで

API の有効化は「蛇口を付ける」だけで、誰にも権限を渡さない。権限を渡すのは OAuth クライアントとスコープの組み合わせであって、そこは用途ごとに完全に独立している。

そして何を許可してもらうかを決めているのは、コンソールの設定ではなくコードに書いたスコープの 1 行。書かない権限は付かない。

ただし 同意画面だけはプロジェクトに 1 枚しかなく、全クライアントで共有される。プロジェクト全体に影響が出るのはここだけ。この一点さえ押さえれば、何を触ると何が動くかが判断できる。

STRUCTURE

プロジェクトの中には何が入っているのか

Google Cloud の「プロジェクト」は、課金と権限をまとめる入れ物でしかない。中身は大きく 3 種類に分かれていて、この 3 つが混同されやすい。

Google Cloud プロジェクト(番号 331995618405) 1. 有効化した API Gmail API Google Drive API 使う API だけスイッチを入れる 2. OAuth 同意画面 プロジェクトに 1 枚だけ 公開ステータス・テストユーザー 全クライアントで共有される 3. 認証情報(いくつでも作れる) OAuth クライアント(ウェブ) …-no02rqsd… / AI ワークスペース OAuth クライアント(デスクトップ) …-c41k3aof… / Gmail 監視 サービスアカウント nzw-ai-agent@nzw-ai-agent.iam.gserviceaccount.com / Drive
プロジェクトの中身。独立して増やせるのは 3 だけで、2 は 1 枚しかない

ID と番号は別物

プロジェクトには ID(nzw-ai-agent のような文字列)と 番号(331995618405 のような数字)の 2 つの呼び名がある。同じものを指すが、出てくる場所が違う。

つまりクライアント ID を見れば、それがどのプロジェクトのものか一目で分かる。逆にサービスアカウントの鍵ファイルには番号が入っていないので、両者が同じプロジェクトかどうかは鍵だけでは判断できない。

MISCONCEPTION

「API を有効にする」は、権限を配ることではない

ここが最初に引っかかったところ。API の有効化は、そのプロジェクトからその API を呼べる状態にするだけで、誰かがアクセスできるようになるわけではない。蛇口を付けただけで、水を出す権利は別に配る必要がある。

アプリ クライアント ID 同意画面 本人が許可する トークン refresh / access Gmail API 実際に呼ぶ スコープを要求 許可 API 有効化が効くのはここだけ 無効なら、この最後の呼び出しが失敗するだけ
API を無効にしたままでも、同意画面の見た目もログインの挙動も一切変わらない

そのため Gmail API を有効にしても、AI ワークスペースのログインには何の影響もない。ワークスペースのログインは Gmail API を呼んでいないので、蛇口が増えたことにすら気づかない。

CLIENT

OAuth クライアント ID は「アプリの身分証」

クライアント ID はアプリ 1 つにつき 1 枚の身分証で、いくつでも発行できる。重要なのは種別によって使える認証の流れが違うこと。同じプロジェクトの中に用途別に何枚あっても構わないし、互いに干渉しない。

種別使う場面リダイレクト先nzw.jp での用途
ウェブ アプリケーションブラウザから。事前登録したドメインにだけ戻せる登録済み URL のみAI ワークスペースのログイン
デスクトップ アプリ手元の CLI から。localhost に戻せるhttp://localhostGmail 監視のトークン取得
サービスアカウントサーバー同士。人の同意が要らない該当なしDrive の共有フォルダ読み書き

Gmail のトークンを取るのにウェブ用ではなくデスクトップ用を新規に作ったのは、この 「戻り先」の違いが理由。

トークン取得スクリプトは手元でローカルサーバーを立てて http://localhost で認可コードを受け取るので、ウェブ用クライアントでは受け取れない。

ここまでの 3 種類はいずれも「クライアント ID + シークレット(またはリダイレクト先)」で身元を証明する。モバイルアプリ向けのクライアントはここに入らない。スマホアプリにシークレットを埋め込んでも抜き出されてしまうため、まったく別の仕組みで身元を確認する。次の 2 節で、その仕組みと、nzw.jp のアプリ(MyDictly)で実際に踏んだ事故を書く。

FIREBASE

Firebase は、GCP の上に乗ったモバイル向けの皮

Firebase は Google が提供するモバイル/Web アプリ向けの開発プラットフォームで、認証(Firebase Auth)・DB(Firestore)・ストレージ・プッシュ通知などをひとまとめに使える。

実体は GCP プロジェクトそのもの。 Firebase でプロジェクトを作ると、同じ番号・同じ ID を持つ GCP プロジェクトが裏で自動的に作られる(逆に、既存の GCP プロジェクトへ Firebase を後から足すこともできる)。Firebase Console は、GCP の設定をモバイル開発者向けに並べ替えた「別の入り口」というのが実態に近い。

結果として起きること

Firebase Console で何かを設定すると、裏側では GCP の「API とサービス」側の設定が一緒に書き換わっていることがある。次節の SHA-1 登録はその典型で、Firebase Console の操作だけで完結して見えるが、実際には GCP の「認証情報」ページに Android 向け OAuth クライアントが生成・更新されている。

最低限の準備

  1. Firebase Console でプロジェクトを作る(または既存の GCP プロジェクトを選ぶ)
  2. プラットフォームごとにアプリを登録する(Android はパッケージ名、iOS は Bundle ID、Web はドメイン)
  3. 生成された設定ファイルをアプリに組み込む(Android は google-services.json、iOS は GoogleService-Info.plist)
  4. 使う機能(Authentication・Firestore など)をコンソールで有効化する

ここまでは「アプリとプロジェクトを紐づける」準備で、Android の Google ログインだけはもう 1 段ある。それが次の SHA-1 登録で、ここを飛ばすとビルドは通り、見た目には何も壊れないまま、ログインだけが動かない。

ANDROID

Android の OAuth クライアントは、SHA-1 証明書で身元確認される

ウェブ・デスクトップ・サービスアカウントは「クライアント ID + シークレット」の組で身元を証明すると書いた。Android 向けクライアントにはシークレットが無い。 代わりにパッケージ名 + 署名証明書の SHA-1 フィンガープリントの組が、そのアプリの身分証になる。

プラットフォーム身元の証明方法署名鍵を変えると
Androidパッケージ名 + 署名証明書の SHA-1別アプリ扱いになる(新しい SHA-1 の登録が要る)
iOSBundle ID + URL スキーム(REVERSED_CLIENT_ID)無関係(署名証明書は見ていない)
WebFirebase Authentication の「承認済みドメイン」無関係(配信ドメインで識別する)
結果として起きる事故

同じパッケージ名のアプリでも、署名鍵を変えると SHA-1 が変わり、Google からは「別のアプリ」として扱われる。 debug 鍵でビルドしていたアプリをリリース鍵に切り替えたとき、新しい鍵の SHA-1 を Firebase/GCP に登録し忘れると、Google Sign-In はそこで失敗する。

厄介なのは失敗の出方で、「このアプリは認証できません」という直接のエラーにはならず、ユーザーが何もしていないのに canceled(キャンセルされた)という結果だけが返ってくる。 署名の不一致が原因だとは気づきにくい。

MyDictly(langbridge)で実際に踏んだ。 v1.3.7(2026-07-12)で debug 鍵から共通リリース鍵へ署名を移行した際、Firebase Console への新しい鍵の SHA-1 登録を行っておらず、2026-10-02 まで 3 ヶ月近く Android 版の Google ログインがこの状態のまま気づかれなかった。詳しい経緯は MyDictly の開発ノート の意思決定ログを参照。

登録・確認のしかた

  1. 署名鍵の SHA-1 / SHA-256 を確認する:keytool -list -v -keystore xxx.jks -alias yyy
  2. Firebase Console → プロジェクトの設定 →「全般」タブ → 対象の Android アプリ →「SHA証明書フィンガープリントを追加」
  3. 登録後、更新された google-services.json を再ダウンロードしてアプリに差し替える(新しい鍵用の oauth_client エントリが増えている)

Play Store 経由で配布するアプリは、Play App Signing が使うアップロード鍵とは別に、Google 管理のアプリ署名鍵が実際の配布に使われる。その SHA-1 も忘れずに登録する(Play Console の「アプリの署名」ページで確認できる)。

SCOPE

スコープが、同意画面に何が出るかを決める

「何を許可するのか」を決めているのは API でもクライアントでもなくスコープ。そして同意画面に表示されるのは、そのとき要求されたスコープだけ。プロジェクトに登録されている他のスコープは出てこない。

区分例審査同意画面
基本openid / email / profile不要警告は出ない
機微カレンダー・連絡先など必要未確認なら警告
制限付きgmail.readonly など必要(重い)未確認なら警告

AI ワークスペースのログインは google.accounts.id という ID トークン専用の仕組みを使っていて、そもそもスコープを一切要求しない。

返ってくるのは「この人は誰か」を示す署名付きの JWT だけ。そのため制限付きスコープを別のクライアントが要求しても、ワークスペース側の同意画面は変わりようがない。

経路 A — AI ワークスペースのログイン ブラウザ → ウェブクライアント → ID トークンのみ → nzw.jp が本人確認 → 許可リスト 7 件 要求スコープ: openid / email / profile のみ。Gmail には一切触れない 登録外のアカウントはログインできてもアプリ側が 403 で弾く 経路 B — Gmail 監視 サーバー → デスクトップクライアント → gmail.readonly → Gmail 読み取り → Slack 通知 アカウントごとに refresh token を 1 本ずつ持つ(nzw.dev / nzw0725) 読み取り専用。メールの送信・削除はできない
同じプロジェクトの中で並走する 2 経路。共有しているのは同意画面だけ
IN CODE

権限は、コードの 1 行が決めている

ここが一番の勘所。「何を許可してもらうか」を決めているのはコンソールの設定ではなく、コード側で書いたスコープの文字列。コンソールでの登録は審査のための申告であって、実際の要求内容はアプリが認可のたびに送っている。

誤解しやすい点

コンソールでスコープを登録しても、コードが要求しなければ何も起きない。逆にコードが要求すれば、同意画面にはその分だけ出る。主導権はコードにある。

1. スコープを宣言する

Gmail 監視では、この 1 行がすべての起点になっている。

SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]

gmail.readonly は読み取り専用を意味する。送信したいなら gmail.send、削除まで許すなら gmail.modify と、別の文字列を書く。

つまり権限の強さは URL の末尾で決まる。ここを gmail.modify に書き換えれば、それだけでメールを消せる権限を要求するアプリになってしまう。

逆に言えば、書かない権限は絶対に付かない。

2. その宣言を持って認可を求めに行く

スクリプトはこの SCOPES を渡してブラウザを開く。ユーザーが見る同意画面の中身は、ここで渡した配列そのもの。

flow = InstalledAppFlow.from_client_config(
    {"installed": {"client_id": client_id, "client_secret": client_secret, ...}},
    SCOPES,          # ← 同意画面に出るのはこれ
)

creds = flow.run_local_server(
    port=0,
    access_type="offline",        # refresh token を貰う(長期間使うため)
    prompt="consent select_account",
)

access_type="offline" が「アプリを閉じた後も使い続けたい」という意思表示で、これがあると refresh token が返る。付けないとその場限りのアクセストークンしか貰えず、定期実行できない。

3. 貰ったトークンで API を呼ぶ

ここから先はブラウザも同意画面も出てこない。サーバーが持っている refresh token だけで動く。

credentials = Credentials(
    token=None,
    refresh_token=refresh_token,   # 手順 2 で貰った長期トークン
    token_uri="https://oauth2.googleapis.com/token",
    client_id=client_id,
    client_secret=client_secret,
    scopes=GMAIL_SCOPES,
)

注意したいのは、ここに書いた scopes が権限を増やすわけではないということ。権限が確定したのは手順 2 の同意の瞬間で、この行は「そのとき何を貰ったか」を控えているだけ。

ここを gmail.modify に書き換えても、実際に許可されていなければ API 呼び出しが弾かれる。

コードに書く gmail.readonly ここが唯一の指定箇所 同意画面に出る 「メールの閲覧」を許可? 本人が 1 回だけ押す refresh token 以降ブラウザ不要 .env に保管 開発者 利用者(1 回だけ) サーバー(ずっと) 権限が確定するのは真ん中の 1 回だけ 左でコードを書き換えても、もう一度同意を取り直さない限り権限は変わらない。 右のコードで強い権限名を書いても、同意していなければ API 側が拒否する。
スコープ宣言 → 同意 → トークン。権限が決まるのは中央の一瞬だけ

比較:ワークスペースのログインは何も宣言していない

同じ理屈で読むと、AI ワークスペース側が Gmail の許可を求めない理由がはっきりする。

google.accounts.id.initialize({
  client_id: CLIENT_ID,
  callback: handleCredentialResponse,
  auto_select: false,
});

scope という項目がどこにもない。書き忘れではなく、google.accounts.id は本人確認専用の API で、スコープを指定するオプションが存在しない。

API アクセスを求めたいときは google.accounts.oauth2 という別の名前空間を使う必要があり、こちらはサイト内で一度も使っていない。

そのためワークスペースのログインで許可画面が出ることは、コードを書き換えない限り起こらない。Gmail API を有効化しようが、別のクライアントが制限付きスコープを要求しようが、この initialize の中身は変わらない。

受け取った側の検証

サーバーは送られてきた ID トークンを Google の公開鍵で検証する。audience は「このトークンは本当にうちのクライアント宛か」の確認。

id_info = google.oauth2.id_token.verify_oauth2_token(
    id_token, request_adapter, audience=settings.GOOGLE_CLIENT_ID
)

ここで使う GOOGLE_CLIENT_ID はウェブ用クライアントの ID。Gmail 監視で使うデスクトップ用クライアントとは別物なので、設定も GMAIL_OAUTH_CLIENT_ID という別の変数に分けてある。

1 つの変数に 2 つの役目を持たせると、Gmail 側を直したときにログインの検証対象がずれて壊れる。

SHARED

唯一の共有物 — 同意画面

ここまで「独立している」と繰り返してきたが、1 つだけプロジェクト全体で共有されるものがある。それが OAuth 同意画面で、クライアントを何枚作っても同意画面は 1 枚しかない。

注意

プロジェクトの設定を触って全クライアントに影響が出るのは、実質ここだけ。逆に言えば、同意画面に手を入れないなら、新しいクライアントを追加してもスコープを増やしても既存のログインは無風で済む。

同意画面が持っている設定のうち、影響が大きいのは公開ステータス(テスト中/本番)。これはプロジェクト単位なので、片方のクライアントのために切り替えると、もう片方も同じ状態になる。

公開ステータスごとの上限・トークン寿命・警告画面の有無、および審査を通すまでの実務は、別ノートに詳しく書いた。

とくに「テスト中」のままだと認可が 7 日で失効する点は、常時動かすバックグラウンド処理には致命的なので、そちらを読んでおくとよい。

DECISION LOG

今回の判断

Gmail 用に新しいクライアントを立てた

DECISION 既存のウェブクライアントを使い回さず、デスクトップ種別を 1 枚追加した。設定も GOOGLE_CLIENT_ID とは別の GMAIL_OAUTH_CLIENT_ID に分離した。

WHY 技術的には http://localhost に戻せないという制約が理由。

ただしそれ以上に、ウェブクライアントの ID はログインの本人確認(audience 検証)に使っている値で、ここを Gmail 用に書き換えると認証の検証対象がずれる。

1 つの値に 2 つの役目を持たせると、片方を直すともう片方が壊れる。

RESULT 用途ごとに独立した。Gmail 側のクライアントを作り直しても、ログインには影響しない。

プロジェクトは分けず、既存のものに相乗りした

DECISION Gmail 監視専用の新規プロジェクトは作らず、AI ワークスペースと同じ 331995618405 に同居させた。

WHY 当初は「認証まわりを触りたくないから分けるべきか」と考えた。

だが影響範囲を洗った結果、共有物は同意画面 1 枚だけで、ワークスペース側は基本スコープしか使っておらず、公開ステータスを変えても実際に使える人は変わらない(アプリ側の許可リストが効く)ことが確認できた。

分ける理由が消えた。

RESULT 管理対象のプロジェクトが増えずに済んだ。ただしこの判断は「ワークスペースが基本スコープしか使っていない」という前提に依存している。

将来ワークスペースが機微・制限付きスコープを要求するようになったら、前提が崩れるので分離を再検討する。

CHEAT SHEET

迷ったときの early return

やろうとしていること他への影響
API を有効化するなし。呼べる API が増えるだけ
OAuth クライアントを追加するなし。身分証が 1 枚増えるだけ
クライアントのシークレットを再発行するそのクライアントのみ。他は無関係
サービスアカウントを作るなし。同意画面を通らない
テストユーザーを追加するプロジェクト全体(ただし増える方向のみ)
公開ステータスを変えるプロジェクト全体。ここだけ要注意
同意画面のスコープ構成を変えるプロジェクト全体。審査要件が変わる

結論としては、同意画面に触るとき以外は、過度に警戒しなくていい。逆に同意画面を触るときは、そのプロジェクトにぶら下がっている全アプリを思い出してから手を動かす。