grove-auth-integration について
Heartwood認証をGroveプロジェクトに組み込む手順を案内するスキルです。OAuthクライアント登録、シークレット、アプリコード、デプロイ設定、テストを扱い、ログインやセッション、保護されたルートを追加する開発者向けです。
主な機能
- 01OAuthクライアント登録の手順
- 02シークレットとハッシュの設定
- 03SvelteKitのログイン・コールバックルート
- 04トークンまたはセッション検証の選択肢
- 05保護ルートとログアウトの設定
- 06デプロイと認証フローのテスト項目
活用シーン
- 01GroveプロジェクトへのHeartwoodログイン追加
- 02OAuthクライアントとコールバックURLの登録
- 03保護ページのセッション確認設定
- 04認証とログアウトのフロー検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ autumnsgrove/ lattice --skill grove-auth-integrationインストール後、Claude Code で/grove-auth-integrationを入力して読み込まれたか確認します。
詳細
Grove 認証の統合
クライアント登録から本番環境へのデプロイまで、Grove のプロパティに Heartwood 認証を追加します。
有効にするタイミング
- ユーザーが「このプロジェクトに認証を追加して」または「Heartwood を接続して」と言った場合
- ログインが必要な新しい Grove プロパティをユーザーが構築している場合
- Heartwood に新しい OAuth クライアントを登録する必要がある場合
- ユーザーが
/grove-auth-integrationを明示的に呼び出した場合 - サインイン、保護されたルート、またはセッション検証が必要だと言った場合
- ユーザーが「GroveAuth を統合して」または「ログインを追加して」と言った場合
主な URL
| サービス | URL | 用途 |
|---|---|---|
| ログイン UI | 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 — 簡単なスラッグ(例:
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;
"
開発用に、redirect_uris と allowed_origins には必ず 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
次のいずれか 1 つを選択します。
オプション 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 Verifier | 10 分 | httpOnly cookie | 1 回限り |
| State | 10 分 | httpOnly cookie | 1 回限り |
セッション検証の方法
| 方法 | 速度 | 要件 | 使用する状況 |
|---|---|---|---|
トークン検証 (/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 フローでログイン試行ごとに新しい verifier を生成している
- コールバックで 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 配列に記載された URL と完全に一致している必要があります。末尾のスラッシュ、プロトコル、ポートを確認してください。
コールバックで「Invalid state」
State Cookie の有効期限(10 分の TTL)が切れたか、失われています。次の項目を確認してください。
- Cookie のドメインがコールバックのドメインと一致している
- ドメイン間のリダイレクトループによって Cookie が早期に消費されていない
「Code verifier required」
PKCE verifier 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 を省略しないでください。任意ではなく必須です
- ログインを試行するたびに、状態値や検証値を再利用しないでください
- トークンを localStorage に保存しないでください(XSS の脆弱性があります)
- コールバックでの状態検証を省略しないでください(CSRF が可能になります)
auth-api.grove.placeをハードコードしないでください。柔軟性を保つため、環境変数を使用してください- redirect_uris に localhost を忘れずに追加してください(開発時に必要です)
認証は必要になるまで意識されないものであるべきです。複雑な処理は Heartwood に任せましょう。