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:
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¶
- Go to Google Cloud Console
- Select your project (or create one for Arini)
- Navigate to APIs & Services → Credentials
- Click + CREATE CREDENTIALS → OAuth client ID
- Select Web application
- 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
Step 3: Configure OAuth Consent Screen¶
- Go to APIs & Services → OAuth consent screen
- Choose Internal (restricts to your Google Workspace org automatically)
- Fill in:
- App name: "Arini Control Plane"
- User support email: your email
- Developer contact: your email
- Scopes: Add
email,profile,openid - 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¶
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.tsto mount auth routes - [ ] Create
src/ui/contexts/AuthContext.tsx - [ ] Create
src/ui/pages/Login.tsx - [ ] Update
src/ui/layouts/DashboardLayout.tsxwith user menu - [ ] Update
src/ui/routes.tsxwith login route - [ ] Update
src/ui/main.tsxwith AuthProvider - [ ] Update
wrangler.jsonc(remove basic auth vars) - [ ] Update
worker-configuration.d.tswith new env types - [ ] Run
pnpm cf-typegento 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)