关于 grove-auth-integration
这项分步技能指导开发者将 Heartwood 身份验证接入 Grove 项目,涵盖 OAuth 客户端注册、密钥、应用代码、部署配置和测试。适用于需要为 Grove 项目添加登录、会话和受保护路由的开发者。
主要功能
- 01OAuth 客户端注册指南
- 02密钥与哈希配置
- 03SvelteKit 登录和回调路由
- 04令牌或会话验证方案
- 05受保护路由与注销设置
- 06部署及流程测试清单
适用场景
- 01为 Grove 项目添加 Heartwood 登录
- 02注册 OAuth 客户端并配置回调 URL
- 03为受保护页面配置会话检查
- 04测试身份验证和注销流程
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ autumnsgrove/ lattice --skill grove-auth-integration安装后在 Claude Code 中输入/grove-auth-integration确认已加载。
详细介绍
Grove Auth 集成
为 Grove 项目添加 Heartwood 身份验证,从客户端注册到生产部署。
何时启用
- 用户说“为此项目添加身份验证”或“接入 Heartwood”
- 用户正在构建一个需要登录的新 Grove 项目
- 用户需要向 Heartwood 注册新的 OAuth 客户端
- 用户明确调用
/grove-auth-integration - 用户提到需要登录、受保护路由或会话验证
- 用户说“集成 GroveAuth”或“添加登录”
关键 URL
| 服务 | URL | 用途 |
|---|---|---|
| 登录界面 | https://heartwood.grove.place | 用户进行身份验证的位置 |
| API | https://auth-api.grove.place | 令牌交换、验证、会话 |
| D1 数据库 | groveauth(通过 wrangler) | 客户端注册 |
流程
Identify → Register Client → Configure Secrets → Write Code → Wire Wrangler → Test
身份验证流程中的错误处理:
身份验证错误必须使用 AUTH_ERRORS Signpost 目录,绝不能使用裸重定向和临时错误字符串。
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)}`);
}
完整的 Signpost 参考请参阅 AgentUsage/error_handling.md。
Catch 代码块中的类型安全错误处理:
在 catch 代码块中,始终使用 Rootwork 类型守卫,而不是手动检查属性。从 @autumnsgrove/lattice/server 导入 isRedirect() 和 isHttpError():
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");
}
步骤 1:确定集成信息
询问用户(或根据上下文确定):
- 项目名称 — Cloudflare Pages/Workers 项目名称
- 客户端 ID — 简单的 slug(例如
grove-plant、grove-domains、arbor-admin) - 站点 URL — 生产环境 URL(例如
https://plant.grove.place) - 回调路径 — 通常为
/auth/callback - 项目类型 — SvelteKit Pages(最常见)、Workers 或其他
- 会话方案 — OAuth 令牌(标准方案)或 SessionDO(速度更快,但仅限同一账户)
步骤 2:注册 OAuth 客户端
2a. 生成客户端密钥
CLIENT_SECRET=$(openssl rand -base64 32)
echo "Client Secret: $CLIENT_SECRET"
保存此值——客户端密钥和数据库哈希都需要用到它。
2b. 生成 base64url 哈希
重要: Heartwood 使用 base64url 编码——使用连字符(-)和下划线(_),不带填充符(=)。
CLIENT_SECRET_HASH=$(echo -n "$CLIENT_SECRET" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=')
echo "Secret Hash: $CLIENT_SECRET_HASH"
| 格式 | 示例 | 正确? |
|---|---|---|
| base64url | Sdgtaokie8-H7GKw-tn0S_6XNSh1rdv | 是 |
| base64 | Sdgtaokie8+H7GKw+tn0S/6XNSh1rdv= | 否 |
| hex | 49d82d6a89227bcf87ec62b0... | 否 |
2c. 插入 Heartwood 数据库
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;
"
开发时,重定向 URI 和允许的来源中始终包含 localhost。
步骤 3:在客户端配置密钥
对于 Pages 项目(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
对于 Workers 项目:
cd worker-directory
echo "CLIENT_ID" | wrangler secret put GROVEAUTH_CLIENT_ID
echo "CLIENT_SECRET" | wrangler secret put GROVEAUTH_CLIENT_SECRET
步骤 4:编写身份验证代码(SvelteKit)
在 SvelteKit 项目中创建以下文件:
4a. 登录发起路由: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. 回调处理程序: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. 会话验证钩子:src/hooks.server.ts
选择一种方案:
方案 A:令牌验证(支持跨账户)
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);
};
方案 B:SessionDO 验证(速度更快,但仅限同一 Cloudflare 账户)
需要在 wrangler.toml 中配置服务绑定(参见步骤 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. 类型定义: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. 受保护路由布局: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. 注销路由: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, "/");
};
步骤 5:配置 wrangler.toml
将以下内容添加到项目的 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
第 6 步:测试流程
- 部署项目(或使用
pnpm dev在本地运行) - 访问
/auth— 应重定向到 Heartwood 登录页面 - 使用 Google(或其他已配置的提供方)进行身份验证
- 应重定向回
/auth/callback - 应设置 Cookie 并重定向到受保护区域
- 访问
/admin(受保护)— 应显示已通过身份验证的内容 - 通过向
/auth/logout发送 POST 请求退出登录 — 应清除会话
令牌生命周期
| 令牌 | 有效期 | 存储方式 | 刷新方式 |
|---|---|---|---|
| 访问令牌 | 1 小时 | httpOnly Cookie | 通过刷新令牌 |
| 刷新令牌 | 30 天 | httpOnly Cookie | 需要重新登录 |
| PKCE 验证器 | 10 分钟 | httpOnly Cookie | 单次使用 |
| State | 10 分钟 | httpOnly Cookie | 单次使用 |
会话验证方式
| 方式 | 速度 | 要求 | 适用场景 |
|---|---|---|---|
令牌验证 (/verify) | ~50-150ms | 网络调用 | 跨账户、外部服务 |
SessionDO (/session/validate) | ~5-20ms | 服务绑定 | 同一 Cloudflare 账户(推荐) |
Cookie SSO (.grove.place) | ~0ms(本地) | 同一域名 | 所有 *.grove.place 属性 |
对于 .grove.place 子域名上的 Grove 属性,grove_session Cookie 会自动在所有子域名之间共享。
检查清单
上线前,请确认:
- 客户端已在 Heartwood D1 中注册(
clients表) - 密钥哈希使用 base64url 编码(连字符、下划线、无填充)
-
redirect_uris同时包含生产环境和 localhost -
allowed_origins同时包含生产环境和 localhost - 密钥通过
wrangler pages secret put设置(不要放在 wrangler.toml 中!) - PKCE 流程在每次登录尝试时都会生成新的验证器
- 在回调中验证 State 参数(防止 CSRF)
- 代码交换后立即清除身份验证 Cookie
- 受保护的路由会检查
locals.user,缺失时重定向 - 退出登录时会在 Heartwood 撤销令牌,并清除本地 Cookie
- 生产环境中的 Cookie
secure标志为 true,localhost 中为 false
故障排查
“Invalid client credentials”(401)
几乎总是哈希格式不匹配。请使用第 2b 步中的准确 base64url 命令重新生成。
“Invalid redirect_uri”
回调 URL 必须与数据库中 redirect_uris JSON 数组内的内容完全一致。检查尾随斜杠、协议和端口。
回调时出现 “Invalid state”
State Cookie 已过期(TTL 为 10 分钟)或丢失。请检查:
- Cookie 域名与回调域名一致
- 域名之间没有消耗 Cookie 的重定向循环
“Code verifier required”
PKCE 验证器 Cookie 未随回调请求发送。请确保:
- Cookie
path为/(而不是/auth) - Cookie 域名与回调域名一致
- 没有第三方 Cookie 拦截
会话验证返回 401
- 令牌可能已过期(有效期为 1 小时)— 请实现刷新逻辑
- 可能未配置服务绑定 — 请检查 wrangler.toml
- Cookie 域名不匹配 —
.grove.placeCookie 只能在子域名上使用
身份验证 API 调用出现 “CORS error”
请将你的域名添加到 Heartwood clients 表的 allowed_origins 中。
反模式
请勿执行以下操作:
- 请勿将
client_secret存储在 wrangler.toml 或源代码中 - 请勿跳过 PKCE——它是必需的,不是可选项
- 请勿在多次登录尝试中重复使用 state 或 verifier 值
- 请勿将令牌存储在 localStorage 中(容易受到 XSS 攻击)
- 请勿在回调中跳过 state 验证(这会导致 CSRF)
- 请勿硬编码
auth-api.grove.place——请使用环境变量以提高灵活性 - 请勿忘记在 redirect_uris 中添加 localhost(开发时需要)
身份验证应当在需要之前始终不可见。让 Heartwood 处理复杂部分。