xiaohongshu
xiaohongshu
grove-auth-integration
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.
Full skill instructions
Add Heartwood authentication to a Grove property — from client registration through production deployment.
/grove-auth-integration| 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 |
Identify → Register Client → Configure Secrets → Write Code → Wire Wrangler → Test
Ask the user (or determine from context):
grove-plant, grove-domains, arbor-admin)https://plant.grove.place)/auth/callbackCLIENT_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.
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 |
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.
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
cd worker-directory
echo "CLIENT_ID" | wrangler secret put GROVEAUTH_CLIENT_ID
echo "CLIENT_SECRET" | wrangler secret put GROVEAUTH_CLIENT_SECRET
Create these files in the SvelteKit project:
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());
};
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 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) {
if (err && typeof err === "object" && "status" in err && err.status === 302) {
throw err; // Re-throw redirects
}
redirect(302, "/?error=auth_failed");
}
};
src/hooks.server.tsChoose 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);
};
src/app.d.tsdeclare 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 {};
src/routes/admin/+layout.server.tsimport { 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 };
};
src/routes/auth/logout/+server.tsimport { 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, "/");
};
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
pnpm dev)/auth — should redirect to Heartwood login/auth/callback/admin (protected) — should show authenticated content/auth/logout — should clear session| 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 |
| 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.
Before going live, verify:
clients table)redirect_uris includes BOTH production AND localhostallowed_origins includes BOTH production AND localhostwrangler pages secret put (NOT in wrangler.toml!)locals.user and redirect if missingsecure flag is true in production, false in localhostAlmost always a hash format mismatch. Regenerate with the exact base64url command in Step 2b.
The callback URL must EXACTLY match what's in the redirect_uris JSON array in the database. Check trailing slashes, protocol, and port.
The state cookie expired (10min TTL) or was lost. Check:
The PKCE verifier cookie wasn't sent with the callback request. Ensure:
path is / (not /auth).grove.place cookies only work on subdomainsAdd your domain to allowed_origins in the Heartwood clients table.
Don't do these:
client_secret in wrangler.toml or source codeauth-api.grove.place — use the env var for flexibilityAuthentication should be invisible until it's needed. Let Heartwood handle the complexity.
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience