mcpField
A
auth-and-guards

auth-and-guards

github.com/tanstack開発15.3k207 回インストール4 日前に更新
リポジトリ

auth-and-guards について

TanStack Router の開発者向けに、ルートガードでナビゲーションを制御し、ルーターコンテキストで認証状態を渡す方法を解説するスキルです。ログイン後のリダイレクトや権限確認に加え、UI のガードとサーバー側のデータ保護を区別します。

主な機能

  1. 01beforeLoad によるルートレイアウトの保護
  2. 02ルーターコンテキストでの認証状態共有
  3. 03ログイン後の元ページへの復帰
  4. 04ロールや権限によるアクセス制御
  5. 05認証失敗とリダイレクトの処理
  6. 06ルートガードとエンドポイント認証の区別

活用シーン

  1. 01未認証時に保護ルートからログインへ誘導
  2. 02ロールに応じた管理ページの制限
  3. 03権限に応じたページ操作の制限
  4. 04ルートガードをサーバーデータ保護と誤認しない設計

インストール

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

$npx skills add https://github.com/tanstack/router --skill auth-and-guards

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

詳細

認証とガード

このスキルでは、認証のルーティング側を扱います。 ルートガードは UX とナビゲーションを制御するものであり、データ/API の境界は、非公開データを読み取る、または変更するサーバー関数、サーバールート、あるいは API エンドポイントで引き続き保護する必要があります。サーバーサイドのプリミティブ(セッション Cookie(HttpOnly/Secure/SameSite)、外部セッションヘルパー、OAuth の state + PKCE、パスワードリセット時のアカウント列挙対策、CSRF、レート制限)については、@tanstack/start-client-core#start-core/auth-server-primitives を参照してください。

重要:ルートガード(beforeLoad)は、そのルートで宣言された createServerFn を保護しません。サーバー関数は、それを呼び出すルートとは関係なくアクセスできる API エンドポイントです。以下の「ルートガードはサーバー関数を保護しません」を参照してください。

セットアップ

パスレスのレイアウトルート(_authenticated)で beforeLoad + redirect() を使ってルートを保護します。

// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  // component defaults to Outlet — no need to declare it
})

src/routes/_authenticated/ の下に配置されたルートファイルはすべて、自動的に保護されます。

// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/dashboard')({
  component: DashboardComponent,
})

function DashboardComponent() {
  const { auth } = Route.useRouteContext()
  return <div>Welcome, {auth.user?.username}</div>
}

基本パターン

認証状態のためのルーターコンテキスト

認証状態は、createRootRouteWithContext と RouterProvider の context プロパティを介してルーターに渡されます。

// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'

interface AuthState {
  isAuthenticated: boolean
  user: { id: string; username: string; email: string } | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

interface MyRouterContext {
  auth: AuthState
}

export const Route = createRootRouteWithContext<MyRouterContext>()({
  component: () => <Outlet />,
})
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
  routeTree,
  context: {
    auth: undefined!, // placeholder — filled by RouterProvider context prop
  },
})

declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}
// src/App.tsx
import { RouterProvider } from '@tanstack/react-router'
import { AuthProvider, useAuth } from './auth'
import { router } from './router'

function InnerApp() {
  const auth = useAuth()
  // context prop injects live auth state WITHOUT recreating the router
  return <RouterProvider router={router} context={{ auth }} />
}

function App() {
  return (
    <AuthProvider>
      <InnerApp />
    </AuthProvider>
  )
}

ルーターはプレースホルダーを使って一度だけ作成します。RouterProvider の context プロパティは、レンダリングのたびに最新の認証状態を注入します。これにより、認証状態の変更時にルーターを再作成して、キャッシュがリセットされたりルートツリーが再構築されたりするのを防げます。

リダイレクト先への復帰を伴う認証リダイレクト

ログイン後に元の場所へ戻れるよう、現在のロケーションを検索パラメーターに保存します。

// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },
})
// src/routes/login.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'

// Validate redirect target to prevent open redirect attacks
function sanitizeRedirect(url: unknown): string {
  if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//')) {
    return '/'
  }
  return url
}

export const Route = createFileRoute('/login')({
  validateSearch: (search) => ({
    redirect: sanitizeRedirect(search.redirect),
  }),
  beforeLoad: ({ context, search }) => {
    if (context.auth.isAuthenticated) {
      throw redirect({ to: search.redirect })
    }
  },
  component: LoginComponent,
})

function LoginComponent() {
  const { auth } = Route.useRouteContext()
  const search = Route.useSearch()
  const navigate = Route.useNavigate()
  const [username, setUsername] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault()
    try {
      await auth.login(username, password)
      navigate({ to: search.redirect })
    } catch {
      setError('Invalid credentials')
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <div>{error}</div>}
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Sign In</button>
    </form>
  )
}
リダイレクトを使わない認証(インラインログイン)

リダイレクトする代わりに、Outlet の位置にログインフォームを表示します。

// src/routes/_authenticated.tsx
import { createFileRoute, Outlet } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  component: AuthenticatedLayout,
})

function AuthenticatedLayout() {
  const { auth } = Route.useRouteContext()

  if (!auth.isAuthenticated) {
    return <LoginForm />
  }

  return <Outlet />
}

これにより URL は変わらず、ユーザーは同じページに留まり、保護されたコンテンツの代わりにログインフォームが表示されます。認証後は <Outlet /> がレンダリングされ、子ルートが表示されます。

ロールと権限による RBAC

認証状態をロール/権限のヘルパーで拡張し、beforeLoad でチェックします。

// src/auth.tsx
interface User {
  id: string
  username: string
  email: string
  roles: string[]
  permissions: string[]
}

interface AuthState {
  isAuthenticated: boolean
  user: User | null
  hasRole: (role: string) => boolean
  hasAnyRole: (roles: string[]) => boolean
  hasPermission: (permission: string) => boolean
  hasAnyPermission: (permissions: string[]) => boolean
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

管理者専用のレイアウトルート:

// src/routes/_authenticated/_admin.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_admin')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasRole('admin')) {
      throw redirect({
        to: '/unauthorized',
        search: { redirect: location.href },
      })
    }
  },
})

複数ロールでのアクセス:

// src/routes/_authenticated/_moderator.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_moderator')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasAnyRole(['admin', 'moderator'])) {
      throw redirect({
        to: '/unauthorized',
        search: { redirect: location.href },
      })
    }
  },
})

権限ベース:

// src/routes/_authenticated/_users.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users')({
  beforeLoad: ({ context, location }) => {
    if (!context.auth.hasAnyPermission(['users:read', 'users:write'])) {
      throw redirect({
        to: '/unauthorized',
        search: { redirect: location.href },
      })
    }
  },
})

ページレベルの権限チェック(すでにロールで保護されているレイアウトの下にネスト):

// src/routes/_authenticated/_users/manage.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated/_users/manage')({
  beforeLoad: ({ context }) => {
    if (!context.auth.hasPermission('users:write')) {
      throw new Error('Write permission required')
    }
  },
  component: UserManagement,
})

function UserManagement() {
  const { auth } = Route.useRouteContext()
  const canDelete = auth.hasPermission('users:delete')

  return (
    <div>
      <h1>User Management</h1>
      {canDelete && <button>Delete User</button>}
    </div>
  )
}
認証チェックの失敗への対処(isRedirect)

beforeLoad に try/catch があると、throw によって動作するリダイレクトが握りつぶされることがあります。isRedirect を使って再スローしてください。

import { createFileRoute, redirect, isRedirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async ({ context, location }) => {
    try {
      const user = await verifySession(context.auth)
      if (!user) {
        throw redirect({
          to: '/login',
          search: { redirect: location.href },
        })
      }
      return { user }
    } catch (error) {
      if (isRedirect(error)) throw error // re-throw redirect, don't swallow it
      // Actual error — redirect to login
      throw redirect({
        to: '/login',
        search: { redirect: location.href },
      })
    }
  },
})

よくある間違い

重要:ルートガードはサーバー関数を保護しません

beforeLoad によるリダイレクトが保護するのはルートの UIであり、そのルートで宣言されたサーバー関数ではありません。createServerFn は RPC エンドポイントを生成します。呼び出し元の UI をどのルートがレンダリングするかに関係なく、宣言された HTTP メソッドで直接アクセスできます。攻撃者は /_authenticated/orders を読み込む必要はありません。この GET RPC エンドポイントを直接呼び出せます。

// WRONG — handler has no auth check; the route guard doesn't help
import { createServerFn } from '@tanstack/react-start'
import { createFileRoute, redirect } from '@tanstack/react-router'

const getMyOrders = createServerFn({ method: 'GET' }).handler(async () => {
  return db.orders.findMany() // ← anyone can hit the RPC
})

export const Route = createFileRoute('/_authenticated/orders')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) throw redirect({ to: '/login' })
  },
  loader: () => getMyOrders(),
})
// CORRECT — auth enforced on the handler itself, via middleware
import { createServerFn } from '@tanstack/react-start'
import { authMiddleware } from '~/server/auth-middleware'

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

経験則として、ユーザーデータを扱うすべての createServerFn、サーバールート、API エンドポイントには、authMiddleware(または同等のハンドラー内チェック)が必要です。ルートガードはページの表示体験を守り、エンドポイントガードはデータを守ります。セッション/ミドルウェアのパターン全体については、@tanstack/start-client-core#start-core/auth-server-primitives を参照してください。

重要:匿名ユーザー向けの遷移先からも保護されたデータが漏れる可能性があります

API 呼び出しだけでなく、匿名ユーザーへの応答全体を保護してください。公開ログインページや未認証ページでも、そのタイトル、文言、検索パラメーター、シリアライズされたローダー状態に保護対象のユーザー、テナント、レコード、リソースが記載されていれば、データが漏洩します。匿名状態で直接リクエストを送り、リダイレクト後も確認してください。非公開データを読み取る前にハンドラーが拒否すること、保護対象のローダーが実行されないこと、最終的な HTML とシリアライズされた状態に保護対象の ID が含まれないこと、リダイレクトにサニタイズ済みの相対リターン URL だけが含まれることを検証してください。

高:beforeLoad ではなくコンポーネントで認証チェックを行う

コンポーネントレベルで認証チェックを行うと、リダイレクト前に保護されたコンテンツが一瞬表示されます。

// WRONG — protected content renders briefly before redirect
export const Route = createFileRoute('/_authenticated/dashboard')({
  component: () => {
    const auth = useAuth()
    if (!auth.isAuthenticated) return <Navigate to="/login" />
    return <Dashboard />
  },
})

// CORRECT — beforeLoad runs before any rendering
export const Route = createFileRoute('/_authenticated/dashboard')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
  },
  component: Dashboard,
})
高:try/catch 内でリダイレクトを再スローしない

redirect() は例外をスローすることで動作します。beforeLoad に try/catch があると、リダイレクトが握りつぶされます。

// WRONG — redirect is caught and swallowed
beforeLoad: async ({ context }) => {
  try {
    await validateSession(context.auth)
  } catch (e) {
    console.error(e) // swallows the redirect!
  }
}

// CORRECT — use isRedirect to distinguish intentional redirects from errors
import { isRedirect } from '@tanstack/react-router'

beforeLoad: async ({ context }) => {
  try {
    await validateSession(context.auth)
  } catch (e) {
    if (isRedirect(e)) throw e
    console.error(e)
  }
}
中:ルートルートのコンポーネントを条件付きでレンダリングする

ルートルートは認証状態に関係なく常にレンダリングされます。そのコンポーネントを条件付きでレンダリングすることはできません。

// WRONG — root route always renders, this doesn't protect anything
export const Route = createRootRoute({
  component: () => {
    if (!isAuthenticated()) return <Login />
    return <Outlet />
  },
})

// CORRECT — use a pathless layout route for auth boundaries
// src/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
  beforeLoad: ({ context }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({ to: '/login' })
    }
  },
})

保護されたルートは、_authenticated レイアウトルートの子として配置してください。公開ルート(ログイン、ホームなど)はその外側に配置します。

相互参照

  • こちらも参照:router-core/data-loading/SKILL.md — beforeLoad は loader より前に実行されます。認証コンテキストはルートコンテキストを介してローダーに渡されます
  • こちらも参照:start-core/auth-server-primitives/SKILL.md — サーバー側のセッション Cookie、OAuth state + PKCE、CSRF、パスワードリセットの強化、レート制限(認証のサーバー側部分)
  • こちらも参照:start-core/middleware/SKILL.md — 個々の createServerFn 呼び出しを保護する authMiddleware ファクトリーパターン

コメント