This is a developer tutorial. It focuses on copy‑pasteable steps, code, and gotchas for MV3 extensions talking to IAP/IAM‑protected Cloud Run. (I have a separate trade‑journal case study aimed at analytics leaders.)
What you’ll build
A Chrome Extension (MV3) that signs in with Google OAuth2 using chrome.identity.launchWebAuthFlow
, obtains a Google ID token, and calls a Flask API on Cloud Run behind IAP/IAM. The backend verifies the token and (optionally) queries BigQuery.
High‑level flow
Extension (MV3)
→ launchWebAuthFlow → Google OAuth2 → ID token (JWT)
→ fetch with Authorization: Bearer <token>
→ Cloud Run (Flask; IAP/IAM)
→ verify token audience
→ BigQuery (optional)
Prereqs
- Chrome 121+ (MV3), a Google Cloud project
- A Web application OAuth2 client (client_id)
- A Cloud Run service (Flask) protected by IAP (preferred) or IAM
- Python 3.10+,
flask
,google-cloud-bigquery
,google-auth
- Basic familiarity with JWTs and Cloud roles
Tip: If you use IAP, the token's audience must be the IAP OAuth Client ID for your Cloud Run service. If you use only IAM, the audience is often the service URL.
1) Minimal MV3 setup
manifest.json
{
"manifest_version": 3,
"name": "Secure Cloud Run Caller",
"version": "1.0.0",
"permissions": ["identity", "storage", "activeTab", "scripting"],
"oauth2": {
"client_id": "YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com",
"scopes": ["openid", "email", "profile"]
},
"background": { "service_worker": "background.js" },
"action": { "default_title": "Secure Cloud Run Caller", "default_popup": "popup.html" }
}
popup.html
(skeleton)
<button id="go">Call API</button>
<pre id="out"></pre>
<script src="popup.js"></script>
2) Get an ID token with launchWebAuthFlow
background.js
async function getIdTokenInteractive() {
const redirectUrl = chrome.identity.getRedirectURL();
const u = new URL("https://accounts.google.com/o/oauth2/v2/auth");
u.searchParams.set("client_id", "YOUR_OAUTH_CLIENT_ID.apps.googleusercontent.com");
u.searchParams.set("response_type", "id_token");
u.searchParams.set("scope", "openid email profile");
u.searchParams.set("redirect_uri", redirectUrl);
u.searchParams.set("nonce", String(Date.now()));
u.searchParams.set("prompt", "select_account");
const { url } = await chrome.identity.launchWebAuthFlow({ url: u.toString(), interactive: true });
const hash = new URL(url).hash.substring(1); // "id_token=...&..."
const params = new URLSearchParams(hash);
const idToken = params.get("id_token");
if (!idToken) throw new Error("No id_token returned");
return idToken;
}
Optional: cache tokens with an expiry buffer
background.js
(continued)
function decodeJwtPayload(jwt) {
const payload = jwt.split(".")[1];
return JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
}
async function getCachedIdToken() {
const now = Math.floor(Date.now() / 1000);
const { cachedToken, cachedExp } = await chrome.storage.local.get(["cachedToken", "cachedExp"]);
if (cachedToken && cachedExp && (cachedExp - 300) > now) { // 5‑min buffer
return cachedToken;
}
const fresh = await getIdTokenInteractive();
const { exp } = decodeJwtPayload(fresh);
await chrome.storage.local.set({ cachedToken: fresh, cachedExp: exp });
return fresh;
}
3) Call the Cloud Run API
popup.js
document.getElementById("go").addEventListener("click", async () => {
const out = document.getElementById("out");
out.textContent = "Calling…";
try {
const token = await chrome.runtime.sendMessage({ type: "GET_TOKEN" });
const res = await fetch("https://YOUR‑SERVICE‑HASH‑ue.a.run.app/run-secure-endpoint", {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ url: (await chrome.tabs.query({ active: true, currentWindow: true }))[0].url })
});
out.textContent = res.ok ? JSON.stringify(await res.json(), null, 2) : `HTTP ${res.status}`;
} catch (e) {
out.textContent = e.message;
}
});
// in background.js
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type === "GET_TOKEN") getCachedIdToken().then(t => sendResponse(t)).catch(e => sendResponse({ error: String(e) }));
return true; // async
});
4) Flask on Cloud Run (verify the token)
main.py
from flask import Flask, request, jsonify
from google.oauth2 import id_token
from google.auth.transport import requests as greq
import os
app = Flask(__name__)
# If using IAP, set to the IAP OAuth client ID. Otherwise, expected audience (e.g., service URL).
IAP_AUDIENCE = os.environ.get("IAP_OAUTH_CLIENT_ID", "")
def verify_google_id_token(token: str) -> dict:
# Raises on failure; returns dict on success
return id_token.verify_oauth2_token(token, greq.Request(), audience=IAP_AUDIENCE)
@app.after_request
def add_cors(resp):
allow = os.environ.get("ALLOWED_ORIGIN", "*")
resp.headers["Access-Control-Allow-Origin"] = allow
resp.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
return resp
@app.route("/run-secure-endpoint", methods=["POST", "OPTIONS"])
def run_secure():
if request.method == "OPTIONS":
return ("", 204)
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "missing bearer token"}), 401
try:
payload = verify_google_id_token(auth.split()[1])
except Exception as e:
return jsonify({"error": "invalid token", "detail": str(e)}), 401
# TODO: enforce domain, user allowlist, or role mapping here
return jsonify({"ok": True, "email": payload.get("email")})
Optional: BigQuery example
from google.cloud import bigquery
@app.get("/prices")
def prices():
token = request.headers.get("Authorization", "")
if not token.startswith("Bearer "):
return jsonify({"error":"auth"}), 401
verify_google_id_token(token.split()[1]) # raises if bad
client = bigquery.Client() # uses Cloud Run service account
rows = client.query("SELECT 1 AS ok").result()
return jsonify({"rows":[dict(r) for r in rows]})
5) Troubleshooting 401s (quick fixes)
- Wrong audience → For IAP, use the IAP OAuth Client ID as audience.
-
Redirect URI mismatch → Add the extension redirect pattern via
chrome.identity.getRedirectURL()
to your OAuth client. - Clock skew → Ensure system time is correct; add a 5‑minute buffer when caching tokens.
-
CORS → Return the
Access-Control-*
headers shown above (or stricter). -
Roles → Caller must have
roles/run.invoker
; service account needs the minimal BigQuery roles. - Mixed IAP/IAM confusion → Pick one model and configure its audience/headers consistently.
6) Security checklist
- [ ] No client secrets or long‑lived tokens in the extension
- [ ] Verify JWT aud and exp on the server
- [ ] Use allowlists (email/domain) before serving data
- [ ] Keep scopes minimal (
openid email profile
) - [ ] Store tokens with an expiry buffer; refresh before use
- [ ] Keep Cloud Run service account least‑privileged; pull secrets from Secret Manager if needed
Repo layout (suggested)
repo/
├── extension/
│ ├── manifest.json
│ ├── background.js
│ └── popup.{html,js}
└── backend/
├── main.py
├── requirements.txt
└── Dockerfile
Wrap‑up
You now have a working pattern for MV3 → OAuth2 ID token → IAP/IAM‑protected Cloud Run → Flask (→ BigQuery). From here you can add per‑route authorization, richer UI, and CI/CD. If you publish a case‑study version for non‑developers, link it as the canonical later.
Got questions or stuck on 401s? Comment here or ping me on GitHub @saloni28.
Top comments (0)