Skip to content

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:

  1. Redirect the user to Google’s consent screen (with code_challenge)
  2. Handle the callback, verify state, exchange the code for tokens using code_verifier
  3. Store access_token (short‑lived) and refresh_token (long‑lived) in your session or secure store
  4. Call Gmail API with Authorization: Bearer <access_token>
  5. 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/callback
  • Scopes (example):

  • openid email profile

  • https://www.googleapis.com/auth/gmail.readonly (or gmail.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 when 401 occurs.
  • 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_description from 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.