はじめに
業務効率化のために、Jiraのタイムラインビューに勤怠データを視覚化するChrome拡張を作っていました。
具体的には、社内Googleスプレッドシートで管理している勤怠表を参照し、各メンバーの「全休」日をJiraのタイムライン上でオレンジ色にハイライトする機能です。
この記事では、最終的にGoogle Apps Script(GAS)をプロキシとして立てることで解決するまでの失敗の過程も含めて共有します。
作ったもの(ざっくり)
- Jiraのタイムラインビューに常駐するChrome拡張(Manifest V3)
- URLのassigneeパラメータから誰のタイムラインか判定
- Googleスプレッドシートから該当ユーザーの勤怠データを取得
- 「全休」の日をオレンジ色で着色
実装はAIとの対話形式で進め、エラーメッセージを貼り付けながら原因を絞り込んでいきました。その過程で「これはCORSの問題だけじゃなく、認証リダイレクトが絡んでいる」という根本原因に気づくことができました。
「これでいけるはず」な当初の設計
最初のアーキテクチャはシンプルでした。
Jiraタイムラインページ
↓ content script が動く
fetch("https://docs.google.com/spreadsheets/d/SHEET_ID/export?format=csv&gid=GID")
↓
CSVを解析 → 全休日を抽出 → DOMに着色
manifest.json にはこう書きました。
{
"host_permissions": [
"https://*.atlassian.net/*",
"https://docs.google.com/*"
]
}
「host_permissionsに書いてあるんだから fetch できるはず」という認識でした。
壁① content scriptのCORSエラー
拡張機能を読み込んでJiraを開くと、早速コンソールにエラーが。
TypeError: Failed to fetch
調べると、MV3のcontent scriptからのクロスオリジンfetchは、host_permissionsに宣言していてもCORSポリシーの対象になることがわかりました。content scriptはあくまでWebページのコンテキストで動くため、docs.google.comが適切なCORSヘッダーを返さない限りブロックされます。
対策として、background service workerにfetchを委譲する構成に変更しました。
// content.js(修正後):fetchをbackground SWに依頼する
const result = await new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'FETCH_CSV', url: csvUrl }, (res) => {
resolve(res || { ok: false, error: 'no response' });
});
});
// background.js(新規追加):background SWでfetchを実行
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FETCH_CSV') {
fetch(msg.url, { credentials: 'include' })
.then(async (res) => {
const text = await res.text();
sendResponse({ ok: res.ok, status: res.status, text });
})
.catch((e) => sendResponse({ ok: false, error: e.message }));
return true; // 非同期レスポンスを使うため必須
}
});
background service workerならホスト権限の範囲で自由にfetchできるはず——そう思っていました。
壁② background SWでも詰まった:認証リダイレクトの罠
background service workerに移行しても、同じエラーが出続けました。
[HolidayExt] スプレッドシート取得エラー: TypeError: Failed to fetch
AIとのデバッグセッションで詳細を掘り下げると、2段階の問題が見えてきました。
問題2-1:credentials: 'include' がCORSプリフライトを呼び起こす
最初の background SW 実装では credentials: 'include' をつけていました。これによってCORSのプリフライトリクエスト(OPTIONSメソッド)が発生し、Googleのサーバーが Access-Control-Allow-Credentials: true を返さないためにブロックされていました。
Access to fetch at 'https://docs.google.com/...'
from origin 'chrome-extension://...' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
The value of the 'Access-Control-Allow-Credentials' header in the response
is '' which must be 'true' when the request's credentials mode is 'include'.
credentials: 'include' を外しました。しかし——
問題2-2(根本原因):プライベートスプレッドシートへのアクセスは認証リダイレクトを踏む
credentials を外しても状況は変わりませんでした。本当の原因はここです。
プライベートなスプレッドシートへのアクセスは、Googleが accounts.google.com へ302リダイレクトして認証を求めます。
fetch("https://docs.google.com/spreadsheets/...")
→ 302 Redirect → https://accounts.google.com/signin/...
accounts.google.com は host_permissions に含まれていません。Chromeはこのリダイレクト先へのアクセスをブロックします。
background.js で redirect: 'manual' を指定してリダイレクト検出を試みたところ、status: 0 が返ってきて302であることは確認できましたが、スプレッドシートのデータは取れません。
host_permissions に https://accounts.google.com/* を追加する手もありますが、そもそも Chrome拡張から Google の認証フロー全体を通した fetch を行うのは設計として無理があると判断しました。
解決策:GASをプロキシとして立てる
最終的に採用した解決策は、Google Apps ScriptをWebアプリとして公開し、プロキシとして利用することです。
GASはGoogleのサーバー上でGoogle認証済みの状態で動くため、スプレッドシートへのアクセスに認証リダイレクトが発生しません。Webアプリとして公開すれば、Chrome拡張から普通のHTTPリクエストで呼び出せます。
Chrome拡張(background SW)
↓ fetch(認証不要)
GAS Webアプリ(Googleサーバー上で実行)
↓ SpreadsheetApp.openById(認証済み)
Googleスプレッドシート
GAS側のコード
const SPREADSHEET_ID = 'your-spreadsheet-id';
const SHEET_GID = 1234567890; // 数値のシートID
function doGet(e) {
try {
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheets().find(s => s.getSheetId() === SHEET_GID);
const values = sheet.getDataRange().getValues();
return ContentService
.createTextOutput(JSON.stringify({ ok: true, data: values }))
.setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService
.createTextOutput(JSON.stringify({ ok: false, error: err.message }))
.setMimeType(ContentService.MimeType.JSON);
}
}
デプロイ設定(重要)
GASエディタの「デプロイ」→「新しいデプロイ」で以下を設定します。
- 種類: ウェブアプリ
- 実行者: 自分(スプレッドシートへのアクセス権を持つGoogleアカウント)
- アクセスできるユーザー: 組織内全員(または自分のみ)
セキュリティ注意: 「全員(匿名含む)」にするとURLを知っていれば誰でもアクセスできます。社内限定ツールなら「組織内全員」で十分です。デプロイURLは外部に漏らさないよう管理してください。
Chrome拡張側の変更
background.jsをGAS URLへのfetchに変更します。
// background.js(最終版)
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'FETCH_CSV') {
fetch(msg.gasUrl) // GASのデプロイURLを使う
.then(async (res) => {
const json = await res.json();
sendResponse({ ok: json.ok, data: json.data });
})
.catch((e) => sendResponse({ ok: false, error: e.message }));
return true;
}
});
manifest.jsonのhost_permissionsもdocs.google.comからscript.google.comに変更します。
"host_permissions": [
"https://*.atlassian.net/*",
"https://script.google.com/*"
]
GAS URLはポップアップUIで設定・chrome.storage.localに保存する形にすると、チームで共有しやすくなります(各自が自分の GAS をデプロイしてURLを入力する運用)。
動作確認
GASプロキシを経由することで、プライベートなスプレッドシートのデータが認証問題なく取得できるようになり、全休日のオレンジ着色機能が動作するようになりました。
まとめ
Chrome拡張からGoogleスプレッドシートへ直接アクセスしようとしたときの失敗と解決策を振り返ります。
【失敗① content scriptから直接fetch】
content scriptはWebページコンテキスト
→ host_permissionsがあってもCORSの壁でブロック
【失敗② background SW + credentials: 'include'】
credentialsつきfetchはCORSプリフライトが発生
→ GoogleサーバーがCORSヘッダーを返さずブロック
【失敗③ background SWからの素のfetch】
プライベートSS → 302 → accounts.google.com
→ host_permissions外へのリダイレクトでChromeがブロック
【解決】GASをWebアプリとして公開・プロキシ化
→ Google認証済みGASがスプレッドシートにアクセス
→ 拡張はGAS URLにfetchするだけ(認証不要)
GASプロキシのパターンは今回のスプレッドシート以外にも、Google Drive・Gmail・CalendarなどGoogle Workspaceサービスへのアクセスが必要なChrome拡張全般に応用できます。
「host_permissionsに書いたのにfetchが通らない」で詰まったときは、そのドメインが認証リダイレクトを挟んでいないかを確認してみてください。リダイレクト先がhost_permissionsの外に出た瞬間、Chromeは問答無用でブロックします。