hars.length]).join('')
}
export function generateVerifier() {
return generateRandomString(64) // Must be 43β128 chars
}
export async function generateChallenge(verifier) {
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const digest = await crypto.subtle.digest('SHA-256', data)
// base64url encode (different from regular base64 β no +, /, or = chars)
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/+/g, '-')
.replace(///g, '_')
.replace(/=/g, '')
}
### Step 2: Build the Authorization URL
```javascript
export async function buildAuthURL() {
const verifier = generateVerifier()
const challenge = await generateChallenge(verifier)
// Store verifier for later β must survive the redirect
sessionStorage.setItem('pkce_verifier', verifier)
const params = new URLSearchParams({
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: 'code',
scope: 'openid email profile https://www.googleapis.com/auth/drive.file',
code_challenge: challenge,
code_challenge_method: 'S256',
access_type: 'offline', // β Required to receive a refresh_token
prompt: 'consent', // β Required to actually receive the refresh_token (see below)
login_hint: invitedEmail, // β Optional: pre-fills the email field
})
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}
Critical Configuration: access_type=offline and prompt=consent
access_type=offline signals Google to issue a refresh token. However, Google caches prior consent and will not issue a new refresh token on subsequent logins. prompt=consent forces the consent screen to reappear, guaranteeing a fresh refresh token. Omitting either parameter results in a short-lived access token only, causing session failure after ~1 hour.
Step 3: Handle the Callback & Exchange Code
// app/auth/callback/route.js
import { NextResponse } from 'next/server'
export async function GET(request) {
const { searchParams } = new URL(request.url)
const code = searchParams.get('code')
const error = searchParams.get('error')
if (error || !code) {
return NextResponse.redirect(new URL('/auth-error', request.url))
}
// Get the verifier from the cookie (set during the redirect step)
const verifier = request.cookies.get('pkce_verifier')?.value
if (!verifier) {
return NextResponse.redirect(new URL('/auth-error', request.url))
}
const tokens = await exchangeCode(code, verifier, request)
// Clean up the verifier cookie
const response = NextResponse.redirect(new URL('/log', request.url))
response.cookies.delete('pkce_verifier')
// Store the access_token in a session cookie (short-lived)
response.cookies.set('g_access_token', tokens.access_token, {
httpOnly: true,
secure: true,
maxAge: 3600
})
// The refresh_token and id_token need further handling β see below
return response
}
async function exchangeCode(code, verifier, request) {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET, // Used server-side only
redirect_uri: `${new URL(request.url).origin}/auth/callback`,
grant_type: 'authorization_code',
code_verifier: verifier,
})
})
if (!response.ok) throw new Error('Token exchange failed')
return response.json()
// Returns: { access_token, refresh_token, id_token, expires_in, token_type }
}
Verifier Persistence Strategy: The verifier is generated client-side but required server-side during callback. The cleanest architecture uses a secure, short-lived cookie:
// In your sign-in button handler
export async function startSignIn() {
const verifier = generateVerifier()
const challenge = await generateChallenge(verifier)
// Set as a cookie so the server callback can read it
document.cookie = `pkce_verifier=${verifier}; path=/; secure; samesite=lax; max-age=300`
const authUrl = await buildAuthURL(verifier, challenge)
window.location.href = authUrl
}
Step 4: Token Routing & Supabase Bootstrap
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
// After token exchange β pass the id_token to Supabase
const { data, error } = await supabase.auth.signInWithIdToken({
provider: 'google',
token: googleIdToken,
})
This decouples authentication boundaries: Google OAuth manages Drive/API access (access_token + refresh_token), while Supabase manages app identity. The two lifecycles operate independently post-bootstrap.
Step 5: Silent Token Refresh
// lib/auth.js
export async function refreshAccessToken() {
// Read the refresh token from SQLite settings
const refreshToken = db.getOne(
'SELECT google_refresh_token FROM settings WHERE id = 1'
)?.google_refresh_token
if (!refreshToken) {
// No refresh token β user needs to sign in again
redirectToSignIn()
return null
}
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
grant_type: 'refresh_token',
})
})
if (!response.ok) {
// Refresh token revoked β show reconnect banner, don't break the app
showReconnectBanner()
return null
}
const { access_token } = await response.json()
// Store in memory (or sessionStorage) for Drive calls
setAccessToken(access_token)
return access_token
}
Schedule this execution at ~55 minutes post-login to preempt the 1-hour expiration window without UI interruption.
Pitfall Guide
- Relying on Implicit Flow: Returning tokens in URL fragments exposes credentials to browser history and referrer leakage. The OAuth Security BCP explicitly deprecates this pattern for SPAs.
- Omitting
access_type=offline or prompt=consent: Without both parameters, Google caches consent and only returns a short-lived access token. Your application will silently break after ~1 hour when the token expires.
- Client-Side Code Exchange: Performing the token exchange in the browser exposes the
client_id and code_verifier to the DOM. Always route the exchange through a serverless function or API route to maintain the PKCE security boundary.
- Persistent Storage in
localStorage: Access tokens are short-lived and highly sensitive. localStorage survives browser restarts and is vulnerable to XSS. Use sessionStorage, in-memory state, or httpOnly cookies instead.
- Verifier Loss Across Redirects: The
code_verifier must survive the OAuth redirect to Google and back. Relying on component state or sessionStorage alone can cause race conditions. Use a secure, SameSite=Lax cookie with a short max-age (e.g., 300s) to bridge client-server boundaries.
- Hardcoded Refresh Timing Without Grace Period: Access tokens expire at exactly 3600 seconds. Scheduling refresh at 60 minutes causes race conditions. Implement a 5-minute grace period (refresh at ~55 minutes) and handle
401 responses with exponential backoff retry logic.
Deliverables
π¦ PKCE OAuth Blueprint
A complete architecture diagram detailing the client-server token exchange boundary, cookie lifecycle management, and dual-auth (Google + Supabase) state synchronization flow. Includes timing diagrams for silent refresh and consent caching bypass.
β
Pre-Flight Implementation Checklist
βοΈ Configuration Templates
# .env.local
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-server-side-secret
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
// Cookie Security Template
const SECURE_COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 3600 // Match token TTL
}
-- Refresh Token Storage Schema (SQLite/PostgreSQL)
CREATE TABLE user_oauth_state (
id INTEGER PRIMARY KEY,
google_refresh_token TEXT NOT NULL,
supabase_user_id UUID REFERENCES auth.users(id),
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);