Heartwood Auth Integration Skill
When to Activate
Activate this skill when:
-
Adding authentication to a Grove application
-
Protecting admin routes
-
Validating user sessions
-
Setting up OAuth sign-in
-
Integrating with Heartwood (GroveAuth)
Overview
Heartwood is Grove's centralized authentication service powered by Better Auth.
Domain Purpose
heartwood.grove.place
Frontend (login UI)
auth-api.grove.place
Backend API
Key Features
-
OAuth Providers: Google
-
Magic Links: Click-to-login emails via Resend
-
Passkeys: WebAuthn passwordless authentication
-
KV-Cached Sessions: Sub-100ms validation
-
Cross-Subdomain SSO: Single session across all .grove.place
Integration Approaches
Option A: Better Auth Client (Recommended)
For new integrations, use Better Auth's client library:
// src/lib/auth/client.ts import { createAuthClient } from "better-auth/client";
export const auth = createAuthClient({ baseURL: "https://auth-api.grove.place", });
// Sign in with Google await auth.signIn.social({ provider: "google" });
// Get current session const session = await auth.getSession();
// Sign out await auth.signOut();
Option B: Cookie-Based SSO (*.grove.place apps)
For apps on .grove.place subdomains, sessions work automatically via cookies:
// src/hooks.server.ts import type { Handle } from "@sveltejs/kit";
export const handle: Handle = async ({ event, resolve }) => { // Check session via Heartwood API const sessionCookie = event.cookies.get("better-auth.session_token");
if (sessionCookie) {
try {
const response = await fetch("https://auth-api.grove.place/api/auth/session", {
headers: {
Cookie: `better-auth.session_token=${sessionCookie}`,
},
});
if (response.ok) {
const data = await response.json();
event.locals.user = data.user;
event.locals.session = data.session;
}
} catch {
// Session invalid, expired, or network error — silently continue
}
}
return resolve(event);
};
Option C: Legacy Token Flow (Backwards Compatible)
For existing integrations using the legacy OAuth flow:
// 1. Redirect to Heartwood login
const params = new URLSearchParams({
client_id: "your-client-id",
redirect_uri: "https://yourapp.grove.place/auth/callback",
state: crypto.randomUUID(),
code_challenge: await generateCodeChallenge(verifier),
code_challenge_method: "S256",
});
redirect(302, https://auth-api.grove.place/login?${params});
// 2. Exchange code for tokens (in callback route) const tokens = await fetch("https://auth-api.grove.place/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: code, redirect_uri: "https://yourapp.grove.place/auth/callback", client_id: "your-client-id", client_secret: env.HEARTWOOD_CLIENT_SECRET, code_verifier: verifier, }), }).then((r) => r.json());
// 3. Verify token on protected routes
const user = await fetch("https://auth-api.grove.place/verify", {
headers: { Authorization: Bearer ${tokens.access_token} },
}).then((r) => r.json());
Protected Routes Pattern
SvelteKit Layout Protection
// 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) { throw redirect(302, "/auth/login"); }
return {
user: locals.user,
};
};
API Route Protection
// src/routes/api/protected/+server.ts import { json, error } from "@sveltejs/kit"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => { if (!locals.user) { throw error(401, "Unauthorized"); }
return json({ message: "Protected data", user: locals.user });
};
Session Validation
Via Better Auth Session Endpoint
async function validateSession(sessionToken: string) {
const response = await fetch("https://auth-api.grove.place/api/auth/session", {
headers: {
Cookie: better-auth.session_token=${sessionToken},
},
});
if (!response.ok) return null;
const data = await response.json();
return data.session ? data : null;
}
Via Legacy Verify Endpoint
async function validateToken(accessToken: string) {
const response = await fetch("https://auth-api.grove.place/verify", {
headers: {
Authorization: Bearer ${accessToken},
},
});
const data = await response.json();
return data.active ? data : null;
}
Client Registration
To integrate a new app with Heartwood, you need to register it as a client.
- Generate Client Credentials
Generate a secure client secret
openssl rand -base64 32
Example: YKzJChC3RPjZvd1f/OD5zUGAvcouOTXG7maQP1ernCg=
Hash it for storage (base64url encoding)
echo -n "YOUR_SECRET" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '='
- Register in Heartwood Database
INSERT INTO clients (id, name, client_id, client_secret_hash, redirect_uris, allowed_origins) VALUES ( lower(hex(randomblob(16))), 'Your App Name', 'your-app-id', 'BASE64URL_HASHED_SECRET', '["https://yourapp.grove.place/auth/callback"]', '["https://yourapp.grove.place"]' );
- Set Secrets on Your App
Set the client secret on your app
wrangler secret put HEARTWOOD_CLIENT_SECRET
Paste: YKzJChC3RPjZvd1f/OD5zUGAvcouOTXG7maQP1ernCg=
Environment Variables
Variable Description
HEARTWOOD_CLIENT_ID
Your registered client ID
HEARTWOOD_CLIENT_SECRET
Your client secret (never commit!)
API Endpoints Reference
Better Auth Endpoints (Recommended)
Method Endpoint Purpose
POST /api/auth/sign-in/social
OAuth sign-in
POST /api/auth/sign-in/magic-link
Magic link sign-in
POST /api/auth/sign-in/passkey
Passkey sign-in
GET /api/auth/session
Get current session
POST /api/auth/sign-out
Sign out
Legacy Endpoints
Method Endpoint Purpose
GET /login
Login page
POST /token
Exchange code for tokens
GET /verify
Validate access token
GET /userinfo
Get user info
Best Practices
DO
-
Use Better Auth client for new integrations
-
Validate sessions on every protected request
-
Use httpOnly cookies for token storage
-
Implement proper error handling for auth failures
-
Log out users gracefully when sessions expire
DON'T
-
Store tokens in localStorage (XSS vulnerable)
-
Skip session validation on API routes
-
Hardcode client secrets
-
Ignore token expiration
Type-Safe Error Handling
Use Rootwork type guards in catch blocks instead of manual error type narrowing. Import from @autumnsgrove/lattice/server :
import { isRedirect, isHttpError } from "@autumnsgrove/lattice/server";
try {
// ... auth flow
} catch (err) {
if (isRedirect(err)) throw err; // Re-throw SvelteKit redirects
if (isHttpError(err)) {
// Handle HTTP errors with proper status code
console.error(Auth failed: ${err.status} ${err.body});
}
// Fallback error handling
}
Reading session data from KV/cache: Use safeJsonParse() for type-safe deserialization:
import { safeJsonParse } from "@autumnsgrove/lattice/server"; import { z } from "zod";
const sessionSchema = z.object({ userId: z.string(), email: z.string().email(), });
const rawSession = await kv.get("session:123"); const session = safeJsonParse(rawSession, sessionSchema);
if (session) { event.locals.user = { id: session.userId, email: session.email }; }
Cross-Subdomain SSO
All .grove.place apps share the same session cookie automatically:
better-auth.session_token (domain=.grove.place)
Once a user signs in on any Grove property, they're signed in everywhere.
Troubleshooting
"Session not found" errors
-
Check cookie domain is .grove.place
-
Verify SESSION_KV namespace is accessible
-
Check session hasn't expired
OAuth callback errors
-
Verify redirect_uri matches registered client
-
Check client_id is correct
-
Ensure client_secret_hash uses base64url encoding
Slow authentication
-
Ensure KV caching is enabled (SESSION_KV binding)
-
Check for cold start issues (Workers may sleep)
Error Codes (HW-AUTH Catalog)
Heartwood has its own Signpost error catalog with 16 codes:
import { AUTH_ERRORS, getAuthError, logAuthError, buildErrorParams, } from "@autumnsgrove/lattice/heartwood";
Key error codes:
Code Key When
HW-AUTH-001
ACCESS_DENIED
User lacks permission
HW-AUTH-002
PROVIDER_ERROR
OAuth provider failed
HW-AUTH-004
REDIRECT_URI_MISMATCH
Callback URL doesn't match registered client
HW-AUTH-020
NO_SESSION
No session cookie found
HW-AUTH-021
SESSION_EXPIRED
Session timed out
HW-AUTH-022
INVALID_TOKEN
Token verification failed
HW-AUTH-023
TOKEN_EXCHANGE_FAILED
Code-for-token exchange failed
Mapping OAuth errors to Signpost codes:
// In callback handler — map OAuth error param to structured error
const authError = getAuthError(errorParam); // e.g. "access_denied" → AUTH_ERRORS.ACCESS_DENIED
logAuthError(authError, { path: "/auth/callback", ip });
redirect(302, /login?${buildErrorParams(authError)});
Number ranges: 001-019 infrastructure, 020-039 session/token, 040+ reserved.
See AgentUsage/error_handling.md for the full Signpost reference.
Related Resources
-
Heartwood Spec: /Users/autumn/Documents/Projects/GroveAuth/GROVEAUTH_SPEC.md
-
Better Auth Docs: https://better-auth.com
-
Client Setup Guide: /Users/autumn/Documents/Projects/GroveAuth/docs/OAUTH_CLIENT_SETUP.md