重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
grove-auth-integration by autumnsgrove/groveengine
npx skills add https://github.com/autumnsgrove/groveengine --skill grove-auth-integration为 Grove 项目添加 Heartwood 身份验证——从客户端注册到生产部署。
/grove-auth-integration| 服务 | URL | 用途 |
|---|---|---|
| 登录界面 | https://heartwood.grove.place | 用户进行身份验证的地方 |
| API | https://auth-api.grove.place | 令牌交换、验证、会话 |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
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";
// 在回调中——将 OAuth 错误映射到结构化代码
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 {
// ... 身份验证流程代码
} catch (err) {
if (isRedirect(err)) throw err; // 重新抛出 SvelteKit 重定向
if (isHttpError(err)) {
// 使用正确的状态和消息处理 HTTP 错误
}
redirect(302, "/?error=auth_failed");
}
询问用户(或根据上下文确定):
grove-plant、grove-domains、arbor-admin)https://plant.grove.place)/auth/callbackCLIENT_SECRET=$(openssl rand -base64 32)
echo "Client Secret: $CLIENT_SECRET"
保存这个值——你将在客户端密钥和数据库哈希中都需要它。
关键: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... | 否 |
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;
"
始终在 redirect_uris 和 allowed_origins 中包含 localhost 以便开发。
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
在 SvelteKit 项目中创建以下文件:
src/routes/auth/+server.ts/**
* OAuth 发起 - 启动 Heartwood OAuth 流程
* 重定向到带有 PKCE 参数的 GroveAuth。
*/
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 分钟
};
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 回调 - 处理 Heartwood OAuth 响应
* 将授权码交换为令牌并创建会话。
*/
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)}`);
}
// 验证 state (CSRF 保护)
const savedState = cookies.get("auth_state");
if (!state || state !== savedState) {
redirect(302, "/?error=invalid_state");
}
// 获取 PKCE verifier
const codeVerifier = cookies.get("auth_code_verifier");
if (!codeVerifier || !code) {
redirect(302, "/?error=missing_credentials");
}
// 立即清除身份验证 cookie
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 {
// 将 code 交换为令牌
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;
};
// 获取用户信息
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");
}
// 设置会话 cookie
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 天
};
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 天
});
}
// TODO: 根据需要创建本地会话或入职记录
// 这因项目而异——根据你的应用需求进行调整
redirect(302, "/dashboard"); // 或重定向到认证用户应去的地方
} catch (err) {
// 使用 Rootwork 类型守卫进行类型安全错误处理
if (isRedirect(err)) throw err; // 重新抛出 SvelteKit 重定向
redirect(302, "/?error=auth_failed");
}
};
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 {
// 令牌无效或过期——用户保持未认证状态
}
}
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") || "";
// 仅当存在 grove_session 或 access_token cookie 时进行验证
if (cookieHeader.includes("grove_session") || cookieHeader.includes("access_token")) {
try {
const env = event.platform?.env as any;
// 使用服务绑定进行亚 100 毫秒的验证
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 {
// 会话无效——用户保持未认证状态
}
}
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; // 服务绑定(仅限选项 B)
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";
// 在 Heartwood 撤销令牌
await fetch(`${authBaseUrl}/logout`, {
method: "POST",
headers: { Authorization: `Bearer ${accessToken}` },
}).catch(() => {}); // 尽力而为
}
// 清除所有身份验证 cookie
cookies.delete("access_token", { path: "/" });
cookies.delete("refresh_token", { path: "/" });
redirect(302, "/");
};
将这些添加到项目的 wrangler.toml:
# Heartwood (GroveAuth) - 用于快速会话验证的服务绑定
# 仅适用于同一 Cloudflare 账户中的项目
[[services]]
binding = "AUTH"
service = "groveauth"
# 密钥(通过 wrangler pages secret / Cloudflare 仪表板配置):
# - GROVEAUTH_URL = https://auth-api.grove.place
# - GROVEAUTH_CLIENT_ID = your-client-id
# - GROVEAUTH_CLIENT_SECRET = (在步骤 2 中生成)
# - GROVEAUTH_REDIRECT_URI = https://yoursite.com/auth/callback
# - PUBLIC_APP_URL = https://yoursite.com
pnpm dev 本地运行)/auth —— 应重定向到 Heartwood 登录/auth/callback/admin(受保护)—— 应显示已认证的内容/auth/logout 注销 —— 应清除会话| 令牌 | 生命周期 | 存储 | 刷新 |
|---|---|---|---|
| 访问令牌 | 1 小时 | httpOnly cookie | 通过刷新令牌 |
| 刷新令牌 | 30 天 | httpOnly cookie | 需要重新登录 |
| PKCE Verifier | 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 会自动在所有子域之间共享。
上线前,验证:
clients 表) 中注册redirect_uris 包含生产环境 和 localhostallowed_origins 包含生产环境 和 localhostwrangler pages secret put 设置(不在 wrangler.toml 中!)locals.user 并在缺失时重定向secure 标志在生产环境中为 true,在 localhost 中为 false几乎总是 哈希格式不匹配。使用步骤 2b 中的确切 base64url 命令重新生成。
回调 URL 必须与数据库中 redirect_uris JSON 数组中的内容 完全匹配。检查尾部斜杠、协议和端口。
state cookie 已过期(10 分钟 TTL)或丢失。检查:
PKCE verifier cookie 未随回调请求发送。确保:
path 是 /(不是 /auth).grove.place cookie 仅适用于子域将你的域名添加到 Heartwood clients 表的 allowed_origins 中。
不要做这些:
client_secret 存储在 wrangler.toml 或源代码中auth-api.grove.place —— 使用环境变量以获得灵活性身份验证应该在需要之前是不可见的。让 Heartwood 处理复杂性。
每周安装
46
仓库
GitHub 星标
2
首次出现
2026年2月5日
安全审计
安装于
opencode46
gemini-cli46
codex46
codebuddy45
github-copilot45
amp45
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
Error Handling in Auth Flows: Auth errors MUST use the AUTH_ERRORS Signpost catalog — never bare redirect with ad-hoc error strings.
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:
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");
}
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 { 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");
}
};
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.
Weekly Installs
46
Repository
GitHub Stars
2
First Seen
Feb 5, 2026
Security Audits
Gen Agent Trust HubFailSocketPassSnykPass
Installed on
opencode46
gemini-cli46
codex46
codebuddy45
github-copilot45
amp45
飞书视频会议CLI工具:lark-vc技能详解,高效搜索与管理会议记录与纪要
43,000 周安装
Go内存缓存库samber/hot使用指南:9种淘汰算法、TTL与Prometheus监控
714 周安装
UX文案助手:AI驱动用户体验文案撰写与评审工具,提升界面文案质量
695 周安装
OpenClaw voice-call 插件:集成 Twilio/Telnyx/Plivo 实现智能语音通话
687 周安装
AlarmKit iOS闹钟框架:在锁屏、灵动岛和Apple Watch上创建醒目闹钟与计时器
697 周安装
企业客户规划指南:战略销售、MEDDICC资质审查与共同行动计划(MAP)框架
721 周安装
OpenCLI 网页自动化工具:复用 Chrome 会话,将网站转为命令行界面
696 周安装