mcpField
A
auth-server-primitives

auth-server-primitives

github.com/tanstackセキュリティ15.3k192 回インストール4 日前に更新
リポジトリ

auth-server-primitives について

TanStack Start のサーバー側認証を扱う開発者向けスキルです。セッション Cookie、OAuth、CSRF 対策、レート制限を通じて、関数やルート、アカウント処理を保護します。

主な機能

  1. 01セッション Cookie の処理
  2. 02認証ミドルウェア
  3. 03OAuth state と PKCE
  4. 04CSRF 対策
  5. 05レート制限とセッションローテーション

活用シーン

  1. 01サーバー関数の非公開データを保護
  2. 02ログインとログアウトの実装
  3. 03パスワードリセットのエンドポイントを強化
  4. 04認証試行を制限

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tanstack/router --skill auth-server-primitives

インストール後、Claude Code で/auth-server-primitivesを入力して読み込まれたか確認します。

詳細

認証サーバーの基本要素

このスキルでは、認証のサーバー側、つまりセッションストレージ、Cookie の発行、OAuth フロー、パスワードリセットの強化、CSRF、レート制限について説明します。ルーティング側(_authenticated レイアウト、beforeLoad リダイレクト、RBAC チェック)については、@tanstack/router-core#router-core/auth-and-guards を参照してください。

重大:まずデータ/API 境界を保護してください。非公開データを扱うサーバー関数、サーバールート、その他の API エンドポイントでは、ハンドラーまたはミドルウェア内で認証を強制する必要があります。ルートガードはルートの UX を担うものであり、データのセキュリティ境界ではありません。 重大:クライアントから渡された識別子の_形式_を検証しても(z.string().uuid().parse(...))、認可にはなりません。UUID の形式が正しくても、それは依然として何らかのテナントを指しています。使用する前に、セッションのプリンシパルに対するメンバーシップを再確認してください。 重大:セッション/Cookie の読み取りは、モジュールスコープではなく、.handler() またはミドルウェアの .server() 内で行ってください。モジュールレベルでの読み取りは、リクエストが存在する前に実行されます(また、Cloudflare Workers では未定義になります)。

本番環境チェックリスト

  • 非公開のユーザー、テナント、アカウントのデータを読み書きするすべてのサーバー関数、サーバールート、API エンドポイントで認証を強制してください。ページの UX にはルートの beforeLoad を使い、データ境界として使わないでください。
  • 入力を受け取るすべてのサーバー関数で .validator() を使用してください。
  • セッションは HttpOnly、Secure、SameSite Cookie に保存してください。セッショントークンを localStorage や sessionStorage に保存しないでください。
  • パスワードは bcrypt、scrypt、または Argon2 でハッシュ化してください。ユーザーが存在しない場合はダミーハッシュで検証し、ログイン/パスワードリセットでは同じメッセージを返してください。
  • ログイン、登録、パスワードリセットのエンドポイントにレート制限を設けてください。
  • GET 以外のサーバー関数とサーバールートには、CSRF 対策または同一オリジン保護を適用してください。
  • 認証イベントを記録し、失敗を監視してください。
  • 保護されたサーバー関数を、認証されていない状態で直接呼び出すテストを行ってください。データを返す前に拒否される必要があります。
  • 匿名ユーザーへのリダイレクトに従い、その HTML とシリアライズされた状態を確認してください。ログインページや認証エラーページに、保護対象のユーザー、テナント、レコードの名前が含まれてはいけません。

セッション Cookie

推奨されるセッションストレージは、不透明なセッション ID(サーバー側で検索)または署名/暗号化されたトークンを格納する HTTP 専用 Cookie です。Cookie のフラグは重要なので、すべて設定してください。

// src/server/session.ts
import {
  deleteCookie,
  getCookie,
  setCookie,
} from '@tanstack/react-start/server'

const SESSION_COOKIE = '__Host-session' // __Host- prefix binds to the exact origin + path '/'
const ONE_DAY = 60 * 60 * 24

const sessionCookieOptions = {
  httpOnly: true, // not readable from JS — defeats XSS exfiltration
  secure: true, // HTTPS only (required for __Host- prefix)
  sameSite: 'lax', // sent on top-level navigations, blocks most CSRF
  path: '/', // required for __Host- prefix
} as const

export function setSessionCookie(token: string) {
  setCookie(SESSION_COOKIE, token, {
    ...sessionCookieOptions,
    maxAge: ONE_DAY,
  })
}

export function clearSessionCookie() {
  deleteCookie(SESSION_COOKIE, sessionCookieOptions)
}

export function readSessionToken(): string | null {
  return getCookie(SESSION_COOKIE) ?? null
}

Cookie の書き込みには setCookie を使用してください(ライブラリがすでにシリアライズした Cookie の場合は appendResponseHeader('set-cookie', serialized) を使用します)。この用途に setResponseHeader('Set-Cookie', ...) を使用しないでください。これは他のミドルウェアが設定した Cookie やハンドラーから返された Cookie を含め、送信レスポンスのすべての Cookie を置き換えます。

フラグの理由:

  • HttpOnly — JavaScript から Cookie を読み取れないため、XSS の脆弱性があってもセッションを盗まれません。
  • Secure — HTTPS のみで使用します。__Host- プレフィックスを使用する場合に必須です。
  • SameSite=Lax — クロスオリジンの POST/PUT/DELETE による CSRF を大半の場合でブロックします。クロスサイト GET ナビゲーションが失われても問題ない最高レベルのセキュリティが必要なフローでは、Strict を使用してください。
  • __Host- プレフィックス — Cookie を正確なオリジンに結び付けます(Domain 属性がなく、Path が / で、Secure が設定されている必要があります)。サブドメインの乗っ取りによってセッション Cookie が偽造されるのを防ぎます。
  • Path=/ — __Host- で必須です。
  • Max-Age — 有限の有効期間を設定し、盗まれた Cookie が永久に使われないようにします。サーバー側のセッションローテーションと組み合わせてください。

ミドルウェアによるセッション検索

ミドルウェアを使ってセッションの読み込みを一元化し、保護されたすべてのハンドラーが型付きセッションを参照できるようにしてください。

// src/server/auth-middleware.ts
import { createMiddleware } from '@tanstack/react-start'
import { readSessionToken } from './session'

export const authMiddleware = createMiddleware({ type: 'function' }).server(
  async ({ next }) => {
    const token = readSessionToken()
    const session = token ? await db.sessions.findValid(token) : null
    if (!session) throw new Error('Unauthorized')
    return next({ context: { session } })
  },
)

ログインユーザーを必要とするすべてのサーバー関数に適用します。

import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'

export const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })

ルートガードでは保護できません。 beforeLoad でリダイレクトする createFileRoute('/_authenticated/orders') を設定しても、getMyOrders は保護されません。ユーザーがそのルートにアクセスしたかどうかにかかわらず、RPC は直接呼び出せます。認証が必要なすべてのサーバー関数に authMiddleware を適用するか、.handler() 内で再確認してください。

ログイン時のセッション発行

// src/server/login.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import { setSessionCookie } from './session'

export const login = createServerFn({ method: 'POST' })
  .validator(z.object({ email: z.string().email(), password: z.string() }))
  .handler(async ({ data }) => {
    const user = await db.users.findByEmail(data.email)
    // Always run verifyPasswordHash — even when the user doesn't exist —
    // so the user-not-found branch takes the same time as wrong-password.
    // DUMMY_PASSWORD_HASH is a hash of any throwaway password computed once
    // at startup with the same algorithm/cost as real password hashes.
    const hashToCheck = user?.passwordHash ?? DUMMY_PASSWORD_HASH
    const passwordMatches = await verifyPasswordHash(hashToCheck, data.password)
    const ok = user != null && passwordMatches
    if (!ok) throw new Error('Invalid email or password')

    // ROTATE on privilege change: destroy any existing session, then issue fresh.
    await db.sessions.revokeAllForUser(user.id)
    const token = await db.sessions.create({ userId: user.id })
    setSessionCookie(token)
    return { ok: true }
  })

ログアウト

import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'
import { clearSessionCookie } from '~/server/session'

export const logout = createServerFn({ method: 'POST' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    await db.sessions.revoke(context.session.id)
    clearSessionCookie()
    return { ok: true }
  })

OAuth:state + PKCE

OAuth の認可コードフローでは、使い捨ての state(CSRF 対策)と PKCE 検証子(認可コードの傍受対策)を生成します。両方を、このログイン試行に固有のキーに紐づけた短期間有効な署名付き Cookie に保存します。

// src/server/oauth.functions.ts
import { createServerFn } from '@tanstack/react-start'
import { redirect } from '@tanstack/react-router'
import { setCookie } from '@tanstack/react-start/server'
import crypto from 'node:crypto'

const OAUTH_STATE_COOKIE = '__Host-oauth' // expires fast; one-shot

function base64url(buf: Buffer) {
  return buf
    .toString('base64')
    .replace(/=/g, '')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
}

export const startOAuth = createServerFn({ method: 'GET' }).handler(
  async () => {
    const state = base64url(crypto.randomBytes(32))
    const verifier = base64url(crypto.randomBytes(32))
    const challenge = base64url(
      crypto.createHash('sha256').update(verifier).digest(),
    )

    setCookie(OAUTH_STATE_COOKIE, signed({ state, verifier }), {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      path: '/',
      maxAge: 600,
    })

    throw redirect({
      href:
        `https://provider.example/authorize` +
        `?response_type=code` +
        `&client_id=${process.env.OAUTH_CLIENT_ID}` +
        `&redirect_uri=${encodeURIComponent(process.env.OAUTH_REDIRECT_URI!)}` +
        `&state=${state}` +
        `&code_challenge=${challenge}` +
        `&code_challenge_method=S256`,
    })
  },
)

コールバックハンドラーで、Cookie の state が返された state と一致することを検証し、検証子を使ってコードを交換します。state がない場合や一致しない場合は中止します。このリクエストは startOAuth から発生したものではありません。

パスワードリセット:ユーザー列挙を防ぐ

ユーザーがリセットをリクエストしたとき、レスポンスの形式や応答時間から、メールアドレスが登録済みかどうかがわからないようにします。

import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'

export const requestPasswordReset = createServerFn({ method: 'POST' })
  .validator(z.object({ email: z.string().email() }))
  .handler(async ({ data }) => {
    const user = await db.users.findByEmail(data.email)
    if (user) {
      const token = await db.passwordResets.issue(user.id)
      await sendResetEmail(user.email, token)
    }
    // Always 200, always the same body, regardless of whether the user exists.
    // The user is told to check their inbox; no confirmation either way.
    return { ok: true }
  })

次のことはしないでください。

  • 登録済みの場合は 200、未登録の場合は 404 を返す。
  • 異なるメッセージを使う(「リンクを送信しました」と「アカウントが見つかりません」など)。
  • ユーザーが存在しない場合に処理を省略する(応答時間から測定可能な情報が漏えいします)。

GET 以外の RPC に対する CSRF 対策

セッション Cookie の SameSite=Lax は、ほとんどのクロスサイト CSRF による POST/PUT/DELETE をブロックします。追加の対策が必要なケースは 2 つあります。

  1. 状態を変更するトップレベルの GET ナビゲーション — 絶対に行わないでください。状態変更には必ず POST/PUT/DELETE を使います。
  2. 兄弟サブドメイン上のページからの POST — SameSite=Lax ではブロックされません。ミドルウェアで Origin ヘッダーがアプリのオリジンと一致することを検証します。
import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'

export const csrfMiddleware = createMiddleware().server(async ({ next }) => {
  const request = getRequest()
  if (request.method !== 'GET' && request.method !== 'HEAD') {
    const origin = request.headers.get('origin')
    // Compare the FULL origin (scheme + host + port) — host alone lets
    // http://example.com pass a check meant for https://example.com.
    if (!origin || new URL(origin).origin !== process.env.APP_ORIGIN) {
      throw new Error('Origin check failed')
    }
  }
  return next()
})

これを src/start.ts のグローバルリクエストミドルウェアに追加し、サーバールートや SSR を含む、GET 以外のすべてのリクエストに適用します。

認証エンドポイントのレート制限

レート制限のないログインエンドポイントは、クレデンシャルスタッフィングの標的になります。スライディングウィンドウ方式で IP ごと(理想的にはアカウントごとにも)に制限します。

import { createMiddleware } from '@tanstack/react-start'
import { getRequest } from '@tanstack/react-start/server'

function rateLimitMiddleware(opts: {
  key: string
  max: number
  windowMs: number
}) {
  return createMiddleware().server(async ({ next }) => {
    const request = getRequest()
    const ip =
      request.headers.get('cf-connecting-ip') ??
      request.headers.get('x-forwarded-for')?.split(',')[0] ??
      'unknown'
    const bucketKey = `rl:${opts.key}:${ip}`
    const allowed = await rateLimiter.consume(
      bucketKey,
      opts.max,
      opts.windowMs,
    )
    if (!allowed) throw new Error('Too many requests')
    return next()
  })
}

// On the login server function:
export const login = createServerFn({ method: 'POST' }).middleware([
  rateLimitMiddleware({ key: 'login', max: 5, windowMs: 60_000 }),
])
// ...

権限変更時のセッションローテーション

ユーザーの権限が変更されるたびに(ログイン、ログアウト、ロール変更、パスワード変更)、古いセッションを破棄して新しいセッションを発行します。これにより、攻撃者がログイン前に被害者のブラウザーへ自分のセッション ID を仕込む、セッション固定攻撃を無効化できます。

// In the login handler (already shown above): destroy any pre-login session, then create a fresh one.
await db.sessions.revokeAllForUser(user.id)
const token = await db.sessions.create({ userId: user.id })
setSessionCookie(token)
// On password change / role grant:
await db.sessions.revokeAllForUser(user.id) // destroy existing
const token = await db.sessions.create({ userId: user.id }) // issue fresh
setSessionCookie(token)

よくある間違い

CRITICAL:サーバー関数の認証をルートガードに任せる
// WRONG — the RPC is callable directly via POST regardless of the route
export const Route = createFileRoute('/_authenticated/orders')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
  },
})
const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  return db.orders.findMany() // ← anyone can hit the RPC and get all orders
})

// CORRECT — auth enforced on the handler itself
const getMyOrders = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .handler(async ({ context }) => {
    return db.orders.findMany({ where: { userId: context.session.userId } })
  })
CRITICAL:形式の検証を認可として扱う

解析済み UUID が示すのは、あるワークスペースであり、認可されたワークスペースではありません。

// WRONG — UUID is well-formed but the user may not be a member
const getWorkspaceData = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .validator(z.object({ workspaceId: z.string().uuid() }))
  .handler(async ({ context, data }) => {
    return db.workspaces.findById(data.workspaceId) // missing membership check!
  })

// CORRECT — verify the session principal has access to that workspace
const getWorkspaceData = createServerFn({ method: 'GET' })
  .middleware([authMiddleware])
  .validator(z.object({ workspaceId: z.string().uuid() }))
  .handler(async ({ context, data }) => {
    const member = await db.memberships.find({
      userId: context.session.userId,
      workspaceId: data.workspaceId,
    })
    if (!member) throw new Error('Not a member of this workspace')
    return db.workspaces.findById(data.workspaceId)
  })
HIGH:メールアドレスの登録有無によって異なるレスポンスを返す

上記のとおり、requestPasswordReset はメールアドレスがユーザーに一致するかどうかにかかわらず、同じ本文を返す必要があります。

HIGH:モジュールスコープで Cookie/環境変数を読み取る
// WRONG — module-load time, before any request exists
const SESSION_SECRET = process.env.SESSION_SECRET
export function signSession(payload) {
  return sign(payload, SESSION_SECRET)
}

// CORRECT — read inside per-request callback
export function signSession(payload) {
  return sign(payload, process.env.SESSION_SECRET)
}

Cloudflare Workers などのエッジランタイムでは、環境変数はリクエストごとに注入されるため、サーバー上でもモジュールレベルの読み取り結果は undefined になります。詳しくは start-core/execution-model を参照してください。

MEDIUM:ローテーションしない長期間有効なセッション

ローテーションされないセッショントークンは、実質的に長期間有効な認証情報です。ログイン、ログアウト、パスワード変更、ロール/権限変更の際にローテーションします。

関連項目

  • @tanstack/router-core#router-core/auth-and-guards — ルーティング側の内容:_authenticated レイアウト、beforeLoad、redirect、RBAC チェック。
  • start-core/server-functions — RPC の公開方法(およびルートガードが RPC を保護しない理由)。
  • start-core/middleware — authMiddleware などの組み合わせ方。
  • start-core/execution-model — モジュールレベルで環境変数やシークレットを読み取るべきでない理由。

コメント