- Grove Auth Integration
- Add Heartwood authentication to a Grove property — from client registration through production deployment.
- When to Activate
- User says "add auth to this project" or "wire up Heartwood"
- User is building a new Grove property that needs login
- User needs to register a new OAuth client with Heartwood
- User explicitly calls
- /grove-auth-integration
- User mentions needing sign-in, protected routes, or session validation
- User says "integrate GroveAuth" or "add login"
- Key URLs
- Service
- URL
- Purpose
- Login UI
- https://heartwood.grove.place
- Where users authenticate
- API
- https://auth-api.grove.place
- Token exchange, verify, sessions
- D1 Database
- groveauth
- (via wrangler)
- Client registration
- The Pipeline
- Identify → Register Client → Configure Secrets → Write Code → Wire Wrangler → Test
- Error Handling in Auth Flows:
- Auth errors MUST use the
- AUTH_ERRORS
- Signpost catalog — never bare redirect with ad-hoc error strings.
- import
- {
- AUTH_ERRORS
- ,
- getAuthError
- ,
- logAuthError
- ,
- buildErrorParams
- ,
- }
- from
- "@autumnsgrove/lattice/heartwood"
- ;
- // In callback — map OAuth error to structured code
- if
- (
- errorParam
- )
- {
- const
- authError
- =
- getAuthError
- (
- errorParam
- )
- ;
- logAuthError
- (
- authError
- ,
- {
- path
- :
- "/auth/callback"
- }
- )
- ;
- redirect
- (
- 302
- ,
- `
- /login?
- ${
- buildErrorParams
- (
- authError
- )
- }
- `
- )
- ;
- }
- See
- AgentUsage/error_handling.md
- for the full Signpost reference.
- Type-Safe Error Handling in Catch Blocks:
- Always use Rootwork type guards in catch blocks instead of manual property checks. Import
- isRedirect()
- and
- isHttpError()
- from
- @autumnsgrove/lattice/server
- :
- import
- {
- isRedirect
- ,
- isHttpError
- }
- from
- "@autumnsgrove/lattice/server"
- ;
- try
- {
- // ... auth flow code
- }
- catch
- (
- err
- )
- {
- if
- (
- isRedirect
- (
- err
- )
- )
- throw
- err
- ;
- // Re-throw SvelteKit redirects
- if
- (
- isHttpError
- (
- err
- )
- )
- {
- // Handle HTTP errors with proper status and message
- }
- redirect
- (
- 302
- ,
- "/?error=auth_failed"
- )
- ;
- }
- Step 1: Identify the Integration
- Ask the user (or determine from context):
- Project name
- — The Cloudflare Pages/Workers project name
- Client ID
- — A simple slug (e.g.,
- grove-plant
- ,
- grove-domains
- ,
- arbor-admin
- )
- Site URL
- — Production URL (e.g.,
- https://plant.grove.place
- )
- Callback path
- — Usually
- /auth/callback
- Project type
- — SvelteKit Pages (most common), Workers, or other
- Session approach
- — OAuth tokens (standard) or SessionDO (faster, same-account only)
- Step 2: Register the OAuth Client
- 2a. Generate client secret
- CLIENT_SECRET
- =
- $(
- openssl rand
- -base64
- 32
- )
- echo
- "Client Secret:
- $CLIENT_SECRET
- "
- Save this value — you'll need it for both the client secrets AND the database hash.
- 2b. Generate base64url hash
- CRITICAL
- Heartwood uses
base64url encoding
— dashes (
-
), underscores (
_
), NO padding (
=
).
CLIENT_SECRET_HASH
=
$(
echo
-n
"
$CLIENT_SECRET
"
|
openssl dgst
-sha256
-binary
|
base64
|
tr
'+/'
'-'
|
tr
-d
'='
)
echo
"Secret Hash:
$CLIENT_SECRET_HASH
"
Format
Example
Correct?
base64url
Sdgtaokie8-H7GKw-tn0S_6XNSh1rdv
YES
base64
Sdgtaokie8+H7GKw+tn0S/6XNSh1rdv=
NO
hex
49d82d6a89227bcf87ec62b0...
NO
2c. Insert into Heartwood database
wrangler d1 execute groveauth
--remote
--command
=
"
INSERT INTO clients (id, name, client_id, client_secret_hash, redirect_uris, allowed_origins)
VALUES (
'
$(
uuidgen
|
tr
'[:upper:]'
'[:lower:]'
)
',
'DISPLAY_NAME',
'CLIENT_ID',
'BASE64URL_HASH',
'[
\"
https://SITE_URL/auth/callback
\"
,
\"
http://localhost:5173/auth/callback
\"
]',
'[
\"
https://SITE_URL
\"
,
\"
http://localhost:5173
\"
]'
)
ON CONFLICT(client_id) DO UPDATE SET
client_secret_hash = excluded.client_secret_hash,
redirect_uris = excluded.redirect_uris,
allowed_origins = excluded.allowed_origins;
"
Always include localhost
in redirect_uris and allowed_origins for development.
Step 3: Configure Secrets on the Client
For Pages projects (SvelteKit):
echo
"CLIENT_ID"
|
wrangler pages secret put GROVEAUTH_CLIENT_ID
--project
PROJECT_NAME
echo
"CLIENT_SECRET"
|
wrangler pages secret put GROVEAUTH_CLIENT_SECRET
--project
PROJECT_NAME
echo
"https://SITE_URL/auth/callback"
|
wrangler pages secret put GROVEAUTH_REDIRECT_URI
--project
PROJECT_NAME
echo
"https://auth-api.grove.place"
|
wrangler pages secret put GROVEAUTH_URL
--project
PROJECT_NAME
For Workers projects:
cd
worker-directory
echo
"CLIENT_ID"
|
wrangler secret put GROVEAUTH_CLIENT_ID
echo
"CLIENT_SECRET"
|
wrangler secret put GROVEAUTH_CLIENT_SECRET
Step 4: Write the Auth Code (SvelteKit)
Create these files in the SvelteKit project:
4a. Login initiation route:
src/routes/auth/+server.ts
/*
* OAuth Initiation - Start Heartwood OAuth flow
* Redirects to GroveAuth with PKCE parameters.
/
import
{
redirect
}
from
"@sveltejs/kit"
;
import
type
{
RequestHandler
}
from
"./$types"
;
function
generateRandomString
(
length
:
number
)
:
string
{
const
charset
=
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-.~"
;
const
randomValues
=
crypto
.
getRandomValues
(
new
Uint8Array
(
length
)
)
;
return
Array
.
from
(
randomValues
,
(
v
)
=>
charset
[
v
%
charset
.
length
]
)
.
join
(
""
)
;
}
async
function
generatePKCE
(
)
:
Promise
<
{
verifier
:
string
;
challenge
:
string
;
}
{ const verifier = generateRandomString ( 64 ) ; const encoder = new TextEncoder ( ) ; const data = encoder . encode ( verifier ) ; const hash = await crypto . subtle . digest ( "SHA-256" , data ) ; const challenge = btoa ( String . fromCharCode ( ... new Uint8Array ( hash ) ) ) . replace ( / + / g , "-" ) . replace ( / \/ / g , "_" ) . replace ( / = / g , "" ) ; return { verifier , challenge } ; } export const GET : RequestHandler = async ( { url , cookies , platform } ) => { const env = platform ?. env as Record < string , string
| undefined ; const authBaseUrl = env ?. GROVEAUTH_URL || "https://auth-api.grove.place" ; const clientId = env ?. GROVEAUTH_CLIENT_ID || "YOUR_CLIENT_ID" ; const appBaseUrl = env ?. PUBLIC_APP_URL || "https://YOUR_SITE_URL" ; const redirectUri =
${ appBaseUrl } /auth/callback; const { verifier , challenge } = await generatePKCE ( ) ; const state = generateRandomString ( 32 ) ; const isProduction = url . hostname !== "localhost" && url . hostname !== "127.0.0.1" ; const cookieOptions = { path : "/" , httpOnly : true , secure : isProduction , sameSite : "lax" as const , maxAge : 60 * 10 , // 10 minutes } ; cookies . set ( "auth_state" , state , cookieOptions ) ; cookies . set ( "auth_code_verifier" , verifier , cookieOptions ) ; const authUrl = new URL (${ authBaseUrl } /login) ; authUrl . searchParams . set ( "client_id" , clientId ) ; authUrl . searchParams . set ( "redirect_uri" , redirectUri ) ; authUrl . searchParams . set ( "response_type" , "code" ) ; authUrl . searchParams . set ( "scope" , "openid profile email" ) ; authUrl . searchParams . set ( "state" , state ) ; authUrl . searchParams . set ( "code_challenge" , challenge ) ; authUrl . searchParams . set ( "code_challenge_method" , "S256" ) ; redirect ( 302 , authUrl . toString ( ) ) ; } ; 4b. Callback handler: src/routes/auth/callback/+server.ts /* * OAuth Callback - Handle Heartwood OAuth response * Exchanges authorization code for tokens and creates session. / import { redirect } from "@sveltejs/kit" ; import { isRedirect } from "@autumnsgrove/lattice/server" ; import type { RequestHandler } from "./$types" ; export const GET : RequestHandler = async ( { url , cookies , platform } ) => { const code = url . searchParams . get ( "code" ) ; const state = url . searchParams . get ( "state" ) ; const errorParam = url . searchParams . get ( "error" ) ; if ( errorParam ) { redirect ( 302 ,/?error= ${ encodeURIComponent ( errorParam ) }) ; } // Validate state (CSRF protection) const savedState = cookies . get ( "auth_state" ) ; if ( ! state || state !== savedState ) { redirect ( 302 , "/?error=invalid_state" ) ; } // Get PKCE verifier const codeVerifier = cookies . get ( "auth_code_verifier" ) ; if ( ! codeVerifier || ! code ) { redirect ( 302 , "/?error=missing_credentials" ) ; } // Clear auth cookies immediately cookies . delete ( "auth_state" , { path : "/" } ) ; cookies . delete ( "auth_code_verifier" , { path : "/" } ) ; const env = platform ?. env as Record < string , string| undefined ; const authBaseUrl = env ?. GROVEAUTH_URL || "https://auth-api.grove.place" ; const clientId = env ?. GROVEAUTH_CLIENT_ID || "YOUR_CLIENT_ID" ; const clientSecret = env ?. GROVEAUTH_CLIENT_SECRET || "" ; const appBaseUrl = env ?. PUBLIC_APP_URL || "https://YOUR_SITE_URL" ; const redirectUri =
${ appBaseUrl } /auth/callback; try { // Exchange code for tokens const tokenResponse = await fetch (${ authBaseUrl } /token, { method : "POST" , headers : { "Content-Type" : "application/x-www-form-urlencoded" } , body : new URLSearchParams ( { grant_type : "authorization_code" , code , redirect_uri : redirectUri , client_id : clientId , client_secret : clientSecret , code_verifier : codeVerifier , } ) , } ) ; if ( ! tokenResponse . ok ) { redirect ( 302 , "/?error=token_exchange_failed" ) ; } const tokens = ( await tokenResponse . json ( ) ) as { access_token : string ; refresh_token ? : string ; expires_in ? : number ; } ; // Fetch user info const userinfoResponse = await fetch (${ authBaseUrl } /userinfo, { headers : { Authorization :Bearer ${ tokens . access_token }} , } ) ; if ( ! userinfoResponse . ok ) { redirect ( 302 , "/?error=userinfo_failed" ) ; } const userinfo = ( await userinfoResponse . json ( ) ) as { sub ? : string ; id ? : string ; email : string ; name ? : string ; email_verified ? : boolean ; } ; const userId = userinfo . sub || userinfo . id ; const email = userinfo . email ; if ( ! userId || ! email ) { redirect ( 302 , "/?error=incomplete_profile" ) ; } // Set session cookies const isProduction = url . hostname !== "localhost" && url . hostname !== "127.0.0.1" ; const cookieOptions = { path : "/" , httpOnly : true , secure : isProduction , sameSite : "lax" as const , maxAge : 60 * 60 * 24 * 7 , // 7 days } ; cookies . set ( "access_token" , tokens . access_token , { ... cookieOptions , maxAge : tokens . expires_in || 3600 , } ) ; if ( tokens . refresh_token ) { cookies . set ( "refresh_token" , tokens . refresh_token , { ... cookieOptions , maxAge : 60 * 60 * 24 * 30 , // 30 days } ) ; } // TODO: Create local session or onboarding record as needed // This varies by property — adapt to your app's needs redirect ( 302 , "/dashboard" ) ; // Or wherever authenticated users go } catch ( err ) { // Type-safe error handling with Rootwork type guards if ( isRedirect ( err ) ) throw err ; // Re-throw SvelteKit redirects redirect ( 302 , "/?error=auth_failed" ) ; } } ; 4c. Session validation hook: src/hooks.server.ts Choose ONE approach: Option A: Token verification (works cross-account) import type { Handle } from "@sveltejs/kit" ; export const handle : Handle = async ( { event , resolve } ) => { const accessToken = event . cookies . get ( "access_token" ) ; if ( accessToken ) { try { const env = event . platform ?. env as Record < string , string| undefined ; const authBaseUrl = env ?. GROVEAUTH_URL || "https://auth-api.grove.place" ; const response = await fetch (
${ authBaseUrl } /verify, { headers : { Authorization :Bearer ${ accessToken }} , } ) ; if ( response . ok ) { const result = await response . json ( ) ; if ( result . active ) { event . locals . user = { id : result . sub , email : result . email , name : result . name , } ; } } } catch { // Token invalid or expired — user remains unauthenticated } } return resolve ( event ) ; } ; Option B: SessionDO validation (faster, same Cloudflare account only) Requires a service binding in wrangler.toml (see Step 5). import type { Handle } from "@sveltejs/kit" ; export const handle : Handle = async ( { event , resolve } ) => { const cookieHeader = event . request . headers . get ( "Cookie" ) || "" ; // Only validate if grove_session or access_token cookie exists if ( cookieHeader . includes ( "grove_session" ) || cookieHeader . includes ( "access_token" ) ) { try { const env = event . platform ?. env as any ; // Use service binding for sub-100ms validation const response = await env . AUTH . fetch ( "https://auth-api.grove.place/session/validate" , { method : "POST" , headers : { Cookie : cookieHeader } , } ) ; if ( response . ok ) { const { valid , user } = await response . json ( ) ; if ( valid && user ) { event . locals . user = { id : user . id , email : user . email , name : user . name , } ; } } } catch { // Session invalid — user remains unauthenticated } } return resolve ( event ) ; } ; 4d. Type definitions: src/app.d.ts declare global { namespace App { interface Locals { user ? : { id : string ; email : string ; name ? : string | null ; } ; } interface Platform { env ? : { GROVEAUTH_URL ? : string ; GROVEAUTH_CLIENT_ID ? : string ; GROVEAUTH_CLIENT_SECRET ? : string ; PUBLIC_APP_URL ? : string ; AUTH ? : Fetcher ; // Service binding (Option B only) DB ? : D1Database ; [ key : string ] : unknown ; } ; } } } export { } ; 4e. Protected route layout: src/routes/admin/+layout.server.ts import { redirect } from "@sveltejs/kit" ; import type { LayoutServerLoad } from "./$types" ; export const load : LayoutServerLoad = async ( { locals } ) => { if ( ! locals . user ) { redirect ( 302 , "/auth" ) ; } return { user : locals . user } ; } ; 4f. Logout route: src/routes/auth/logout/+server.ts import { redirect } from "@sveltejs/kit" ; import type { RequestHandler } from "./$types" ; export const POST : RequestHandler = async ( { cookies , platform } ) => { const accessToken = cookies . get ( "access_token" ) ; if ( accessToken ) { const env = platform ?. env as Record < string , string| undefined ; const authBaseUrl = env ?. GROVEAUTH_URL || "https://auth-api.grove.place" ; // Revoke token at Heartwood await fetch (
${ authBaseUrl } /logout, { method : "POST" , headers : { Authorization :Bearer ${ accessToken }} , } ) . catch ( ( ) => { } ) ; // Best-effort } // Clear all auth cookies cookies . delete ( "access_token" , { path : "/" } ) ; cookies . delete ( "refresh_token" , { path : "/" } ) ; redirect ( 302 , "/" ) ; } ; Step 5: Wire Up wrangler.toml Add these to the project's wrangler.toml :
Heartwood (GroveAuth) - Service Binding for fast session validation
Only works for projects in the same Cloudflare account
[ [ services ] ] binding = "AUTH" service = "groveauth"
Secrets (configured via wrangler pages secret / Cloudflare Dashboard):
- GROVEAUTH_URL = https://auth-api.grove.place
- GROVEAUTH_CLIENT_ID = your-client-id
- GROVEAUTH_CLIENT_SECRET = (generated in Step 2)
- GROVEAUTH_REDIRECT_URI = https://yoursite.com/auth/callback
- PUBLIC_APP_URL = https://yoursite.com
Step 6: Test the Flow Deploy the project (or run locally with pnpm dev ) Navigate to /auth — should redirect to Heartwood login Authenticate with Google (or other configured provider) Should redirect back to /auth/callback Should set cookies and redirect to protected area Visit /admin (protected) — should show authenticated content Logout via POST to /auth/logout — should clear session Token Lifecycle Token Lifetime Storage Refresh Access Token 1 hour httpOnly cookie Via refresh token Refresh Token 30 days httpOnly cookie Re-login required PKCE Verifier 10 minutes httpOnly cookie Single-use State 10 minutes httpOnly cookie Single-use Session Validation Approaches Approach Speed Requirement Use When Token Verify ( /verify ) ~50-150ms Network call Cross-account, external services SessionDO ( /session/validate ) ~5-20ms Service binding Same Cloudflare account (recommended) Cookie SSO ( .grove.place ) ~0ms (local) Same domain All *.grove.place properties For Grove properties on .grove.place subdomains, the grove_session cookie is shared automatically across all subdomains. Checklist Before going live, verify: Client registered in Heartwood D1 ( clients table) Secret hash uses base64url encoding (dashes, underscores, no padding) redirect_uris includes BOTH production AND localhost allowed_origins includes BOTH production AND localhost Secrets set via wrangler pages secret put (NOT in wrangler.toml!) PKCE flow generates fresh verifier per login attempt State parameter validated in callback (CSRF protection) Auth cookies cleared immediately after code exchange Protected routes check locals.user and redirect if missing Logout revokes token at Heartwood AND clears local cookies Cookie secure flag is true in production, false in localhost Troubleshooting "Invalid client credentials" (401) Almost always a hash format mismatch . Regenerate with the exact base64url command in Step 2b. "Invalid redirect_uri" The callback URL must EXACTLY match what's in the redirect_uris JSON array in the database. Check trailing slashes, protocol, and port. "Invalid state" on callback The state cookie expired (10min TTL) or was lost. Check: Cookie domain matches the callback domain No redirect loops between domains consuming the cookie early "Code verifier required" The PKCE verifier cookie wasn't sent with the callback request. Ensure: Cookie path is / (not /auth ) Cookie domain matches callback domain No third-party cookie blocking Session validation returns 401 Token may have expired (1hr lifetime) — implement refresh logic Service binding may not be configured — check wrangler.toml Cookie domain mismatch — .grove.place cookies only work on subdomains "CORS error" on auth API calls Add your domain to allowed_origins in the Heartwood clients table. Anti-Patterns Don't do these: Don't store client_secret in wrangler.toml or source code Don't skip PKCE — it's required, not optional Don't reuse state or verifier values across login attempts Don't store tokens in localStorage (XSS-vulnerable) Don't skip state validation in the callback (enables CSRF) Don't hard-code auth-api.grove.place — use the env var for flexibility Don't forget localhost in redirect_uris (you'll need it for dev) Authentication should be invisible until it's needed. Let Heartwood handle the complexity.