mcpField
G
grove-auth-integration

grove-auth-integration

github.com/autumnsgrove安全663 次安装4 天前更新
代码仓库

关于 grove-auth-integration

这项分步技能指导开发者将 Heartwood 身份验证接入 Grove 项目,涵盖 OAuth 客户端注册、密钥、应用代码、部署配置和测试。适用于需要为 Grove 项目添加登录、会话和受保护路由的开发者。

主要功能

  1. 01OAuth 客户端注册指南
  2. 02密钥与哈希配置
  3. 03SvelteKit 登录和回调路由
  4. 04令牌或会话验证方案
  5. 05受保护路由与注销设置
  6. 06部署及流程测试清单

适用场景

  1. 01为 Grove 项目添加 Heartwood 登录
  2. 02注册 OAuth 客户端并配置回调 URL
  3. 03为受保护页面配置会话检查
  4. 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用户进行身份验证的位置
APIhttps://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:确定集成信息

询问用户(或根据上下文确定):

  1. 项目名称 — Cloudflare Pages/Workers 项目名称
  2. 客户端 ID — 简单的 slug(例如 grove-plant、grove-domains、arbor-admin)
  3. 站点 URL — 生产环境 URL(例如 https://plant.grove.place)
  4. 回调路径 — 通常为 /auth/callback
  5. 项目类型 — SvelteKit Pages(最常见)、Workers 或其他
  6. 会话方案 — 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"
格式示例正确?
base64urlSdgtaokie8-H7GKw-tn0S_6XNSh1rdv是
base64Sdgtaokie8+H7GKw+tn0S/6XNSh1rdv=否
hex49d82d6a89227bcf87ec62b0...否
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 步:测试流程
  1. 部署项目(或使用 pnpm dev 在本地运行)
  2. 访问 /auth — 应重定向到 Heartwood 登录页面
  3. 使用 Google(或其他已配置的提供方)进行身份验证
  4. 应重定向回 /auth/callback
  5. 应设置 Cookie 并重定向到受保护区域
  6. 访问 /admin(受保护)— 应显示已通过身份验证的内容
  7. 通过向 /auth/logout 发送 POST 请求退出登录 — 应清除会话

令牌生命周期

令牌有效期存储方式刷新方式
访问令牌1 小时httpOnly Cookie通过刷新令牌
刷新令牌30 天httpOnly Cookie需要重新登录
PKCE 验证器10 分钟httpOnly Cookie单次使用
State10 分钟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.place Cookie 只能在子域名上使用
身份验证 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 处理复杂部分。

评论

支持 Markdown