Skip to content

Google OAuth Authentication Design

Overview

Replace HTTP Basic Auth with Google Workspace OAuth for the Arini Control Plane. Stateless JWT-based sessions, no database required for auth.

Decisions

Decision Choice Rationale
Auth approach Stateless JWT Internal tool, no need for session revocation
Session duration 30 days Low friction, natural expiry if someone leaves org
Library @hono/oauth-providers + hono/jwt Native to Hono, minimal deps
Domain restriction @arini.ai only Google Workspace org
Cookie scope Two contexts .arini-ai.workers.dev (previews) and ari.ni (prod)
Post-login redirect Return to original URL Better UX for shared links

Authentication Flow

┌─────────────────────────────────────────────────────────────────┐
│ 1. User visits /debug/call/abc123 (protected)                   │
│    └─► Middleware checks for valid JWT cookie                   │
│        └─► No cookie → redirect to /auth/google?state=<url>     │
├─────────────────────────────────────────────────────────────────┤
│ 2. /auth/google initiates OAuth                                 │
│    └─► @hono/oauth-providers handles Google redirect            │
│    └─► Original URL preserved in `state` parameter              │
├─────────────────────────────────────────────────────────────────┤
│ 3. Google authenticates, redirects to /auth/google (callback)   │
│    └─► Middleware extracts user info (email, name, picture)     │
│    └─► Verify email ends with @arini.ai                         │
│    └─► Create signed JWT with user info (30-day expiry)         │
│    └─► Set cookie (Domain based on environment)                 │
│    └─► Redirect to original URL from state                      │
├─────────────────────────────────────────────────────────────────┤
│ 4. Subsequent requests                                          │
│    └─► Middleware verifies JWT signature                        │
│    └─► Valid → proceed to route handler                         │
│    └─► Invalid/expired → back to step 1                         │
└─────────────────────────────────────────────────────────────────┘

JWT Payload

{
  email: "user@arini.ai",
  name: "User Name",
  picture: "https://lh3.googleusercontent.com/...",
  exp: <30 days from issue time>
}

File Structure

New Files

src/worker/
├── routes/
│   └── auth.ts              # OAuth routes (/auth/google, /auth/logout, /auth/session)

src/ui/
├── contexts/
│   └── AuthContext.tsx      # User state provider, logout function
├── pages/
│   └── Login.tsx            # "Sign in with Google" page

Modified Files

src/worker/
├── middleware/
│   └── auth.ts              # Replace basic auth with JWT verification
└── index.ts                 # Mount auth routes, update middleware

src/ui/
├── layouts/
│   └── DashboardLayout.tsx  # Add user menu (avatar, name, sign out)
├── routes.tsx               # Add /login route, protect other routes
└── main.tsx                 # Wrap app in AuthProvider

Route Structure

Route Auth Purpose
GET /auth/google No Initiate Google OAuth / handle callback
GET /auth/logout No Clear cookie, redirect to login
GET /auth/session No Return current user from JWT (for frontend)
GET /login No Login page (frontend)
GET /d/:callId No Short URL redirects (public)
GET /api/* Yes All API routes (protected)
GET /* Yes All other frontend routes (protected)

Backend Implementation

src/worker/routes/auth.ts

import { Hono } from "hono"
import { googleAuth } from "@hono/oauth-providers/google"
import { sign, verify } from "hono/jwt"
import { setCookie, getCookie } from "hono/cookie"

const auth = new Hono<{ Bindings: Env }>()

// Google OAuth - handles both initiate and callback on same route
auth.use(
  "/google",
  googleAuth({
    scope: ["openid", "email", "profile"],
  })
)

auth.get("/google", async (c) => {
  const googleUser = c.get("user-google")

  // Verify arini.ai domain
  if (!googleUser?.email?.endsWith("@arini.ai")) {
    return c.redirect("/login?error=domain")
  }

  // Create JWT (30 days)
  const token = await sign(
    {
      email: googleUser.email,
      name: googleUser.name,
      picture: googleUser.picture,
      exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
    },
    c.env.SESSION_SECRET
  )

  // Set cookie with appropriate domain
  const isWorkersDev = new URL(c.req.url).hostname.endsWith("workers.dev")
  const domain = isWorkersDev ? ".arini-ai.workers.dev" : "ari.ni"

  setCookie(c, "session", token, {
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    domain,
    maxAge: 60 * 60 * 24 * 30,
    path: "/",
  })

  // Redirect to original URL (from state) or home
  const state = c.req.query("state")
  const redirectTo = state ? decodeURIComponent(state) : "/"
  return c.redirect(redirectTo)
})

auth.get("/logout", (c) => {
  const isWorkersDev = new URL(c.req.url).hostname.endsWith("workers.dev")
  const domain = isWorkersDev ? ".arini-ai.workers.dev" : "ari.ni"

  setCookie(c, "session", "", {
    httpOnly: true,
    secure: true,
    sameSite: "Lax",
    domain,
    maxAge: 0,
    path: "/",
  })
  return c.redirect("/login")
})

auth.get("/session", async (c) => {
  const session = getCookie(c, "session")
  if (!session) {
    return c.json({ user: null })
  }

  try {
    const payload = await verify(session, c.env.SESSION_SECRET)
    return c.json({
      user: {
        email: payload.email,
        name: payload.name,
        picture: payload.picture,
      },
    })
  } catch {
    return c.json({ user: null })
  }
})

export default auth

src/worker/middleware/auth.ts

import type { Context, Next } from "hono"
import { verify } from "hono/jwt"
import { getCookie } from "hono/cookie"

// Paths that don't require authentication
const PUBLIC_PATHS = ["/auth/", "/login", "/d/"]

function isPublicPath(path: string): boolean {
  return PUBLIC_PATHS.some((p) => path.startsWith(p))
}

export function createAuthMiddleware() {
  return async (c: Context<{ Bindings: Env }>, next: Next) => {
    // Allow public paths without auth
    if (isPublicPath(c.req.path)) {
      return next()
    }

    const token = getCookie(c, "session")

    if (!token) {
      // API requests get 401, page requests redirect to OAuth
      if (c.req.path.startsWith("/api/")) {
        return c.json({ error: "Unauthorized" }, 401)
      }
      const redirectUrl = encodeURIComponent(c.req.url)
      return c.redirect(`/auth/google?state=${redirectUrl}`)
    }

    try {
      const payload = await verify(token, c.env.SESSION_SECRET)
      c.set("user", payload)
      return next()
    } catch {
      // Invalid or expired token
      if (c.req.path.startsWith("/api/")) {
        return c.json({ error: "Unauthorized" }, 401)
      }
      const redirectUrl = encodeURIComponent(c.req.url)
      return c.redirect(`/auth/google?state=${redirectUrl}`)
    }
  }
}

src/worker/index.ts changes

import authRouter from "./routes/auth"

// Mount auth routes
app.route("/auth", authRouter)

// Apply auth middleware to all routes
app.use("*", createAuthMiddleware())

Frontend Implementation

src/ui/contexts/AuthContext.tsx

import { createContext, useContext, useEffect, useState, type ReactNode } from "react"

type User = {
  email: string
  name: string
  picture: string
}

type AuthContextType = {
  user: User | null
  loading: boolean
  logout: () => void
}

const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch("/auth/session")
      .then((r) => r.json())
      .then((data) => setUser(data.user))
      .catch(() => setUser(null))
      .finally(() => setLoading(false))
  }, [])

  const logout = () => {
    window.location.href = "/auth/logout"
  }

  return (
    <AuthContext.Provider value={{ user, loading, logout }}>
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error("useAuth must be used within AuthProvider")
  }
  return context
}

src/ui/pages/Login.tsx

import { useEffect } from "react"
import { useNavigate } from "react-router"
import { useAuth } from "@/contexts/AuthContext"

export function Login() {
  const { user, loading } = useAuth()
  const navigate = useNavigate()
  const error = new URLSearchParams(window.location.search).get("error")

  // Redirect if already logged in
  useEffect(() => {
    if (!loading && user) {
      navigate("/")
    }
  }, [user, loading, navigate])

  if (loading) {
    return (
      <div className="flex min-h-screen items-center justify-center bg-surface-1">
        <div className="text-white/60">Loading...</div>
      </div>
    )
  }

  return (
    <div className="flex min-h-screen items-center justify-center bg-surface-1">
      <div className="w-80 rounded-sm border border-border bg-surface-2 p-8 shadow-lg">
        <h1 className="mb-2 text-xl font-medium text-white">Arini Control Plane</h1>
        <p className="mb-6 text-sm text-white/60">Sign in to continue</p>

        {error === "domain" && (
          <div className="mb-4 rounded-sm bg-red-500/10 border border-red-500/20 p-3 text-sm text-red-400">
            Only @arini.ai accounts are allowed
          </div>
        )}

        <a
          href="/auth/google"
          className="flex w-full items-center justify-center gap-2 rounded-sm bg-white px-4 py-2 text-sm font-medium text-gray-800 hover:bg-gray-100 transition-colors"
        >
          <svg className="h-4 w-4" viewBox="0 0 24 24">
            <path
              fill="currentColor"
              d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
            />
            <path
              fill="currentColor"
              d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
            />
            <path
              fill="currentColor"
              d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
            />
            <path
              fill="currentColor"
              d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
            />
          </svg>
          Sign in with Google
        </a>
      </div>
    </div>
  )
}

src/ui/layouts/DashboardLayout.tsx changes

Add user menu to header:

import { useAuth } from "@/contexts/AuthContext"
import { useState, useRef, useEffect } from "react"

// Inside the component:
const { user, logout } = useAuth()
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)

// Close menu on outside click
useEffect(() => {
  const handleClickOutside = (e: MouseEvent) => {
    if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
      setMenuOpen(false)
    }
  }
  document.addEventListener("mousedown", handleClickOutside)
  return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])

// In the header JSX, add:
{user && (
  <div className="relative" ref={menuRef}>
    <button
      type="button"
      onClick={() => setMenuOpen(!menuOpen)}
      className="flex items-center gap-2 rounded-sm p-1 hover:bg-white/5"
    >
      {user.picture ? (
        <img src={user.picture} alt="" className="h-7 w-7 rounded-full" />
      ) : (
        <div className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-xs font-medium text-white">
          {user.name?.[0] || user.email[0]}
        </div>
      )}
    </button>

    {menuOpen && (
      <div className="absolute right-0 top-full mt-1 w-56 rounded-sm border border-border bg-surface-2 py-1 shadow-lg">
        <div className="border-b border-border px-3 py-2">
          <div className="text-sm font-medium text-white">{user.name}</div>
          <div className="text-xs text-white/60">{user.email}</div>
        </div>
        <button
          type="button"
          onClick={logout}
          className="w-full px-3 py-2 text-left text-sm text-white/80 hover:bg-white/5"
        >
          Sign out
        </button>
      </div>
    )}
  </div>
)}

src/ui/routes.tsx changes

Add login route:

import { Login } from "@/pages/Login"

// Add to routes:
{
  path: "/login",
  element: <Login />,
}

src/ui/main.tsx changes

Wrap app in AuthProvider:

import { AuthProvider } from "@/contexts/AuthContext"

// Wrap RouterProvider:
<AuthProvider>
  <RouterProvider router={router} />
</AuthProvider>

Environment Configuration

wrangler.jsonc

Remove basic auth vars, add Google redirect URI:

"vars": {
  "AWS_REGION": "us-east-2",
  "GOOGLE_REDIRECT_URI": "https://control-plane.arini-ai.workers.dev/auth/google"
}

Secrets (via wrangler CLI)

# Google OAuth credentials
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET

# JWT signing key (generate with: openssl rand -base64 32)
wrangler secret put SESSION_SECRET

worker-configuration.d.ts

Update Env type:

interface Env {
  // ... existing bindings
  GOOGLE_CLIENT_ID: string
  GOOGLE_CLIENT_SECRET: string
  GOOGLE_REDIRECT_URI: string
  SESSION_SECRET: string
}

Google Cloud Console Setup

Step 1: Create OAuth Credentials

  1. Go to Google Cloud Console
  2. Select your project (or create one for Arini)
  3. Navigate to APIs & ServicesCredentials
  4. Click + CREATE CREDENTIALSOAuth client ID
  5. Select Web application
  6. Name it "Arini Control Plane"

Step 2: Configure Authorized Redirect URIs

Add these redirect URIs:

# Production
https://ari.ni/auth/google

# Staging
https://control-plane.arini-ai.workers.dev/auth/google

# Local development
http://localhost:5173/auth/google
  1. Go to APIs & ServicesOAuth consent screen
  2. Choose Internal (restricts to your Google Workspace org automatically)
  3. Fill in:
  4. App name: "Arini Control Plane"
  5. User support email: your email
  6. Developer contact: your email
  7. Scopes: Add email, profile, openid
  8. Save

Step 4: Copy Credentials

After creating the OAuth client: 1. Copy the Client ID (looks like 123456789-abc.apps.googleusercontent.com) 2. Copy the Client Secret (looks like GOCSPX-...)

Step 5: Set Secrets in Cloudflare

# Set the secrets
wrangler secret put GOOGLE_CLIENT_ID
# Paste the client ID when prompted

wrangler secret put GOOGLE_CLIENT_SECRET
# Paste the client secret when prompted

# Generate and set session secret
openssl rand -base64 32
wrangler secret put SESSION_SECRET
# Paste the generated value when prompted

Dependencies

pnpm add @hono/oauth-providers

Note: hono/jwt and hono/cookie are built into Hono, no additional install needed.

Implementation Checklist

  • [ ] Create src/worker/routes/auth.ts
  • [ ] Update src/worker/middleware/auth.ts
  • [ ] Update src/worker/index.ts to mount auth routes
  • [ ] Create src/ui/contexts/AuthContext.tsx
  • [ ] Create src/ui/pages/Login.tsx
  • [ ] Update src/ui/layouts/DashboardLayout.tsx with user menu
  • [ ] Update src/ui/routes.tsx with login route
  • [ ] Update src/ui/main.tsx with AuthProvider
  • [ ] Update wrangler.jsonc (remove basic auth vars)
  • [ ] Update worker-configuration.d.ts with new env types
  • [ ] Run pnpm cf-typegen to regenerate types
  • [ ] Set up Google Cloud OAuth credentials
  • [ ] Set Cloudflare secrets via wrangler CLI
  • [ ] Test locally with pnpm dev
  • [ ] Deploy and test on staging
  • [ ] Test on production (ari.ni)