- name
- grove-auth-integration
- description
- Integrate Heartwood authentication into a new or existing Grove property. Covers client registration, PKCE OAuth flow, SvelteKit route setup, session validation, and wrangler configuration. Use when adding auth to any Grove site.
# 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.
```typescript
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`:
```typescript
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):
1. **Project name** — The Cloudflare Pages/Workers project name
2. **Client ID** — A simple slug (e.g., `grove-plant`, `grove-domains`, `arbor-admin`)
3. **Site URL** — Production URL (e.g., `https://plant.grove.place`)
4. **Callback path** — Usually `/auth/callback`
5. **Project type** — SvelteKit Pages (most common), Workers, or other
6. **Session approach** — OAuth tokens (standard) or SessionDO (faster, same-account only)
---
### Step 2: Register the OAuth Client
#### 2a. Generate client secret
```bash
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 (`=`).
```bash
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
```bash
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):
```bash
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:
```bash
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`
```typescript
/**
* 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`
```typescript
/**
* 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
GitHub에서 보기