Google OAuth
Here’s a compact, reference‑style recipe you can drop into the Cookbook for auth integration using Google (Gmail) as the example. It shows the standard OAuth 2.0 Authorization Code + PKCE web flow using Jetty HttpClient, persisting tokens in session, and making a sample Gmail API call.
🔐 Recipe: Google (Gmail) OAuth 2.0 with PKCE
SØAD supports browser‑based OAuth flows. For Google, use the Authorization Code Flow with PKCE:
- Redirect the user to Google’s consent screen (with
code_challenge) - Handle the callback, verify
state, exchange thecodefor tokens usingcode_verifier - Store
access_token(short‑lived) andrefresh_token(long‑lived) in your session or secure store - Call Gmail API with
Authorization: Bearer <access_token> - When access token expires, refresh it with the
refresh_token
Configure your Google OAuth client:
- Type: Web application
- Authorized redirect URI:
https://<host><ctxPath>/t/auth/google/callbackScopes (example):
openid email profilehttps://www.googleapis.com/auth/gmail.readonly(orgmail.send, etc.)
🧩 Transaction: google_auth.py
from utils import render, Log
from java.util import Base64, UUID
from java.net import URLEncoder
from java.nio.charset import StandardCharsets
from java.security import SecureRandom, MessageDigest
from org.eclipse.jetty.http import HttpHeader
from org.eclipse.jetty.client.util import StringContentProvider
from http_client_provider import HttpClientProvider # as in your Mailgun recipe
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GMAIL_LABELS_API = "https://gmail.googleapis.com/gmail/v1/users/me/labels"
# ---- Replace with your values (prefer reading from env or secure config) ----
CLIENT_ID = "YOUR_GOOGLE_CLIENT_ID"
CLIENT_SECRET = "YOUR_GOOGLE_CLIENT_SECRET" # not used for pure PKCE, but fine to keep
SCOPES = "openid email profile https://www.googleapis.com/auth/gmail.readonly"
# ---------------------------------------------------------------------------
class Google_auth(Layout):
# --- Helpers (PKCE + URL building) ---
def _base64url(self, b):
s = Base64.getUrlEncoder().withoutPadding().encodeToString(b)
return s
def _random_bytes(self, n=32):
rnd = SecureRandom()
buf = bytearray(n)
rnd.nextBytes(buf)
return bytes(buf)
def _code_verifier(self):
# 43-128 chars; here we generate 64 random bytes then base64url
return self._base64url(self._random_bytes(64))
def _code_challenge(self, verifier):
digest = MessageDigest.getInstance("SHA-256").digest(verifier.encode("UTF-8"))
return self._base64url(digest)
def _q(self, k, v):
return "%s=%s" % (k, URLEncoder.encode(v, StandardCharsets.UTF_8.name()))
# --- Step 1: Redirect user to Google with PKCE ---
def login(self, ctx):
session = ctx.getSession()
# PKCE
verifier = self._code_verifier()
challenge = self._code_challenge(verifier)
# CSRF state
state = UUID.randomUUID().toString()
# Store in session
session.setAttribute("google_pkce_verifier", verifier)
session.setAttribute("google_oauth_state", state)
redirect_uri = "%s/t/auth/google/callback" % ctx.ctxPath
params = [
self._q("client_id", CLIENT_ID),
self._q("redirect_uri", redirect_uri),
self._q("response_type", "code"),
self._q("scope", SCOPES),
self._q("state", state),
self._q("code_challenge", challenge),
self._q("code_challenge_method", "S256"),
# Optional UX tweaks:
# self._q("access_type", "offline"), # ask for refresh_token
# self._q("prompt", "consent"), # force consent each time
]
auth_url = GOOGLE_AUTH_URL + "?" + "&".join(params)
ctx.redirect(auth_url)
# --- Step 2: Handle callback & exchange code for tokens ---
def callback(self, ctx):
request = ctx.getRequest()
session = ctx.getSession()
code = request.getParameter("code")
state = request.getParameter("state")
err = request.getParameter("error")
if err:
Log.error("Google OAuth error: %s" % err)
ctx.output["error"] = "Authorization failed."
ctx.go_to = render.as_view(ctx, "auth_result")
return
expected_state = session.getAttribute("google_oauth_state")
if not expected_state or state != expected_state:
ctx.output["error"] = "Invalid state."
ctx.go_to = render.as_view(ctx, "auth_result")
return
verifier = session.getAttribute("google_pkce_verifier")
if not verifier:
ctx.output["error"] = "Missing PKCE verifier."
ctx.go_to = render.as_view(ctx, "auth_result")
return
redirect_uri = "%s/t/auth/google/callback" % ctx.ctxPath
form = [
self._q("grant_type", "authorization_code"),
self._q("code", code),
self._q("client_id", CLIENT_ID),
self._q("redirect_uri", redirect_uri),
self._q("code_verifier", verifier),
# If your OAuth config requires client_secret for web apps, include:
# self._q("client_secret", CLIENT_SECRET),
]
form_body = "&".join(form)
try:
client = HttpClientProvider.get_client()
req = client.POST(GOOGLE_TOKEN_URL)
req.header(HttpHeader.CONTENT_TYPE, "application/x-www-form-urlencoded")
req.content(StringContentProvider(form_body, "utf-8"))
res = req.send()
body = res.getContentAsString()
# Parse JSON (Jython)
import json
tok = json.loads(body)
access_token = tok.get("access_token")
refresh_token = tok.get("refresh_token") # may be None unless 'access_type=offline' & consent
id_token = tok.get("id_token")
if not access_token:
ctx.output["error"] = "Token exchange failed."
ctx.output["response"] = body
ctx.go_to = render.as_view(ctx, "auth_result")
return
# Persist tokens (session for demo; use secure store for prod)
session.setAttribute("google_access_token", access_token)
if refresh_token:
session.setAttribute("google_refresh_token", refresh_token)
if id_token:
session.setAttribute("google_id_token", id_token)
ctx.output["message"] = "Google account connected."
ctx.go_to = render.as_view(ctx, "auth_result")
except Exception as e:
Log.error("Token exchange error: %s" % str(e))
ctx.output["error"] = "Token exchange error."
ctx.go_to = render.as_view(ctx, "auth_result")
# --- Step 3: Call Gmail API (example: list labels) ---
def gmail_labels(self, ctx):
session = ctx.getSession()
token = session.getAttribute("google_access_token")
if not token:
ctx.redirect("%s/t/auth/google/login" % ctx.ctxPath)
return
try:
client = HttpClientProvider.get_client()
req = client.newRequest(GMAIL_LABELS_API)
req.header(HttpHeader.AUTHORIZATION, "Bearer %s" % token)
res = req.send()
ctx.output["status"] = res.getStatus()
ctx.output["result"] = res.getContentAsString()
except Exception as e:
Log.error("Gmail API call failed: %s" % str(e))
ctx.output["error"] = "Gmail API call failed."
ctx.go_to = render.as_view(ctx, "gmail_result")
📄 Views
_google_auth/auth_result.html
<h2>Google Auth Result</h2>
{{#if error}}
<div class="alert alert-danger">Error: {{error}}</div>
{{#if response}}<pre>{{response}}</pre>{{/if}}
{{else}}
<div class="alert alert-success">{{message}}</div>
<p><a href="{{ctxPath}}/t/auth/google/gmail_labels">List Gmail Labels</a></p>
{{/if}}
_google_auth/gmail_result.html
<h2>Gmail Labels (Raw Response)</h2>
{{#if error}}
<div class="alert alert-danger">{{error}}</div>
{{else}}
<div class="alert alert-success"><strong>Status:</strong> {{status}}</div>
<pre>{{result}}</pre>
{{/if}}
📝 Notes & Good Practices
- Refresh tokens: Request with
access_type=offline+prompt=consent(if needed). Use a scheduled job or on‑demand refresh when401occurs. - Token storage: Session is fine for demos; for production, store encrypted (e.g., DB + KMS, or OS keyring for CLI).
- Scopes: Minimize to what you need (
gmail.readonly,gmail.send, etc.). - Callback URL: Must match the one registered in Google Cloud Console.
- Error handling: Surface
error_descriptionfrom token endpoint if present to aid debugging.
This pattern is reusable for other Google APIs: change scopes and endpoints, keep the PKCE + code exchange flow the same.