# AuthProvider Source: https://docs.ouim.me/logto-authkit/api/components/auth-provider The main authentication provider component that wraps your application with Logto authentication. ## Overview The `AuthProvider` component is the root authentication provider that wraps your application and provides authentication context to all child components. It integrates with Logto's authentication service and manages user session state. ## Props The child components to be wrapped by the authentication provider. Logto configuration object containing your application credentials and settings. ```typescript theme={null} interface LogtoConfig { endpoint: string appId: string resources?: string[] scopes?: string[] prompt?: string } ``` The URL to redirect to after successful authentication. Defaults to the current page URL if not specified. Custom navigation function to handle routing in your application. Useful for integration with client-side routers like Next.js or React Router. ```typescript theme={null} interface NavigationOptions { replace?: boolean // Use replaceState instead of pushState force?: boolean // Force navigation even if already on the same page } ``` Enable popup-based sign-in flow instead of full-page redirect. When enabled, the sign-in page opens in a popup window. ## Usage ### Basic Setup ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' function App() { const logtoConfig = { endpoint: 'https://your-logto-endpoint.com', appId: 'your-app-id', } return ( ) } ``` ### With Callback URL ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' function App() { const logtoConfig = { endpoint: 'https://your-logto-endpoint.com', appId: 'your-app-id', } return ( ) } ``` ### With Popup Sign-In ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' function App() { const logtoConfig = { endpoint: 'https://your-logto-endpoint.com', appId: 'your-app-id', } return ( ) } ``` ### With Custom Navigation (Next.js) ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' function App({ children }) { const router = useRouter() const logtoConfig = { endpoint: 'https://your-logto-endpoint.com', appId: 'your-app-id', } const customNavigate = (url: string, options?: NavigationOptions) => { if (options?.replace) { router.replace(url) } else { router.push(url) } } return ( {children} ) } ``` ## Features * **Client-Side Only**: Automatically prevents SSR issues by rendering only on the client side * **Cross-Tab Synchronization**: Auth state is synchronized across browser tabs and windows * **Auto Token Management**: Automatically manages JWT tokens in cookies * **Error Handling**: Built-in error recovery and automatic logout on auth failures * **Popup Support**: Optional popup-based authentication flow * **Rate Limiting**: Prevents excessive auth state refresh calls ## Notes * The `AuthProvider` must be placed at the root of your component tree, above any components that use authentication * Configuration is validated on mount to ensure all required fields are present * Auth state changes trigger a custom `auth-state-changed` event on the window object * The provider automatically handles token refresh and session management # CallbackPage Source: https://docs.ouim.me/logto-authkit/api/components/callback-page Handles the OAuth callback after authentication with Logto. ## Overview The `CallbackPage` component handles the OAuth callback flow after a user completes authentication with Logto. It processes the authorization code, exchanges it for tokens, and redirects the user appropriately. ## Props Additional CSS classes to apply to the container element. Custom component to display while the callback is being processed. If not provided, a default loading spinner with "Signing you in..." text is shown. Custom component to display after successful authentication. If not provided, a default spinner with "Authentication complete! Redirecting..." text is shown. Callback function that is called after successful authentication, before any redirect or window close actions. Callback function that is called if an error occurs during the authentication callback. ## Usage ### Basic Setup Create a callback route in your application (e.g., `/callback` or `/auth/callback`): ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' function Callback() { return } export default Callback ``` ### With Custom Loading Component ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' function Callback() { return (

Please wait...

Authenticating your account

} /> ) } ``` ### With Callbacks ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' function Callback() { const router = useRouter() return ( { console.log('Authentication successful!') // Optionally track analytics, etc. }} onError={(error) => { console.error('Authentication failed:', error) router.push('/login?error=auth_failed') }} /> ) } ``` ### Fully Customized ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' function Callback() { return (

Signing you in...

} successComponent={

Success! Redirecting...

} onSuccess={() => { // Track authentication success analytics.track('user_signed_in') }} onError={(error) => { // Track authentication error analytics.track('auth_error', { error: error.message }) }} /> ) } ``` ## Behavior ### Standard Flow 1. User is redirected to this page after authenticating with Logto 2. The component exchanges the authorization code for tokens 3. `onSuccess` callback is called (if provided) 4. User is redirected to the home page (`/`) ### Popup Flow When authentication happens in a popup window: 1. Component detects it's running in a popup (via `window.opener` or `sessionStorage`) 2. Exchanges authorization code for tokens 3. `onSuccess` callback is called (if provided) 4. Sends `SIGNIN_SUCCESS` message to parent window via `postMessage` 5. Falls back to `localStorage` if `postMessage` fails 6. Closes the popup window after a 100ms delay ### Error Handling If an error occurs during the callback: 1. Error is logged to console 2. `onError` callback is called (if provided) 3. Loading state continues (manual error handling required) ## Notes * The component must be used within an `AuthProvider` context * The callback URL must be registered in your Logto application settings * The component handles both full-page redirects and popup authentication flows automatically * A `simple_logto_popup_flow` sessionStorage flag persists the popup state across redirects * The component prevents duplicate callback execution using a ref * Spin animation keyframes are injected into the document head automatically ## Related * [SignInPage](/logto-authkit/api/components/sign-in-page) - Initiates the sign-in flow * [AuthProvider](/logto-authkit/api/components/auth-provider) - Required context provider # SignInPage Source: https://docs.ouim.me/logto-authkit/api/components/sign-in-page A page component that initiates the authentication flow with Logto. ## Overview The `SignInPage` component is a page-level component that automatically initiates the Logto authentication flow when rendered. It handles both standard redirects and popup-based authentication. ## Props This component does not accept any props. All configuration is managed through the `AuthProvider`. ## Usage ### Basic Setup Create a sign-in route in your application (e.g., `/signin` or `/login`): ```tsx theme={null} import { SignInPage } from '@ouim/logto-authkit' function SignIn() { return } export default SignIn ``` ### Next.js App Router ```tsx theme={null} // app/signin/page.tsx import { SignInPage } from '@ouim/logto-authkit' export default function SignIn() { return } ``` ### Next.js Pages Router ```tsx theme={null} // pages/signin.tsx import { SignInPage } from '@ouim/logto-authkit' function SignIn() { return } export default SignIn ``` ### React Router ```tsx theme={null} import { Routes, Route } from 'react-router-dom' import { SignInPage } from '@ouim/logto-authkit' function App() { return ( } /> ) } ``` ## Behavior ### Standard Flow 1. Component checks if user is already authenticated 2. If not authenticated, initiates Logto sign-in flow 3. User is redirected to Logto's authentication page 4. After authentication, user is redirected to the callback URL ### Popup Flow When popup sign-in is enabled (`enablePopupSignIn={true}` in `AuthProvider`): 1. Component detects it's opened in a popup window (via URL param `?popup=true`) 2. Sets `simple_logto_popup_flow` flag in sessionStorage 3. Initiates sign-in with popup disabled to prevent nested popups 4. After authentication, notifies parent window and closes popup ### Already Authenticated If user is already signed in: **Standard window:** * Redirects to home page (`/`) if not already there * Reloads the page if already on home page **Popup window:** * Sends `SIGNIN_COMPLETE` message to parent window * Falls back to localStorage if `postMessage` fails * Closes the popup after 100ms delay ### Loading State While checking authentication state: * Displays a centered loading spinner * Prevents any sign-in actions until state is determined ## Features * **Auto-Redirect**: Automatically initiates sign-in without user interaction * **Popup Detection**: Intelligently detects and handles popup authentication * **State Persistence**: Uses sessionStorage to maintain popup state across redirects * **Fallback Messaging**: Multiple communication methods between popup and parent window * **Prevents Nested Popups**: Disables popup mode when already in a popup * **Loading Feedback**: Built-in loading state while determining auth status ## Implementation Details ### Popup Detection The component detects popup mode through: 1. **URL Parameter**: `?popup=true` query string 2. **SessionStorage**: `simple_logto_popup_flow` flag (persists across redirects) 3. **Window Opener**: Presence of `window.opener` reference ### Parent Window Communication When in popup mode, the component communicates with the parent window via: 1. **Primary**: `window.opener.postMessage()` with same-origin check 2. **Fallback**: `localStorage.setItem('simple_logto_signin_complete')` for broadcast ### Sign-In Prevention The component uses a ref (`signInInProgress`) to prevent multiple simultaneous sign-in attempts. ## Notes * The component must be used within an `AuthProvider` context * No visual UI is rendered after the loading state (redirects immediately) * The `/signin` route is expected by the default popup implementation in `AuthProvider` * If you use a different route, update popup calls accordingly * The component automatically handles cleanup of sessionStorage flags ## Related * [CallbackPage](/logto-authkit/api/components/callback-page) - Handles the callback after authentication * [AuthProvider](/logto-authkit/api/components/auth-provider) - Configure authentication behavior * [useAuth](/logto-authkit/api/hooks/use-auth) - Access authentication state programmatically # useAuth Source: https://docs.ouim.me/logto-authkit/api/hooks/use-auth React hook for managing authentication state and access control ## Overview The `useAuth` hook provides access to authentication state and handles automatic redirects based on authentication status and middleware configuration. ## Signature ```typescript theme={null} function useAuth(options?: AuthOptions): AuthContextType ``` ## Parameters Configuration options for authentication behavior Defines the authentication requirement for the current page: * `'auth'`: Requires authentication (redirects unauthenticated users) * `'guest'`: Guest-only page (redirects authenticated users) * `undefined`: No automatic redirects URL to redirect to when `middleware: 'auth'` and user is not authenticated URL to redirect to when `middleware: 'guest'` and user is authenticated Navigation behavior options Use `replaceState` instead of `pushState` for navigation Force navigation even if already on the same page ## Returns Authentication context object containing user state and authentication methods Current authenticated user object, or `null` if not authenticated Unique user identifier User's display name User's email address URL to user's avatar image Loading state indicator for user authentication status Function to initiate sign-in flow Function to sign out the current user Function to refresh authentication state Whether popup sign-in is enabled ## Examples ### Basic Usage ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function Profile() { const { user, isLoadingUser } = useAuth() if (isLoadingUser) { return
Loading...
} if (!user) { return
Not authenticated
} return (

Welcome, {user.name}

Email: {user.email}

) } ``` ### Protected Route ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function Dashboard() { const { user, signOut } = useAuth({ middleware: 'auth', redirectTo: '/login' }) return (

Dashboard

Welcome, {user?.name}

) } ``` ### Guest-Only Route ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function Login() { const { signIn } = useAuth({ middleware: 'guest', redirectIfAuthenticated: '/dashboard' }) return (

Login

) } ``` ### Custom Navigation Options ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function ProtectedPage() { const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', navigationOptions: { replace: true, // Use replace instead of push force: false } }) return
Protected content
} ``` ## Behavior * The hook automatically handles redirects based on the `middleware` option and authentication state * Redirects only occur after the loading state completes (`isLoadingUser` is `false`) * When `middleware: 'auth'` is set and user is not authenticated, redirects to `redirectTo` (defaults to `/404`) * When `middleware: 'guest'` is set and user is authenticated, redirects to `redirectIfAuthenticated` * The hook memoizes options to prevent infinite re-renders when the options object reference changes ## See Also * [AuthProvider](/logto-authkit/api/components/auth-provider) - Provider component required to use this hook * [verifyAuth](/logto-authkit/api/server/verify-auth) - Server authentication verification # createExpressAuthMiddleware Source: https://docs.ouim.me/logto-authkit/api/server/create-express-auth-middleware Express middleware factory for Logto authentication ## Overview The `createExpressAuthMiddleware` function creates an Express middleware that automatically verifies Logto authentication tokens and attaches authentication context to the request object. ## Signature ```typescript theme={null} function createExpressAuthMiddleware( options: VerifyAuthOptions ): (req: ExpressRequest, res: ExpressResponse, next: ExpressNext) => void ``` ## Parameters Configuration options for token verification Your Logto server URL (e.g., `https://your-logto.app`) Expected audience claim in the token (your application identifier) Name of the cookie containing the auth token Required OAuth scope that must be present in the token Allow guest access when no valid token is found. Attaches guest context instead of returning 401 ## Returns Express middleware function that can be used with `app.use()` or route handlers ## Request Enhancement The middleware adds an `auth` property to the Express request object: ```typescript theme={null} req.auth: AuthContext ``` User ID from the token's `sub` claim, or `null` for guest users Whether the user is authenticated (`true`) or guest (`false`) Decoded JWT payload, or `null` for guest users `true` if this is a guest context (only when `allowGuest: true`) Unique identifier for guest users (auto-generated UUID) ## Examples ### Basic Setup ```typescript theme={null} import express from 'express' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const app = express() const authMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE }) // Apply to all routes app.use(authMiddleware) app.get('/profile', (req, res) => { res.json({ userId: req.auth.userId, authenticated: req.auth.isAuthenticated }) }) app.listen(3000) ``` ### Protected Routes Only ```typescript theme={null} import express from 'express' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const app = express() const authMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE }) // Public route (no auth required) app.get('/public', (req, res) => { res.json({ message: 'Public endpoint' }) }) // Protected routes app.get('/api/user', authMiddleware, (req, res) => { res.json({ userId: req.auth.userId }) }) app.post('/api/data', authMiddleware, (req, res) => { const userId = req.auth.userId // Handle authenticated request res.json({ success: true }) }) ``` ### With Guest Support ```typescript theme={null} import express from 'express' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const app = express() const authMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE, allowGuest: true // Allow unauthenticated access }) app.use(authMiddleware) app.get('/api/content', (req, res) => { if (req.auth.isGuest) { res.json({ message: 'Limited content for guests', guestId: req.auth.guestId }) } else { res.json({ message: 'Full content for authenticated users', userId: req.auth.userId }) } }) ``` ### With Required Scope ```typescript theme={null} import express from 'express' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const app = express() const adminAuthMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE, requiredScope: 'admin:write' }) // Admin-only endpoint app.delete('/api/users/:id', adminAuthMiddleware, (req, res) => { // Only users with admin:write scope can access this res.json({ success: true }) }) ``` ### Custom Cookie Name ```typescript theme={null} import express from 'express' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const app = express() const authMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE, cookieName: 'my_custom_auth_cookie' }) app.use(authMiddleware) ``` ### Error Handling ```typescript theme={null} import express from 'express' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const app = express() const authMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE }) app.use(authMiddleware) // Authentication errors return 401 automatically // Add error handler for custom error responses app.use((err, req, res, next) => { if (err.status === 401) { res.status(401).json({ error: 'Unauthorized', message: 'Please sign in to access this resource' }) } else { next(err) } }) ``` ## Behavior ### Token Extraction The middleware extracts tokens in this order: 1. Cookie (using `cookieName` option, defaults to `logto_authtoken`) 2. Authorization header (Bearer token) ### Authentication Responses **When `allowGuest: false` (default):** * No token found: Returns 401 with error message * Invalid token: Returns 401 with error details * Valid token: Attaches `AuthContext` to `req.auth` and calls `next()` **When `allowGuest: true`:** * No token found: Attaches guest `AuthContext` to `req.auth` and calls `next()` * Invalid token: Attaches guest `AuthContext` to `req.auth` and calls `next()` * Valid token: Attaches authenticated `AuthContext` to `req.auth` and calls `next()` ### Cookie Parsing The middleware automatically handles cookie parsing: * If cookies are not already parsed, it applies `cookie-parser` internally * No need to manually add `cookie-parser` middleware when using this middleware ## TypeScript Support Extend the Express request type to include the `auth` property: ```typescript theme={null} import type { AuthContext } from '@ouim/logto-authkit/server' declare global { namespace Express { interface Request { auth: AuthContext } } } ``` ## See Also * [verifyAuth](/logto-authkit/api/server/verify-auth) - Generic verification function * [verifyNextAuth](/logto-authkit/api/server/verify-next-auth) - Next.js authentication helper * [useAuth](/logto-authkit/api/hooks/use-auth) - React hook for client-side authentication # verifyAuth Source: https://docs.ouim.me/logto-authkit/api/server/verify-auth Universal function for verifying Logto authentication tokens in any Node.js environment ## Overview The `verifyAuth` function is a generic authentication verification utility that works in any Node.js environment. It can accept either a raw JWT token string or a request object with cookies and headers. ## Signature ```typescript theme={null} async function verifyAuth( tokenOrRequest: string | { cookies?: any; headers?: any }, options: VerifyAuthOptions ): Promise ``` ## Parameters Either a JWT token string or a request object containing cookies and headers When passing an object: * `cookies`: Cookie object (e.g., from cookie-parser) * `headers`: Headers object or Headers API Configuration options for token verification Your Logto server URL (e.g., `https://your-logto.app`) Expected audience claim in the token (your application identifier) Name of the cookie containing the auth token Required OAuth scope that must be present in the token Allow guest access when no valid token is found. Returns guest context instead of throwing error ## Returns Promise that resolves to authentication context User ID from the token's `sub` claim, or `null` for guest users Whether the user is authenticated (`true`) or guest (`false`) Decoded JWT payload, or `null` for guest users User ID (subject claim) OAuth scopes granted to the token Additional claims in the JWT payload `true` if this is a guest context (only when `allowGuest: true`) Unique identifier for guest users (auto-generated UUID) ## Throws * Throws an error if no token is found and `allowGuest` is `false` * Throws an error if token verification fails and `allowGuest` is `false` * When `allowGuest: true`, returns guest context instead of throwing ## Examples ### With JWT Token String ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server' const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...' try { const auth = await verifyAuth(token, { logtoUrl: 'https://your-logto.app', audience: 'https://api.yourapp.com' }) console.log('User ID:', auth.userId) console.log('Authenticated:', auth.isAuthenticated) } catch (error) { console.error('Authentication failed:', error) } ``` ### With Request Object (Generic) ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server' const request = { cookies: { logto_authtoken: 'eyJhbGciOiJ...' }, headers: { authorization: 'Bearer eyJhbGciOiJ...' } } const auth = await verifyAuth(request, { logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE }) if (auth.isAuthenticated) { console.log('User:', auth.userId) console.log('Scopes:', auth.payload?.scope) } ``` ### With Guest Support ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server' const auth = await verifyAuth(request, { logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE, allowGuest: true // Won't throw if no token found }) if (auth.isGuest) { console.log('Guest user:', auth.guestId) } else { console.log('Authenticated user:', auth.userId) } ``` ### With Required Scope ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server' try { const auth = await verifyAuth(token, { logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE, requiredScope: 'admin:write' // Requires this scope }) // Token has admin:write scope console.log('Admin user:', auth.userId) } catch (error) { console.error('Missing required scope') } ``` ### Custom Cookie Name ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server' const auth = await verifyAuth(request, { logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_AUDIENCE, cookieName: 'my_custom_auth_cookie' }) ``` ## Token Extraction Order When a request object is provided, tokens are extracted in this order: 1. Cookie (using `cookieName` option, defaults to `logto_authtoken`) 2. Authorization header (Bearer token) The first valid token found is used for verification. ## Verification Process The function performs the following verification steps: 1. Fetches JWKS (JSON Web Key Set) from your Logto server 2. Decodes JWT header to identify the signing key 3. Verifies JWT signature using the public key 4. Validates token claims: * Issuer (`iss`) matches Logto URL * Audience (`aud`) matches provided audience * Token is not expired (`exp`) * Token is valid (`nbf` - not before) * Required scope is present (if specified) ## Caching JWKS (signing keys) are cached for 5 minutes to reduce requests to the Logto server and improve performance. ## See Also * [createExpressAuthMiddleware](/logto-authkit/api/server/create-express-auth-middleware) - Express.js middleware * [verifyNextAuth](/logto-authkit/api/server/verify-next-auth) - Next.js authentication helper * [useAuth](/logto-authkit/api/hooks/use-auth) - React hook for client-side authentication # verifyNextAuth Source: https://docs.ouim.me/logto-authkit/api/server/verify-next-auth Next.js authentication verification helper for App Router middleware and API routes ## Overview The `verifyNextAuth` function is designed specifically for Next.js App Router, providing authentication verification for middleware and API routes with Next.js-specific request handling. ## Signature ```typescript theme={null} async function verifyNextAuth( request: NextRequest, options: VerifyAuthOptions ): Promise< | { success: true; auth: AuthContext } | { success: false; error: string; auth?: AuthContext } > ``` ## Parameters Next.js request object from middleware or API routes Configuration options for token verification Your Logto server URL (e.g., `https://your-logto.app`) Expected audience claim in the token (your application identifier) Name of the cookie containing the auth token Required OAuth scope that must be present in the token Allow guest access when no valid token is found. Returns guest context in response ## Returns Promise that resolves to a result object indicating success or failure **Success Response:** Indicates successful authentication Authentication context with user information **Failure Response:** Indicates authentication failed Error message describing why authentication failed Guest authentication context (only present when `allowGuest: true`) ### AuthContext Properties User ID from the token's `sub` claim, or `null` for guest users Whether the user is authenticated (`true`) or guest (`false`) Decoded JWT payload, or `null` for guest users User ID (subject claim) OAuth scopes granted to the token Additional claims in the JWT payload `true` if this is a guest context (only when `allowGuest: true`) Unique identifier for guest users (auto-generated UUID) ## Examples ### Middleware Protection ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function middleware(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE! }) if (!result.success) { return NextResponse.redirect(new URL('/login', request.url)) } // User is authenticated, continue return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/api/protected/:path*'] } ``` ### API Route Protection ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE! }) if (!result.success) { return NextResponse.json( { error: result.error }, { status: 401 } ) } // Access authenticated user const userId = result.auth.userId return NextResponse.json({ message: 'Success', userId }) } ``` ### With Guest Support ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE!, allowGuest: true }) if (!result.success) { // Guest user return NextResponse.json({ message: 'Limited content for guests', guestId: result.auth?.guestId }) } // Authenticated user return NextResponse.json({ message: 'Full content', userId: result.auth.userId }) } ``` ### Middleware with Guest Support ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function middleware(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE!, allowGuest: true }) // Add auth context to request headers for use in app const requestHeaders = new Headers(request.headers) if (result.success) { requestHeaders.set('x-user-id', result.auth.userId || '') requestHeaders.set('x-is-authenticated', 'true') } else if (result.auth?.isGuest) { requestHeaders.set('x-guest-id', result.auth.guestId || '') requestHeaders.set('x-is-authenticated', 'false') } return NextResponse.next({ request: { headers: requestHeaders } }) } ``` ### Role-Based Access Control ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function middleware(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE!, requiredScope: 'admin:write' }) if (!result.success) { return NextResponse.json( { error: 'Admin access required' }, { status: 403 } ) } return NextResponse.next() } export const config = { matcher: '/admin/:path*' } ``` ### Custom Error Handling ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE! }) if (!result.success) { // Detailed error handling const statusCode = result.error.includes('expired') ? 401 : 403 return NextResponse.json( { error: 'Authentication failed', details: result.error, timestamp: new Date().toISOString() }, { status: statusCode } ) } return NextResponse.json({ userId: result.auth.userId, scopes: result.auth.payload?.scope }) } ``` ### Extracting User Info in Server Component ```typescript theme={null} // app/dashboard/page.tsx import { cookies, headers } from 'next/headers' import { verifyAuth } from '@ouim/logto-authkit/server' export default async function DashboardPage() { const cookieStore = cookies() const headersList = headers() const auth = await verifyAuth( { cookies: Object.fromEntries(cookieStore.getAll().map(c => [c.name, c.value])), headers: Object.fromEntries(headersList.entries()) }, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE! } ) return (

Dashboard

User ID: {auth.userId}

) } ``` ## Token Extraction Order The function extracts tokens in this order: 1. Cookie (using `cookieName` option, defaults to `logto_authtoken`) 2. Authorization header (Bearer token) ## Response Patterns ### Authenticated User ```typescript theme={null} { success: true, auth: { userId: 'user-123', isAuthenticated: true, payload: { sub: 'user-123', scope: 'profile email' }, isGuest: false } } ``` ### Guest User (when `allowGuest: true`) ```typescript theme={null} { success: false, error: 'No authentication token found', auth: { userId: null, isAuthenticated: false, payload: null, isGuest: true, guestId: 'a1b2c3d4-e5f6-4g7h-8i9j-0k1l2m3n4o5p' } } ``` ### Failed Authentication (when `allowGuest: false`) ```typescript theme={null} { success: false, error: 'No token found in cookies or Authorization header' } ``` ## See Also * [verifyAuth](/logto-authkit/api/server/verify-auth) - Generic verification function * [createExpressAuthMiddleware](/logto-authkit/api/server/create-express-auth-middleware) - Express.js middleware * [useAuth](/logto-authkit/api/hooks/use-auth) - React hook for client-side authentication # Frontend Types Source: https://docs.ouim.me/logto-authkit/api/types/frontend-types TypeScript type definitions for logto-authkit frontend SDK Frontend TypeScript types used in logto-authkit React components and hooks. ## User Types ### LogtoUser Represents an authenticated user in the application. Unique identifier for the user Display name of the user Email address of the user URL to the user's avatar/profile picture Additional custom properties from the identity provider ```typescript theme={null} export type LogtoUser = { id: string name?: string email?: string avatar?: string [key: string]: any } ``` ## Authentication Types ### AuthMiddleware Defines the authentication requirement level for a page or route. ```typescript theme={null} export type AuthMiddleware = 'auth' | 'guest' | undefined ``` * `'auth'` - Requires authenticated user * `'guest'` - Allows unauthenticated access (guest mode) * `undefined` - No middleware applied ### NavigationOptions Options for controlling navigation behavior. Use `replaceState` instead of `pushState` to avoid adding to browser history Force navigation even if already on the same page ```typescript theme={null} export interface NavigationOptions { replace?: boolean force?: boolean } ``` ### AuthOptions Configuration options for authentication behavior and redirects. Authentication middleware type to apply URL to redirect to after authentication URL to redirect to if user is already authenticated Options for controlling navigation behavior ```typescript theme={null} export interface AuthOptions { middleware?: AuthMiddleware redirectTo?: string redirectIfAuthenticated?: string navigationOptions?: NavigationOptions } ``` ## Context Types ### AuthContextType The authentication context type provided by `useAuth()` hook. The currently authenticated user, or `null` if not authenticated Indicates if user data is currently being loaded Function to initiate sign-in flow * `callbackUrl` - URL to redirect to after successful sign-in * `usePopup` - Whether to use popup sign-in instead of redirect Function to sign out the current user * `callbackUrl` - URL to redirect to after sign-out * `global` - Whether to perform global sign-out across all sessions Function to refresh authentication state and user data Whether popup sign-in is enabled ```typescript theme={null} export interface AuthContextType { user: LogtoUser | null isLoadingUser: boolean signIn: (callbackUrl?: string, usePopup?: boolean) => Promise signOut: (options?: { callbackUrl?: string; global?: boolean }) => Promise refreshAuth: () => Promise enablePopupSignIn?: boolean } ``` ## Component Props ### AuthProviderProps Props for the `AuthProvider` component. Child components to render within the auth context Logto configuration object containing `endpoint`, `appId`, and `resources` Default callback URL for authentication redirects Custom navigation function (e.g., for React Router integration) Enable popup-based sign-in flow ```typescript theme={null} export interface AuthProviderProps { children: React.ReactNode config: LogtoConfig callbackUrl?: string customNavigate?: (url: string, options?: NavigationOptions) => void enablePopupSignIn?: boolean } ``` ### CallbackPageProps Props for the `CallbackPage` component. CSS class name for styling the callback page Custom component to display during authentication callback processing Custom component to display on successful authentication Callback function executed on successful authentication Callback function executed on authentication error ```typescript theme={null} export interface CallbackPageProps { className?: string loadingComponent?: React.ReactNode successComponent?: React.ReactNode onSuccess?: () => void onError?: (error: Error) => void } ``` ### AdditionalPage Defines additional pages/links for UI components. URL or path for the page Display text for the link Optional icon element to display with the link ```typescript theme={null} export interface AdditionalPage { link: string text: string icon?: React.ReactNode } ``` ## Usage Example ```typescript theme={null} import type { LogtoUser, AuthOptions, AuthContextType } from '@ouim/logto-authkit' import { useAuth } from '@ouim/logto-authkit' function UserProfile() { const { user, signOut }: AuthContextType = useAuth() if (!user) return null const logtoUser: LogtoUser = user return (

{logtoUser.name}

{logtoUser.email}

) } ``` # Server Types Source: https://docs.ouim.me/logto-authkit/api/types/server-types TypeScript type definitions for logto-authkit server SDK Server TypeScript types used in logto-authkit server-side authentication and middleware. ## Authentication Types ### AuthPayload JWT payload structure returned after token verification. Subject - the user ID from Logto OAuth scopes granted to the token Additional claims from the JWT token ```typescript theme={null} export interface AuthPayload { sub: string // user ID scope: string [key: string]: any } ``` ### AuthContext Authentication context attached to requests after verification. The authenticated user ID, or `null` if not authenticated Whether the request has a valid authentication token The full JWT payload if authenticated, otherwise `null` Whether the request is from a guest user (when `allowGuest` is enabled) The guest user ID (when in guest mode) ```typescript theme={null} export interface AuthContext { userId: string | null isAuthenticated: boolean payload: AuthPayload | null isGuest?: boolean guestId?: string } ``` ### VerifyAuthOptions Configuration options for token verification. The Logto tenant URL (e.g., `https://your-tenant.logto.app`) The API resource identifier configured in Logto Name of the cookie containing the JWT token Required OAuth scope for authorization (e.g., `"read:users"`) Allow unauthenticated guest users with guest IDs ```typescript theme={null} export interface VerifyAuthOptions { logtoUrl: string audience: string cookieName?: string requiredScope?: string allowGuest?: boolean } ``` ## Express Types ### ExpressRequest Extended Express request interface with authentication context. Parsed cookies from the request HTTP headers from the request Authentication context added by the middleware ```typescript theme={null} export interface ExpressRequest { cookies?: { [key: string]: string } headers: { [key: string]: string | string[] | undefined } auth?: AuthContext } ``` ### ExpressResponse Express response interface for middleware. Set HTTP status code Send JSON response ```typescript theme={null} export interface ExpressResponse { status: (code: number) => ExpressResponse json: (obj: any) => ExpressResponse } ``` ### ExpressNext Express next function type. ```typescript theme={null} export type ExpressNext = (err?: any) => void ``` ## Next.js Types ### NextRequest Next.js request interface for route handlers and middleware. Cookie accessor for Next.js requests Header accessor for Next.js requests ```typescript theme={null} export interface NextRequest { cookies: { get: (name: string) => { value: string } | undefined } headers: { get: (name: string) => string | null } } ``` ### NextResponse Next.js response interface for route handlers. Send JSON response with optional status code ```typescript theme={null} export interface NextResponse { json: (body: any, init?: { status?: number }) => NextResponse } ``` ## Usage Examples ### Express Middleware ```typescript theme={null} import type { ExpressRequest, ExpressResponse } from '@ouim/logto-authkit/server' import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const authMiddleware = createExpressAuthMiddleware({ logtoUrl: 'https://your-tenant.logto.app', audience: 'https://api.example.com', requiredScope: 'read:users' }) app.get('/api/protected', authMiddleware, (req: ExpressRequest, res: ExpressResponse) => { const userId = req.auth?.userId res.json({ userId, message: 'Protected data' }) }) ``` ### Next.js Route Handler ```typescript theme={null} import type { NextRequest } from '@ouim/logto-authkit/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' import { NextResponse } from 'next/server' export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_AUDIENCE!, }) if (!result.success || !result.auth.isAuthenticated) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } return NextResponse.json({ userId: result.auth.userId }) } ``` ### Guest Mode ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server' const guestMiddleware = createExpressAuthMiddleware({ logtoUrl: 'https://your-tenant.logto.app', audience: 'https://api.example.com', allowGuest: true }) app.get('/api/content', guestMiddleware, (req, res) => { if (req.auth?.isGuest) { // Handle guest user console.log('Guest ID:', req.auth.guestId) } else { // Handle authenticated user console.log('User ID:', req.auth?.userId) } res.json({ message: 'Content accessible to all' }) }) ``` # Bundler Configuration Source: https://docs.ouim.me/logto-authkit/api/utilities/bundler-config Configuration utilities for resolving jose library issues in different bundlers Utilities to help configure your bundler (Vite, Webpack, or Next.js) to properly resolve the `jose` library dependency. ## Overview logto-authkit uses the `jose` library for JWT operations. Some bundlers require special configuration to properly resolve this dependency. These utilities provide the necessary configuration for different build tools. ## Functions ### getBundlerConfig Returns bundler-specific configuration for resolving the `jose` library. ```typescript theme={null} getBundlerConfig(bundler?: 'vite' | 'webpack' | 'nextjs'): BundlerConfig ``` The bundler type to generate configuration for **Returns:** `BundlerConfig` - Configuration object specific to the bundler ## Pre-configured Exports ### viteConfig Pre-configured settings for Vite projects. ```typescript theme={null} export const viteConfig: BundlerConfig ``` ### webpackConfig Pre-configured settings for Webpack projects. ```typescript theme={null} export const webpackConfig: BundlerConfig ``` ### nextjsConfig Pre-configured settings for Next.js projects. ```typescript theme={null} export const nextjsConfig: BundlerConfig ``` ## Type Definitions ### BundlerConfig ```typescript theme={null} interface BundlerConfig { optimizeDeps?: { include: string[] } resolve?: { alias: Record } alias?: Record } ``` Vite-specific dependency optimization settings Module resolution aliases Alternative alias format for some bundlers ## Usage Examples ### Vite Add to your `vite.config.ts`: ```typescript theme={null} import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { viteConfig } from '@ouim/logto-authkit/bundler-config' export default defineConfig({ plugins: [react()], ...viteConfig, }) ``` Or use the function: ```typescript theme={null} import { getBundlerConfig } from '@ouim/logto-authkit/bundler-config' const bundlerConfig = getBundlerConfig('vite') export default defineConfig({ plugins: [react()], optimizeDeps: bundlerConfig.optimizeDeps, resolve: bundlerConfig.resolve, }) ``` ### Webpack Add to your `webpack.config.js`: ```javascript theme={null} const { webpackConfig } = require('@ouim/logto-authkit/bundler-config') module.exports = { // ... other webpack config resolve: webpackConfig.resolve, } ``` ### Next.js Add to your `next.config.js`: ```javascript theme={null} const { nextjsConfig } = require('@ouim/logto-authkit/bundler-config') module.exports = { // ... other Next.js config webpack: (config) => { config.resolve.alias = { ...config.resolve.alias, ...nextjsConfig.resolve.alias, } return config }, } ``` Or in TypeScript (`next.config.ts`): ```typescript theme={null} import type { NextConfig } from 'next' import { nextjsConfig } from '@ouim/logto-authkit/bundler-config' const config: NextConfig = { webpack: (config) => { config.resolve.alias = { ...config.resolve.alias, ...nextjsConfig.resolve?.alias, } return config }, } export default config ``` ### Custom Bundler For other bundlers, use the `getBundlerConfig` function: ```typescript theme={null} import { getBundlerConfig } from '@ouim/logto-authkit/bundler-config' // Get the base jose alias const config = getBundlerConfig() // Returns { alias: { jose: 'jose/dist/node/cjs' } } // Apply to your custom bundler configuration customBundler.setAlias(config.alias) ``` ## What Does This Fix? The `jose` library has different builds for different environments. These configurations ensure that: 1. The correct CommonJS build is used (`jose/dist/node/cjs`) 2. Dependencies are properly optimized for Vite 3. Module resolution works correctly across different bundlers Without this configuration, you may encounter errors like: * `Cannot find module 'jose'` * `Error: Package subpath './jwt/verify' is not defined` * Module resolution failures during build ## Configuration Details ### Vite Configuration ```typescript theme={null} { optimizeDeps: { include: ['@logto/react', '@ouim/better-logto-react'], }, resolve: { alias: { jose: 'jose/dist/node/cjs', }, }, } ``` ### Webpack/Next.js Configuration ```typescript theme={null} { resolve: { alias: { jose: 'jose/dist/node/cjs', }, }, } ``` # Cookie Utilities Source: https://docs.ouim.me/logto-authkit/api/utilities/cookie-utils Client-side cookie management utilities for authentication Utilities for managing cookies and JWT tokens in logto-authkit applications. ## Cookie Utilities General-purpose cookie management functions. ### cookieUtils.setCookie Set a cookie with the given name, value, and options. ```typescript theme={null} cookieUtils.setCookie( name: string, value: string, options?: CookieOptions ): void ``` The cookie name The cookie value Cookie configuration options #### CookieOptions Expiration date (Date object) or number of days until expiration Maximum age in seconds Cookie domain Cookie path Whether cookie requires HTTPS SameSite cookie attribute Whether cookie is HTTP-only (not accessible via JavaScript) **Example:** ```typescript theme={null} import { cookieUtils } from '@ouim/logto-authkit' // Set a cookie that expires in 7 days cookieUtils.setCookie('user_preference', 'dark-mode', { expires: 7, secure: true, sameSite: 'strict' }) // Set a cookie with a specific expiration date cookieUtils.setCookie('session', 'abc123', { expires: new Date('2026-12-31'), path: '/' }) ``` ### cookieUtils.getCookie Get a cookie value by name. ```typescript theme={null} cookieUtils.getCookie(name: string): string | null ``` The cookie name to retrieve **Returns:** `string | null` - The cookie value, or `null` if not found **Example:** ```typescript theme={null} import { cookieUtils } from '@ouim/logto-authkit' const preference = cookieUtils.getCookie('user_preference') if (preference === 'dark-mode') { // Enable dark mode } ``` ### cookieUtils.removeCookie Remove a cookie by name. ```typescript theme={null} cookieUtils.removeCookie( name: string, options?: RemoveCookieOptions ): void ``` The cookie name to remove Cookie removal options (domain and path must match the original cookie) #### RemoveCookieOptions Cookie domain (must match original) Cookie path (must match original) **Example:** ```typescript theme={null} import { cookieUtils } from '@ouim/logto-authkit' cookieUtils.removeCookie('user_preference', { path: '/' }) ``` ## JWT Token Utilities Specialized utilities for managing JWT authentication tokens. ### jwtCookieUtils.saveToken Save a JWT token to a secure cookie. ```typescript theme={null} jwtCookieUtils.saveToken(token: string): void ``` The JWT token to save **Cookie details:** * Name: `logto_authtoken` * Expires: 7 days * Secure: true (HTTPS only) * SameSite: strict * Path: / **Example:** ```typescript theme={null} import { jwtCookieUtils } from '@ouim/logto-authkit' // After successful authentication const token = await getAccessToken() jwtCookieUtils.saveToken(token) ``` ### jwtCookieUtils.getToken Retrieve the JWT token from the cookie. ```typescript theme={null} jwtCookieUtils.getToken(): string | null ``` **Returns:** `string | null` - The JWT token, or `null` if not found **Example:** ```typescript theme={null} import { jwtCookieUtils } from '@ouim/logto-authkit' const token = jwtCookieUtils.getToken() if (token) { // Make authenticated API request fetch('/api/user', { headers: { Authorization: `Bearer ${token}` } }) } ``` ### jwtCookieUtils.removeToken Remove the JWT token cookie. ```typescript theme={null} jwtCookieUtils.removeToken(): void ``` **Example:** ```typescript theme={null} import { jwtCookieUtils } from '@ouim/logto-authkit' // On sign out function handleSignOut() { jwtCookieUtils.removeToken() // Redirect to login page } ``` ## Configuration Validation ### validateLogtoConfig Validate Logto configuration for required fields. ```typescript theme={null} validateLogtoConfig(config: LogtoConfig): void ``` The Logto configuration object to validate **Throws:** `Error` if configuration is invalid or missing required fields **Example:** ```typescript theme={null} import { validateLogtoConfig } from '@ouim/logto-authkit' import type { LogtoConfig } from '@logto/react' const config: LogtoConfig = { endpoint: 'https://your-tenant.logto.app', appId: 'your-app-id', resources: ['https://api.example.com'] } try { validateLogtoConfig(config) console.log('Config is valid') } catch (error) { console.error('Invalid config:', error.message) } ``` The library uses internal utilities for transforming user data and generating guest IDs. These utilities are used automatically by the AuthProvider and do not need to be called directly. # Bundler Configuration Source: https://docs.ouim.me/logto-authkit/configuration/bundler-setup Configure Vite, Webpack, or Next.js to work with logto-authkit logto-authkit provides pre-configured bundler settings to resolve common issues with the `jose` library and optimize dependencies. ## Why Bundler Configuration? The underlying Logto SDK uses the `jose` library for JWT handling, which can cause bundling issues in some environments. logto-authkit provides ready-to-use configurations that: * Resolve `jose` library compatibility issues * Optimize dependency bundling * Ensure proper module resolution ## Vite Configuration When editing a Vite config or other build-time script, import directly from the `bundler-config` subpath. This avoids executing the main library bundle (which pulls in React and may cause issues during Node startup). ```javascript vite.config.js theme={null} import { viteConfig } from '@ouim/logto-authkit/bundler-config' export default { ...viteConfig, // your other config } ``` ### What's Included The Vite configuration provides: ```javascript theme={null} { optimizeDeps: { include: ['@logto/react', '@ouim/better-logto-react'], }, resolve: { alias: { jose: 'jose/dist/node/cjs', }, }, } ``` ## Webpack Configuration ```javascript webpack.config.js theme={null} import { webpackConfig } from '@ouim/logto-authkit' module.exports = { ...webpackConfig, // your other config } ``` ### What's Included The Webpack configuration provides: ```javascript theme={null} { resolve: { alias: { jose: 'jose/dist/node/cjs', }, }, } ``` ## Next.js Configuration ```javascript next.config.js theme={null} import { nextjsConfig } from '@ouim/logto-authkit' module.exports = { ...nextjsConfig, // your other config } ``` ### What's Included The Next.js configuration provides the same alias configuration as Webpack: ```javascript theme={null} { resolve: { alias: { jose: 'jose/dist/node/cjs', }, }, } ``` ## Custom Configuration If you need more control or want to integrate the configuration differently, you can use the `getBundlerConfig` helper: ```javascript theme={null} import { getBundlerConfig } from '@ouim/logto-authkit/bundler-config' // Get configuration for specific bundler const viteConfig = getBundlerConfig('vite') const webpackConfig = getBundlerConfig('webpack') const nextConfig = getBundlerConfig('nextjs') ``` ### Parameters The `getBundlerConfig` function accepts one parameter: The bundler type to get configuration for ### Merging with Existing Config You can merge the provided configuration with your existing setup: ```javascript vite.config.js theme={null} import { getBundlerConfig } from '@ouim/logto-authkit/bundler-config' import { defineConfig } from 'vite' const logtoConfig = getBundlerConfig('vite') export default defineConfig({ // Merge optimize deps optimizeDeps: { ...logtoConfig.optimizeDeps, include: [ ...logtoConfig.optimizeDeps.include, 'your-other-deps', ], }, // Merge resolve aliases resolve: { ...logtoConfig.resolve, alias: { ...logtoConfig.resolve.alias, '@': '/src', }, }, // Your other config plugins: [...], }) ``` The bundler configuration is minimal and designed to be easily merged with your existing setup. It only modifies what's necessary for logto-authkit to work properly. ## Troubleshooting Make sure you've applied the bundler configuration. The `jose` alias resolves to the CommonJS distribution which is more compatible with bundlers. If you're importing from the main package in your build config, switch to importing from `@ouim/logto-authkit/bundler-config` instead. This avoids loading React during the build process. If you see errors about pre-bundling dependencies, make sure the `optimizeDeps.include` array includes both `@logto/react` and `@ouim/better-logto-react`. ## Next Steps Learn how to enable guest mode for unauthenticated users Set up custom navigation with your router # Custom Navigation Source: https://docs.ouim.me/logto-authkit/configuration/custom-navigation Integrate logto-authkit with React Router, Next.js, or other routing libraries logto-authkit supports custom navigation to integrate seamlessly with your routing library, preventing full page reloads during authentication flows. ## Why Custom Navigation? By default, Logto uses `window.location.href` for navigation, which causes full page reloads. Custom navigation allows you to: * Use client-side routing (React Router, Next.js, etc.) * Maintain application state during auth flows * Provide a smoother user experience * Control navigation behavior in SPAs ## Basic Setup Pass a `customNavigate` function to the `AuthProvider`: ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' const config = { endpoint: 'https://your-logto-endpoint.com', appId: 'your-app-id', } function App() { const customNavigate = (url, options) => { // Your navigation logic here console.log('Navigating to:', url, options) } return ( ) } ``` ## React Router Integration ### React Router v6 ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import { BrowserRouter, useNavigate } from 'react-router-dom' function AppProviders({ children }) { const navigate = useNavigate() const customNavigate = (url, options) => { // Handle both relative and absolute URLs if (url.startsWith('http://') || url.startsWith('https://')) { // External URL - use window.location window.location.href = url } else { // Internal URL - use React Router navigate(url, { replace: options?.replace }) } } return ( {children} ) } function App() { return ( ) } ``` ### React Router v5 ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import { BrowserRouter, useHistory } from 'react-router-dom' function AppProviders({ children }) { const history = useHistory() const customNavigate = (url, options) => { if (url.startsWith('http://') || url.startsWith('https://')) { window.location.href = url } else { if (options?.replace) { history.replace(url) } else { history.push(url) } } } return ( {children} ) } ``` ## Next.js Integration ### App Router (Next.js 13+) ```tsx theme={null} 'use client' import { AuthProvider } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' function Providers({ children }) { const router = useRouter() const customNavigate = (url, options) => { if (url.startsWith('http://') || url.startsWith('https://')) { window.location.href = url } else { if (options?.replace) { router.replace(url) } else { router.push(url) } } } return ( {children} ) } ``` ### Pages Router (Next.js 12 and earlier) ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import { useRouter } from 'next/router' function MyApp({ Component, pageProps }) { const router = useRouter() const customNavigate = (url, options) => { if (url.startsWith('http://') || url.startsWith('https://')) { window.location.href = url } else { if (options?.replace) { router.replace(url) } else { router.push(url) } } } return ( ) } ``` ## Navigation Options The `customNavigate` function receives two parameters: The URL to navigate to (can be relative or absolute) Navigation options object: Whether to replace the current history entry instead of pushing a new one ## How It Works When you pass `customNavigate` to `AuthProvider`, it registers your navigation function globally for the library. When logto-authkit needs to navigate (e.g., after sign-in, during callback), it uses your custom function instead of `window.location.href`. When the `AuthProvider` unmounts, it automatically unregisters the custom navigation function. ## Implementation Details The custom navigation is set using the `setCustomNavigate` utility from the library: ```typescript theme={null} // From context.tsx (line 360-365) useEffect(() => { setCustomNavigate(customNavigate || null) // Cleanup on unmount return () => setCustomNavigate(null) }, [customNavigate]) ``` This ensures that: * The navigation function is available throughout the library * It's properly cleaned up when the component unmounts * You can dynamically change the navigation function if needed ## Best Practices Always check if the URL is external (starts with `http://` or `https://`) and use `window.location.href` for those cases. Authentication flows may redirect to external Logto servers. ```tsx theme={null} const customNavigate = (url, options) => { if (url.startsWith('http://') || url.startsWith('https://')) { window.location.href = url } else { // Use your router } } ``` Respect the `options.replace` parameter to allow the library to replace history entries when appropriate. ```tsx theme={null} const customNavigate = (url, options) => { if (options?.replace) { router.replace(url) } else { router.push(url) } } ``` Make sure your `customNavigate` function is stable (use `useCallback` if needed) to prevent unnecessary re-renders. ```tsx theme={null} const customNavigate = useCallback((url, options) => { // navigation logic }, [router]) ``` ## Popup Sign-In Custom navigation works seamlessly with popup sign-in mode: ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import { useNavigate } from 'react-router-dom' function AppProviders({ children }) { const navigate = useNavigate() const customNavigate = (url, options) => { if (url.startsWith('http://') || url.startsWith('https://')) { window.location.href = url } else { navigate(url, { replace: options?.replace }) } } return ( {children} ) } ``` When using popup sign-in, the main window navigation is handled by your custom function, while the popup window uses its own navigation context. ## Troubleshooting Make sure your `customNavigate` function is properly handling relative URLs. Check that you're not falling back to `window.location.href` for internal routes. Verify that your router hook is available in the component where you define `customNavigate`. For React Router, ensure you're inside a ``. For Next.js, make sure you're in a client component (`'use client'`). Check that your callback URL matches the route where you render ``. Mismatched URLs can cause redirect loops. ## Next Steps Learn more about AuthProvider configuration Learn more about the useAuth hook # Guest Mode Source: https://docs.ouim.me/logto-authkit/configuration/guest-mode Enable guest mode to support unauthenticated users with fingerprinting Guest mode allows your application to work with both authenticated and unauthenticated users, providing a seamless experience with built-in fingerprint-based guest IDs. ## Overview When guest mode is enabled: * Unauthenticated users automatically get a unique guest ID * Guest IDs are stored in the `guest_logto_authtoken` cookie for persistence * Your app can track guest users across sessions * Guest users can later sign in without losing their data ## Frontend Setup Use the `useAuth` hook with the `guest` middleware: ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function MixedContent() { const { user } = useAuth({ middleware: 'guest', // Allow guest users }) return (
{user ? (

Welcome back, {user.name}!

) : (

You're browsing as a guest

)}
) } ``` ### Middleware Options The `useAuth` hook supports different middleware modes: * `'auth'`: Require authentication (redirect if not logged in) * `'guest'`: Allow both authenticated and guest users URL to redirect to when authentication is required (used with `middleware: 'auth'`) ### Route Protection with Auth Middleware ```tsx theme={null} function ProtectedPage() { const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', // Redirect if not authenticated }) if (!user) return null // or loading indicator return
Protected content for {user.name}
} ``` ## Server Setup Enable guest mode in your server authentication middleware: ### Express.js ```javascript theme={null} import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server' const authMiddleware = createExpressAuthMiddleware({ logtoUrl: 'https://your-logto-domain.com', audience: 'your-api-resource-identifier', cookieName: 'logto_authtoken', allowGuest: true, // Enable guest mode }) app.get('/api/content', authMiddleware, (req, res) => { res.json({ userId: req.auth.userId, isAuthenticated: req.auth.isAuthenticated, isGuest: req.auth.isGuest, guestId: req.auth.guestId, // Available when isGuest is true }) }) ``` ### Next.js API Routes ```javascript theme={null} import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function GET(request) { const authResult = await verifyNextAuth(request, { logtoUrl: 'https://your-logto-domain.com', audience: 'your-api-resource-identifier', allowGuest: true, // Enable guest mode }) if (!authResult.success) { return Response.json({ error: authResult.error }, { status: 401 }) } return Response.json({ userId: authResult.auth.userId, isAuthenticated: authResult.auth.isAuthenticated, isGuest: authResult.auth.isGuest, guestId: authResult.auth.guestId, }) } ``` ## Auth Context Structure When guest mode is enabled, the auth context includes additional fields: ```typescript theme={null} interface AuthContext { userId: string | null // User ID from token (null for guests) isAuthenticated: boolean // true for authenticated users isGuest: boolean // true for guest users payload: AuthPayload | null // Full JWT payload (null for guests) guestId?: string // Guest fingerprint ID (when allowGuest is true) } ``` ## How Guest IDs Work When a user visits your site without authentication, logto-authkit automatically generates a unique guest ID using browser fingerprinting. The guest ID is stored in the browser's `guest_logto_authtoken` cookie, ensuring the same ID is used across sessions. When `allowGuest: true` is set, the server helpers can return a guest auth context when no valid JWT token is available, using the guest cookie to persist the guest ID. When a guest user signs in, they receive a proper JWT token. You can associate their previous guest activity with their authenticated account using the guest ID. ## Use Cases Allow users to add items to cart before signing in, then associate the cart with their account after login. Track guest preferences and browsing history, then merge with their profile when they sign in. Track user behavior for both authenticated and guest users with a consistent identifier. Let users explore your app as guests, then prompt them to sign in when they need advanced features. ## Example: Tracking Guest to User Conversion ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useEffect, useState } from 'react' function ProductPage() { const { user, isLoadingUser } = useAuth({ middleware: 'guest' }) const [guestId, setGuestId] = useState(null) useEffect(() => { // Store guest ID before user signs in if (!user && typeof window !== 'undefined') { const storedGuestId = document.cookie .split('; ') .find((entry) => entry.startsWith('guest_logto_authtoken=')) ?.split('=')[1] ?? null setGuestId(storedGuestId) } }, [user]) useEffect(() => { // User just signed in - migrate guest data if (user && guestId) { migrateGuestData(guestId, user.id) setGuestId(null) } }, [user, guestId]) async function migrateGuestData(guestId: string, userId: string) { // Call your API to associate guest data with the user await fetch('/api/migrate-guest-data', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guestId, userId }), }) } if (isLoadingUser) return
Loading...
return (
{user ? (

Welcome back, {user.name}!

) : (

Browsing as guest (ID: {guestId})

)}
) } ``` Guest IDs are stored in a browser cookie and can still be cleared by the user. Don't rely on them for critical functionality or security. They're best used for convenience features like cart persistence. ## Configuration Options Enable guest mode in server middleware Custom cookie name for both JWT tokens and guest IDs ## Next Steps Learn more about server authentication Explore all useAuth hook options # Next.js App Example Source: https://docs.ouim.me/logto-authkit/examples/nextjs-app Complete Next.js application example with logto-authkit This example demonstrates how to integrate logto-authkit into a Next.js application with both client and server-side authentication. ## Complete Example Install logto-authkit and required dependencies: ```bash theme={null} npm install @ouim/logto-authkit @logto/react ``` Set up the auth provider in your root layout: ```tsx app/layout.tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import './globals.css' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( {children} ) } ``` Build your landing page with authentication: ```tsx app/page.tsx theme={null} 'use client' import { useAuth, UserCenter } from '@ouim/logto-authkit' import Link from 'next/link' export default function Home() { const { user, isLoadingUser, signIn } = useAuth() return (

Welcome to logto-authkit

A simplified authentication solution for Next.js applications. Get started with secure, production-ready auth in minutes.

{isLoadingUser ? (
) : user ? (

Welcome back, {user.name || user.email}!

You are successfully authenticated.

Go to Dashboard
) : (

Sign in to access your personalized dashboard

)}
) } ```
Build a dashboard with automatic authentication protection: ```tsx app/dashboard/page.tsx theme={null} 'use client' import { useAuth, UserCenter } from '@ouim/logto-authkit' import Link from 'next/link' export default function Dashboard() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', }) if (isLoadingUser) { return (
) } return (

Dashboard

Welcome to your protected dashboard

User Profile

User ID
{user?.id}
Name
{user?.name || 'Not provided'}
Email
{user?.email || 'Not provided'}

Authentication Status

Authenticated

You have successfully authenticated and can access protected resources.

View API Response →
) } ```
Add a protected API route using server-side authentication: ```ts app/api/user/route.ts theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_ENDPOINT!, audience: process.env.LOGTO_API_RESOURCE!, }) if (!result.success) { return NextResponse.json( { error: result.error }, { status: 401 } ) } // Access authenticated user info const { auth } = result return NextResponse.json({ userId: auth.userId, isAuthenticated: auth.isAuthenticated, payload: auth.payload, }) } ``` Create the required callback and sign-in pages: ```tsx app/callback/page.tsx theme={null} 'use client' import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return ( { console.log('Authentication successful') }} onError={(error) => { console.error('Authentication failed:', error) }} /> ) } ``` ```tsx app/signin/page.tsx theme={null} 'use client' import { SignInPage } from '@ouim/logto-authkit' export default function SignIn() { return } ``` Create a `.env.local` file with your Logto configuration: ```bash .env.local theme={null} NEXT_PUBLIC_LOGTO_ENDPOINT=https://your-tenant.logto.app NEXT_PUBLIC_LOGTO_APP_ID=your-app-id NEXT_PUBLIC_API_RESOURCE=https://api.yourapp.com NEXT_PUBLIC_APP_URL=http://localhost:3000 # Server-side variables LOGTO_ENDPOINT=https://your-tenant.logto.app LOGTO_API_RESOURCE=https://api.yourapp.com ```
## Server-Side Authentication logto-authkit supports server-side authentication for API routes and middleware: ```ts app/api/protected/route.ts theme={null} import { NextRequest, NextResponse } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_ENDPOINT!, audience: process.env.LOGTO_API_RESOURCE!, }) if (!result.success) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ) } return NextResponse.json({ message: 'Protected data', userId: result.auth.userId, }) } ``` ```ts middleware.ts theme={null} import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { verifyNextAuth } from '@ouim/logto-authkit/server' export async function middleware(request: NextRequest) { // Only protect API routes if (request.nextUrl.pathname.startsWith('/api/protected')) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_ENDPOINT!, audience: process.env.LOGTO_API_RESOURCE!, }) if (!result.success) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ) } } return NextResponse.next() } export const config = { matcher: '/api/protected/:path*', } ``` ## Features Demonstrated * **Next.js App Router**: Full integration with App Router * **Client-Side Auth**: Protected pages with automatic redirects * **Server-Side Auth**: Secure API routes with JWT verification * **Environment Variables**: Proper configuration management * **Popup Sign-In**: Optional popup-based authentication * **User Center**: Pre-built UI component for user management ## Running the Example ```bash theme={null} npm run dev ``` Visit `http://localhost:3000` to see your Next.js app with authentication. ## Next Steps Advanced route protection patterns Learn more about server-side auth # Protected Routes Example Source: https://docs.ouim.me/logto-authkit/examples/protected-routes Learn how to implement protected routes and access control This guide demonstrates various patterns for protecting routes and implementing access control in your application using logto-authkit. ## Basic Route Protection The simplest way to protect a route is using the `middleware` option in the `useAuth` hook: ```tsx pages/Dashboard.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function Dashboard() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', }) if (isLoadingUser) { return
Loading...
} return (

Protected Dashboard

Welcome, {user?.name}!

) } ``` ## Advanced Protection Patterns Redirect authenticated users away from sign-in pages: ```tsx pages/Login.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function Login() { const { user, isLoadingUser } = useAuth({ middleware: 'guest', redirectIfAuthenticated: '/dashboard', }) if (isLoadingUser) { return
Loading...
} return (

Sign In

Please sign in to continue

) } ```
Use custom navigation options for more control: ```tsx pages/Settings.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function Settings() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', navigationOptions: { replace: true, // Use replaceState instead of pushState force: false, // Only navigate if not already on target page }, }) if (isLoadingUser) { return
Loading...
} return (

Settings

User ID: {user?.id}

) } ```
Implement complex access control logic: ```tsx pages/AdminPanel.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' export default function AdminPanel() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', }) const navigate = useNavigate() useEffect(() => { // Custom role-based access control if (!isLoadingUser && user) { const isAdmin = user.roles?.includes('admin') if (!isAdmin) { navigate('/unauthorized') } } }, [user, isLoadingUser, navigate]) if (isLoadingUser) { return
Loading...
} return (

Admin Panel

Welcome, Admin {user?.name}

) } ```
## Route Guard Component Create a reusable route guard component for cleaner code: ```tsx components/RouteGuard.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { ReactNode } from 'react' interface RouteGuardProps { children: ReactNode fallback?: ReactNode requireAuth?: boolean redirectTo?: string } export function RouteGuard({ children, fallback =
Loading...
, requireAuth = true, redirectTo = '/signin', }: RouteGuardProps) { const { user, isLoadingUser } = useAuth({ middleware: requireAuth ? 'auth' : undefined, redirectTo: requireAuth ? redirectTo : undefined, }) if (isLoadingUser) { return <>{fallback} } if (requireAuth && !user) { return null // Will redirect } return <>{children} } ```
```tsx pages/Profile.tsx theme={null} import { RouteGuard } from '../components/RouteGuard' import { useAuth } from '@ouim/logto-authkit' export default function Profile() { const { user } = useAuth() return (

User Profile

Name: {user?.name}

Email: {user?.email}

) } ```
## React Router Integration Protect routes at the router level: ```tsx App.tsx theme={null} import { AuthProvider, useAuth } from '@ouim/logto-authkit' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ReactNode } from 'react' // Protected route wrapper function ProtectedRoute({ children }: { children: ReactNode }) { const { user, isLoadingUser } = useAuth() if (isLoadingUser) { return (
) } if (!user) { return } return <>{children} } function App() { return ( } /> } /> } /> {/* Protected routes */} } /> } /> ) } ``` ## Next.js Route Protection For Next.js applications, use client-side protection: ```tsx app/dashboard/layout.tsx theme={null} 'use client' import { useAuth } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' import { useEffect } from 'react' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const { user, isLoadingUser } = useAuth() const router = useRouter() useEffect(() => { if (!isLoadingUser && !user) { router.push('/signin') } }, [user, isLoadingUser, router]) if (isLoadingUser) { return (
) } if (!user) { return null } return <>{children} } ```
```tsx pages/dashboard.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useRouter } from 'next/router' import { useEffect } from 'react' export default function Dashboard() { const { user, isLoadingUser } = useAuth() const router = useRouter() useEffect(() => { if (!isLoadingUser && !user) { router.push('/signin') } }, [user, isLoadingUser, router]) if (isLoadingUser) { return
Loading...
} if (!user) { return null } return (

Dashboard

Welcome, {user.name}!

) } ```
## Role-Based Access Control Implement role-based access control (RBAC): ```tsx hooks/useRBAC.ts theme={null} import { useAuth } from '@ouim/logto-authkit' import { useMemo } from 'react' type Role = 'admin' | 'user' | 'moderator' export function useRBAC() { const { user } = useAuth() const hasRole = useMemo(() => { return (role: Role) => { return user?.roles?.includes(role) ?? false } }, [user]) const hasAnyRole = useMemo(() => { return (roles: Role[]) => { return roles.some(role => user?.roles?.includes(role)) } }, [user]) const hasAllRoles = useMemo(() => { return (roles: Role[]) => { return roles.every(role => user?.roles?.includes(role)) } }, [user]) return { hasRole, hasAnyRole, hasAllRoles, userRoles: user?.roles || [], } } ``` Use the RBAC hook in your components: ```tsx pages/AdminDashboard.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useRBAC } from '../hooks/useRBAC' import { useNavigate } from 'react-router-dom' import { useEffect } from 'react' export default function AdminDashboard() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', }) const { hasRole } = useRBAC() const navigate = useNavigate() useEffect(() => { if (!isLoadingUser && user && !hasRole('admin')) { navigate('/unauthorized') } }, [user, isLoadingUser, hasRole, navigate]) if (isLoadingUser) { return
Loading...
} if (!hasRole('admin')) { return null } return (

Admin Dashboard

Admin-only content

) } ``` ## Loading States Handle loading states elegantly: ```tsx components/ProtectedPage.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { ReactNode } from 'react' interface ProtectedPageProps { children: ReactNode loadingComponent?: ReactNode } export function ProtectedPage({ children, loadingComponent, }: ProtectedPageProps) { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', }) if (isLoadingUser) { return ( <> {loadingComponent || (
)} ) } if (!user) { return null // Will redirect } return <>{children} } ``` ## Best Practices Before checking if a user exists, always verify `isLoadingUser` is `false` to avoid race conditions: ```tsx theme={null} const { user, isLoadingUser } = useAuth() if (isLoadingUser) { return } if (!user) { // Safe to redirect or show login } ``` The `middleware` option in `useAuth` provides automatic redirects: ```tsx theme={null} useAuth({ middleware: 'auth', // Require authentication redirectTo: '/signin', // Where to redirect if not authenticated }) ``` For cleaner code and better DX, implement protection at the router level rather than in each component. Always show loading indicators while authentication state is being determined: ```tsx theme={null} if (isLoadingUser) { return } ``` ## Next Steps See a complete React application Explore Next.js integration Learn more about the useAuth hook Implement server-side protection # React SPA Example Source: https://docs.ouim.me/logto-authkit/examples/react-spa Complete React single-page application example with logto-authkit This example demonstrates how to build a complete React SPA with authentication using logto-authkit. It includes user sign-in, protected routes, and user profile management. ## Complete Example First, install logto-authkit and its peer dependencies: ```bash theme={null} npm install @ouim/logto-authkit @logto/react ``` Wrap your app with the `AuthProvider` to enable authentication throughout your application: ```tsx App.tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' import { BrowserRouter, Routes, Route } from 'react-router-dom' import Home from './pages/Home' import Dashboard from './pages/Dashboard' import CallbackPage from './pages/Callback' import SignIn from './pages/SignIn' function App() { return ( } /> } /> } /> } /> ) } export default App ``` Build a landing page with sign-in functionality: ```tsx pages/Home.tsx theme={null} import { useAuth, UserCenter } from '@ouim/logto-authkit' import { useNavigate } from 'react-router-dom' export default function Home() { const { user, isLoadingUser, signIn } = useAuth() const navigate = useNavigate() if (isLoadingUser) { return (
) } return (

Welcome to logto-authkit

A simplified authentication solution for React applications

{user ? (

Hello, {user.name || user.email}!

) : ( )}
) } ```
Build a protected dashboard page that requires authentication: ```tsx pages/Dashboard.tsx theme={null} import { useAuth, UserCenter } from '@ouim/logto-authkit' import { useNavigate } from 'react-router-dom' export default function Dashboard() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/signin', }) const navigate = useNavigate() if (isLoadingUser) { return (
) } return (

Dashboard

User Information

ID
{user?.id}
Name
{user?.name || 'N/A'}
Email
{user?.email || 'N/A'}

Status

You are successfully authenticated and can access protected resources.

) } ```
Add the required authentication pages: ```tsx pages/Callback.tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return ( { console.log('Authentication successful!') }} onError={(error) => { console.error('Authentication error:', error) }} /> ) } ``` ```tsx pages/SignIn.tsx theme={null} import { SignInPage } from '@ouim/logto-authkit' export default function SignIn() { return } ```
## Features Demonstrated This example showcases: * **Authentication Provider**: Global auth state management * **Popup Sign-In**: Optional popup-based authentication flow * **User Center Component**: Pre-built user menu with avatar and sign-out * **Protected Routes**: Automatic redirection for unauthenticated users * **Loading States**: Proper handling of authentication loading states * **User Profile Display**: Accessing and displaying user information ## Running the Example ```bash theme={null} npm run dev ``` Visit `http://localhost:3000` to see the app in action. ## Next Steps Learn advanced patterns for protecting routes See how to use logto-authkit with Next.js # AuthProvider Source: https://docs.ouim.me/logto-authkit/frontend/auth-provider The main authentication provider component that wraps your application ## Overview The `AuthProvider` component is the core wrapper for logto-authkit authentication. It manages authentication state, handles sign-in/sign-out operations, and provides auth context to your entire application. ## Installation The `AuthProvider` is included in the logto-authkit package: ```bash theme={null} npm install @ouim/logto-authkit ``` ## Basic Usage Wrap your application with `AuthProvider` at the root level: ```tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' function App() { return ( {/* Your app components */} ) } ``` ## Props Logto configuration object containing authentication settings. Your Logto instance endpoint URL Your Logto application ID Array of API resource identifiers The URL to redirect to after authentication. Defaults to current page. ```tsx theme={null} ``` Enable popup-based sign-in instead of full page redirects. ```tsx theme={null} ``` Custom navigation function for framework-specific routing (e.g., Next.js router). Use replaceState instead of pushState Force navigation even if already on the same page ```tsx theme={null} import { useRouter } from 'next/router' function App() { const router = useRouter() return ( { if (options?.replace) { router.replace(url) } else { router.push(url) } }} > ) } ``` Your application components that need access to authentication context. ## Features ### Automatic State Management The `AuthProvider` automatically: * Fetches and updates user information when authentication state changes * Handles JWT token storage in cookies * Syncs authentication across browser tabs and windows * Refreshes auth state on window focus * Manages loading states during authentication operations ### Popup Sign-In Support When `enablePopupSignIn` is enabled, sign-in operations open in a popup window instead of redirecting the entire page: ```tsx theme={null} {children} ``` Popup sign-in provides a better user experience as it doesn't interrupt the user's current page state. ### Cross-Tab Synchronization Authentication state is automatically synchronized across browser tabs: * Sign in on one tab → all tabs update * Sign out on one tab → all tabs update * Uses localStorage events and custom event dispatching ### Error Handling The provider includes robust error handling: * Automatic logout on invalid/expired tokens * Rate limiting to prevent excessive API calls * Graceful fallback for popup authentication failures ## Advanced Usage ### Next.js App Router ```tsx app/layout.tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` ### With Custom Navigation (Next.js) ```tsx theme={null} 'use client' import { AuthProvider } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' export function AuthProviderWrapper({ children }: { children: React.ReactNode }) { const router = useRouter() return ( { if (options?.replace) { router.replace(url) } else { router.push(url) } }} > {children} ) } ``` ## Context Value The `AuthProvider` exposes the following context value (accessible via `useAuth` hook): ```typescript theme={null} interface AuthContextType { user: LogtoUser | null isLoadingUser: boolean signIn: (callbackUrl?: string, usePopup?: boolean) => Promise signOut: (options?: { callbackUrl?: string; global?: boolean }) => Promise refreshAuth: () => Promise enablePopupSignIn?: boolean } ``` ## Best Practices Always wrap your entire application with `AuthProvider` to ensure all components have access to authentication context. Store sensitive configuration like `endpoint` and `appId` in environment variables, not hardcoded in your source. For better user experience, enable `enablePopupSignIn` to avoid full page redirects during authentication. The provider includes built-in SSR handling with `ClientOnly` wrapper. No additional configuration needed for Next.js. ## Troubleshooting If you see "useAuthContext must be used within an AuthProvider" error, ensure the `AuthProvider` wraps your component tree. The provider automatically handles token refresh and manages authentication state across page navigations. ## Related Access authentication state and methods Handle authentication callbacks Pre-built user menu component Protect routes and pages # CallbackPage Source: https://docs.ouim.me/logto-authkit/frontend/callback-page Component for handling authentication callbacks after Logto redirect ## Overview The `CallbackPage` component handles the authentication callback after users are redirected back from Logto. It processes the authentication code, exchanges it for tokens, and manages the post-authentication flow including popup and redirect scenarios. ## Installation ```bash theme={null} npm install @ouim/logto-authkit ``` ## Basic Usage Create a callback route in your application: ```tsx app/callback/page.tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return } ``` ## Props Additional CSS classes to apply to the container element. ```tsx theme={null} ``` Custom component to display while processing authentication. ```tsx theme={null}

Please wait...

} /> ```
Custom component to display after successful authentication. ```tsx theme={null}

Success! Taking you to your dashboard...

} /> ```
Callback function executed after successful authentication, before redirect/close. ```tsx theme={null} { console.log('Authentication successful!') // Track analytics, etc. }} /> ``` Callback function executed if authentication fails. ```tsx theme={null} { console.error('Auth failed:', error) // Show error message, redirect to error page, etc. }} /> ``` ## How It Works The `CallbackPage` component: 1. **Receives the auth code** from Logto redirect URL 2. **Exchanges code for tokens** using `useHandleSignInCallback()` from `@logto/react` 3. **Detects the flow type** (popup vs. redirect) 4. **Handles completion**: * **Popup flow**: Sends message to parent window and closes * **Redirect flow**: Redirects to home page (`/`) ## Flow Detection The component automatically detects whether it's handling a popup or redirect flow: ```typescript theme={null} const isPopup = (window.opener && window.opener !== window) || sessionStorage.getItem('simple_logto_popup_flow') === 'true' ``` * Checks if the window has an `opener` (parent window) * Falls back to `sessionStorage` flag for cross-origin scenarios ## Popup Flow For popup-based authentication: 1. Component detects it's in a popup 2. Processes authentication 3. Sends `SIGNIN_SUCCESS` message to parent window 4. Closes the popup ```typescript theme={null} // Sends message to parent window.opener.postMessage({ type: 'SIGNIN_SUCCESS' }, window.location.origin) // Closes popup after small delay setTimeout(() => { window.close() }, 100) ``` ### Fallback Mechanism If `window.opener` is unavailable (some browsers clear it), falls back to localStorage: ```typescript theme={null} localStorage.setItem('simple_logto_signin_complete', Date.now().toString()) ``` The parent window listens for both `postMessage` and `localStorage` events to handle popup completion. ## Redirect Flow For full-page redirect authentication: 1. Component detects it's NOT in a popup 2. Processes authentication 3. Redirects to home page (`/`) ```typescript theme={null} if (!isPopup) { window.location.href = '/' } ``` ## Examples ### Basic Setup (Next.js App Router) ```tsx app/callback/page.tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return } ``` ### With Custom Loading State ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' import { Loader2 } from 'lucide-react' export default function Callback() { return (

Signing you in...

Please wait a moment

} /> ) } ``` ### With Success Tracking ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' export default function Callback() { const router = useRouter() return ( { // Track successful authentication if (typeof window !== 'undefined' && window.analytics) { window.analytics.track('User Signed In', { method: 'logto', timestamp: new Date().toISOString(), }) } }} onError={error => { console.error('Authentication error:', error) // Redirect to error page router.push('/auth/error') }} /> ) } ``` ### Custom Styling ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return (

Authenticating...

} />
) } ``` ### With Custom Redirect ```tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return ( { // Check if this is popup flow const isPopup = (window.opener && window.opener !== window) || sessionStorage.getItem('simple_logto_popup_flow') === 'true' if (!isPopup) { // Custom redirect for non-popup flow window.location.href = '/dashboard' } }} /> ) } ``` ## Default UI If no custom components are provided, the callback page displays: ### Loading State ```tsx theme={null}
{/* Animated spinner */}
Signing you in...
``` ### Success State ```tsx theme={null}
Authentication complete! Redirecting...
``` ## Session Storage Flag For popup flows, your sign-in page should set a flag: ```tsx app/signin/page.tsx theme={null} 'use client' import { useEffect } from 'react' import { useAuth } from '@ouim/logto-authkit' import { useSearchParams } from 'next/navigation' export default function SignIn() { const { signIn } = useAuth() const searchParams = useSearchParams() const isPopup = searchParams.get('popup') === 'true' useEffect(() => { if (isPopup) { sessionStorage.setItem('simple_logto_popup_flow', 'true') } signIn('/callback') }, [signIn, isPopup]) return
Redirecting to sign in...
} ``` ## Error Handling The component handles errors during authentication: ```typescript theme={null} try { // Process authentication } catch (error) { console.error('Authentication callback error:', error) if (onError) { onError(error as Error) } } ``` Common errors: * Invalid authorization code * Token exchange failure * Network errors * CORS issues ## Best Practices Always use a dedicated route like `/callback` or `/auth/callback` for handling authentication callbacks. Make sure your Logto application's redirect URI matches your callback route exactly. The component automatically handles both popup and redirect flows - no additional configuration needed. Use the `onError` callback to track authentication failures in your monitoring system. Provide custom loading and success components that match your application's design. ## Configuration in Logto In your Logto application settings, add your callback URL: ``` https://yourdomain.com/callback ``` For local development: ``` http://localhost:3000/callback ``` ## Troubleshooting **Popup doesn't close**: Ensure your sign-in page sets the `simple_logto_popup_flow` sessionStorage flag when `?popup=true` is in the URL. **Infinite redirect loop**: Check that your Logto redirect URI exactly matches your callback route. **CORS errors**: Ensure your Logto application's allowed origins include your application's domain. The component automatically cleans up the `simple_logto_popup_flow` flag after successful authentication. ## Related Configure authentication provider Access sign-in functionality User menu with sign-in button Protect authenticated routes # Route Protection Source: https://docs.ouim.me/logto-authkit/frontend/route-protection Patterns and best practices for protecting routes and pages ## Overview Route protection ensures that certain pages are only accessible to authenticated users, while others are restricted to guests. logto-authkit provides multiple patterns for implementing route protection. ## Protection Methods There are three main approaches to route protection: 1. **Middleware option in `useAuth` hook** (recommended) 2. **Manual checks with conditional rendering** 3. **Higher-order components (HOC)** ## Method 1: Middleware Option (Recommended) The simplest and most declarative approach using the `useAuth` hook's built-in middleware. ### Protected Route (Auth Required) ```tsx app/dashboard/page.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function DashboardPage() { const { user } = useAuth({ middleware: 'auth', redirectTo: '/login' }) // This will only render if user is authenticated // Otherwise, automatically redirects to '/login' return (

Dashboard

Welcome, {user?.name}!

) } ``` ### Guest-Only Route ```tsx app/login/page.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function LoginPage() { const { signIn } = useAuth({ middleware: 'guest', redirectIfAuthenticated: '/dashboard' }) // This will only render if user is NOT authenticated // Otherwise, automatically redirects to '/dashboard' return (

Sign In

) } ``` ## Method 2: Manual Checks For more control over the protection logic and UI. ### With Loading and Redirect ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' import { useEffect } from 'react' export default function ProtectedPage() { const { user, isLoadingUser } = useAuth() const router = useRouter() useEffect(() => { if (!isLoadingUser && !user) { router.push('/login') } }, [user, isLoadingUser, router]) if (isLoadingUser) { return
Loading...
} if (!user) { return null // or return
Redirecting...
} return (

Protected Content

Only authenticated users see this

) } ``` ### With Error Message ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function ProtectedPage() { const { user, isLoadingUser, signIn } = useAuth() if (isLoadingUser) { return (
) } if (!user) { return (

Authentication Required

Please sign in to access this page

) } return (

Protected Content

) } ``` ## Method 3: Higher-Order Component Create reusable protection wrappers. ### Protected Page HOC ```tsx lib/withAuth.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' import { useEffect, ComponentType } from 'react' export function withAuth

( Component: ComponentType

, redirectTo: string = '/login' ) { return function ProtectedComponent(props: P) { const { user, isLoadingUser } = useAuth() const router = useRouter() useEffect(() => { if (!isLoadingUser && !user) { router.push(redirectTo) } }, [user, isLoadingUser, router]) if (isLoadingUser) { return (

) } if (!user) { return null } return } } // Usage import { withAuth } from '@/lib/withAuth' function DashboardPage() { return
Dashboard Content
} export default withAuth(DashboardPage) ``` ### Guest-Only HOC ```tsx lib/withGuest.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' import { useEffect, ComponentType } from 'react' export function withGuest

( Component: ComponentType

, redirectTo: string = '/dashboard' ) { return function GuestComponent(props: P) { const { user, isLoadingUser } = useAuth() const router = useRouter() useEffect(() => { if (!isLoadingUser && user) { router.push(redirectTo) } }, [user, isLoadingUser, router]) if (isLoadingUser) { return (

) } if (user) { return null } return } } // Usage import { withGuest } from '@/lib/withGuest' function LoginPage() { return
Login Content
} export default withGuest(LoginPage) ``` ## Advanced Patterns ### Role-Based Protection ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' import { useRouter } from 'next/navigation' import { useEffect } from 'react' export default function AdminPage() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/login' }) const router = useRouter() useEffect(() => { if (!isLoadingUser && user && user.role !== 'admin') { router.push('/unauthorized') } }, [user, isLoadingUser, router]) if (isLoadingUser) { return
Loading...
} if (!user || user.role !== 'admin') { return null } return (

Admin Dashboard

Admin-only content

) } ``` ### Permission-Based Protection ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function hasPermission(user: any, permission: string): boolean { return user?.permissions?.includes(permission) ?? false } export default function SettingsPage() { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/login' }) if (isLoadingUser) { return
Loading...
} const canEditSettings = hasPermission(user, 'settings:write') const canViewSettings = hasPermission(user, 'settings:read') if (!canViewSettings) { return (

Access Denied

You don't have permission to view settings

) } return (

Settings

{canEditSettings ? ( ) : (

Read-only view

)}
) } ``` ### Conditional Component Rendering ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function ProtectedSection({ children }: { children: React.ReactNode }) { const { user, isLoadingUser } = useAuth() if (isLoadingUser) { return
Loading...
} if (!user) { return null // Don't render anything } return <>{children} } // Usage export default function Page() { return (

Public Content

Everyone can see this

Members Only

Only authenticated users see this section

) } ``` ### Layout-Level Protection ```tsx app/dashboard/layout.tsx theme={null} 'use client' import { useAuth } from '@ouim/logto-authkit' export default function DashboardLayout({ children, }: { children: React.ReactNode }) { const { user, isLoadingUser } = useAuth({ middleware: 'auth', redirectTo: '/login' }) if (isLoadingUser) { return (
) } return ( ) } ``` ## Navigation Options Control how redirects behave: ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function Page() { const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', navigationOptions: { replace: true, // Replace history instead of push force: true // Force navigation even if on same page } }) return
Protected content
} ``` ## Server-Side Protection (Next.js) For server components and API routes: ```tsx app/api/protected/route.ts theme={null} import { cookies } from 'next/headers' import { NextResponse } from 'next/server' export async function GET() { const cookieStore = cookies() const token = cookieStore.get('logto_access_token') if (!token) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ) } // Verify token and proceed return NextResponse.json({ data: 'Protected data' }) } ``` ## Best Practices The `middleware` option in `useAuth` is the simplest and most declarative way to protect routes. Show a loading indicator while `isLoadingUser` is `true` to prevent UI flashing. For sections with multiple protected pages, protect the layout instead of each page individually. When access is denied, show a clear message and provide a way to authenticate. Create reusable HOCs to ensure consistent protection logic across your app. For sensitive data, protect both the UI (client) and API routes (server). ## Common Patterns Summary | Pattern | Use Case | Complexity | | --------------------- | ---------------------- | ---------- | | `middleware: 'auth'` | Require authentication | Low | | `middleware: 'guest'` | Guest-only pages | Low | | Manual checks | Custom logic needed | Medium | | HOC | Reusable protection | Medium | | Role-based | Multiple user types | High | | Permission-based | Fine-grained access | High | ## Troubleshooting **Infinite redirect loop**: Ensure `redirectTo` and `redirectIfAuthenticated` point to pages with different middleware settings. **Flash of unauthenticated content**: Always check `isLoadingUser` before rendering protected content. The `useAuth` hook waits for loading to complete before performing middleware redirects, preventing race conditions. ## Related Complete hook documentation Configure authentication provider Pre-built user menu component Handle authentication callbacks # useAuth Source: https://docs.ouim.me/logto-authkit/frontend/use-auth React hook for accessing authentication state and methods ## Overview The `useAuth` hook provides access to the current user's authentication state and authentication methods. It also supports middleware for route protection and automatic redirects. ## Installation ```bash theme={null} npm install @ouim/logto-authkit ``` ## Basic Usage ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function MyComponent() { const { user, isLoadingUser, signIn, signOut } = useAuth() if (isLoadingUser) { return
Loading...
} if (user) { return (

Welcome, {user.name}!

) } return } ``` ## Parameters The hook accepts an optional `options` object: Configuration object for authentication behavior and route protection. Route protection middleware type: * `'auth'`: Requires authentication (redirects unauthenticated users) * `'guest'`: Guest-only route (redirects authenticated users) * `undefined`: No protection ```tsx theme={null} const { user } = useAuth({ middleware: 'auth' }) ``` URL to redirect to when `middleware: 'auth'` and user is not authenticated. ```tsx theme={null} const { user } = useAuth({ middleware: 'auth', redirectTo: '/login' }) ``` URL to redirect to when `middleware: 'guest'` and user is authenticated. ```tsx theme={null} const { user } = useAuth({ middleware: 'guest', redirectIfAuthenticated: '/dashboard' }) ``` Options for navigation behavior. Use replaceState instead of pushState Force navigation even if already on the same page ```tsx theme={null} const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', navigationOptions: { replace: true } }) ``` ## Return Value The hook returns an `AuthContextType` object: ```typescript theme={null} interface AuthContextType { user: LogtoUser | null isLoadingUser: boolean signIn: (callbackUrl?: string, usePopup?: boolean) => Promise signOut: (options?: { callbackUrl?: string; global?: boolean }) => Promise refreshAuth: () => Promise enablePopupSignIn?: boolean } ``` Current authenticated user object, or `null` if not authenticated. ```typescript theme={null} type LogtoUser = { id: string name?: string email?: string avatar?: string [key: string]: any } ``` Loading state indicator. `true` while fetching user data, `false` when complete. Function to initiate sign-in flow. - `callbackUrl`: Optional URL to redirect to after authentication - `usePopup`: Override the `enablePopupSignIn` setting from `AuthProvider` `tsx // Default sign-in await signIn() // With custom callback URL await signIn('/dashboard') // Force popup mode await signIn('/callback', true) // Force redirect mode await signIn('/callback', false) ` Function to sign out the current user. * `callbackUrl`: Optional URL to redirect to after sign-out * `global`: Whether to perform global sign-out (default: `true`) ```tsx theme={null} // Default global sign-out await signOut() // With custom redirect await signOut({ callbackUrl: '/goodbye' }) // Local sign-out only await signOut({ global: false }) // Local sign-out with redirect await signOut({ callbackUrl: '/login', global: false }) ``` Function to manually refresh the authentication state. `tsx // Refresh user data await refreshAuth() ` Whether popup sign-in is enabled (from `AuthProvider` configuration). ## Examples ### Display User Information ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function UserProfile() { const { user, isLoadingUser } = useAuth() if (isLoadingUser) { return
Loading user...
} if (!user) { return
Please sign in
} return (
{user.name}

{user.name}

{user.email}

User ID: {user.id}

) } ``` ### Protected Page ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function DashboardPage() { const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', }) // No need to check if user exists - middleware handles it return (

Dashboard

Welcome, {user?.name}!

) } ``` ### Guest-Only Page ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' export default function LoginPage() { const { signIn } = useAuth({ middleware: 'guest', redirectIfAuthenticated: '/dashboard', }) return (

Sign In

) } ``` ### Custom Sign-In Button ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function SignInButton() { const { signIn, isLoadingUser } = useAuth() return ( ) } ``` ### Sign-Out with Confirmation ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function SignOutButton() { const { signOut } = useAuth() const handleSignOut = async () => { if (confirm('Are you sure you want to sign out?')) { await signOut({ callbackUrl: '/' }) } } return } ``` ### Conditional Rendering ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function Navigation() { const { user, signIn, signOut } = useAuth() return ( ) } ``` ### Manual Auth Refresh ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function ProfilePage() { const { user, refreshAuth } = useAuth({ middleware: 'auth' }) const handleProfileUpdate = async () => { // Update profile via API await updateProfile({ name: 'New Name' }) // Refresh auth state to get updated user data await refreshAuth() } return (

{user?.name}

) } ``` ### With Loading State ```tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function App() { const { user, isLoadingUser, signIn, signOut } = useAuth() return (
{isLoadingUser ? (
) : user ? ( ) : ( )}
) } ``` ## Middleware Behavior ### `middleware: 'auth'` Protects routes that require authentication: ```tsx theme={null} const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', }) // If user is NOT authenticated → redirects to '/login' // If user IS authenticated → renders normally ``` ### `middleware: 'guest'` Protects routes that should only be accessible to unauthenticated users: ```tsx theme={null} const { user } = useAuth({ middleware: 'guest', redirectIfAuthenticated: '/dashboard', }) // If user IS authenticated → redirects to '/dashboard' // If user is NOT authenticated → renders normally ``` ### No Middleware ```tsx theme={null} const { user } = useAuth() // No automatic redirects // Manually check user state and handle accordingly ``` ## SSR Considerations The hook waits for client-side mounting and `isLoadingUser` to be `false` before performing middleware redirects, preventing hydration mismatches. ```tsx theme={null} useEffect(() => { if (isLoadingUser) return // Wait for loading to complete // Perform middleware checks and redirects }, [user, isLoadingUser, memoizedOptions]) ``` ## Best Practices Instead of manually checking `user` and redirecting, use the built-in `middleware` option for cleaner code. Always handle the `isLoadingUser` state to prevent UI flashing and provide better UX. The hook internally memoizes options, but you can also wrap your options in `useMemo` if they depend on other state. Wrap `signOut` calls in try-catch blocks to handle potential errors gracefully. ## TypeScript Support The hook is fully typed: ```typescript theme={null} import { useAuth, type AuthOptions, type AuthContextType } from '@ouim/logto-authkit' const options: AuthOptions = { middleware: 'auth', redirectTo: '/login', } const auth: AuthContextType = useAuth(options) ``` ## Troubleshooting **"useAuthContext must be used within an AuthProvider"**: Ensure your component is wrapped with `AuthProvider`. **Infinite redirect loop**: Check that `redirectTo` and `redirectIfAuthenticated` point to different routes and don't conflict. The hook automatically refreshes auth state when: - Window regains focus - Storage events occur (cross-tab sync) - Custom `auth-state-changed` events are dispatched ## Related Configure authentication provider Advanced route protection patterns Pre-built user menu component Handle authentication callbacks # UserCenter Source: https://docs.ouim.me/logto-authkit/frontend/user-center Pre-built dropdown user menu component with sign-in/sign-out functionality ## Overview The `UserCenter` component is a ready-to-use dropdown menu that displays user information and provides authentication controls. It automatically adapts based on authentication state, showing either a sign-in button or user profile menu. ## Installation ```bash theme={null} npm install @ouim/logto-authkit ``` ## Basic Usage ```tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' function Header() { return (
) } ``` ## Props Additional CSS classes to apply to the avatar element. \`\`\`tsx ```` Whether to perform a global sign-out (logs out from entire Logto ecosystem) or local-only sign-out. ```tsx ```` URL to redirect to after sign-out. Defaults to current page. \`\`\`tsx ```` Custom theme classes for the dropdown menu styling. ```tsx ```` Additional menu items to show in the dropdown before the sign-out button. ```typescript theme={null} interface AdditionalPage { link: string // URL to navigate to text: string // Display text for the menu item icon?: React.ReactNode // Optional icon component } ``` ```tsx theme={null} import { Settings, CreditCard } from 'lucide-react' }, { link: '/billing', text: 'Billing', icon: } ]} /> ``` ## States ### Loading State While authentication state is loading, displays a pulsing skeleton: ```tsx theme={null}
``` ### Authenticated State When a user is signed in, shows: * User avatar (if available) or initials * Dropdown menu with: * User name and email * Additional custom pages (if configured) * Sign out button ### Unauthenticated State When no user is signed in, shows: * Generic user icon * Dropdown menu with sign-in button ## Examples ### Basic Implementation ```tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' export default function Header() { return (

My App

) } ``` ### With Custom Pages ```tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' import { Settings, User, CreditCard } from 'lucide-react' export default function Navigation() { return ( ) } ``` ### Custom Styling ```tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' export default function ThemedHeader() { return (
) } ``` ### Local Sign-Out Only ```tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' export default function Header() { return (
) } ``` ## User Data Display The component automatically displays: * **User Avatar**: If `user.avatar` exists, shows the avatar image * **User Initials**: If no avatar, generates initials from `user.name` * **User Name**: Displays as the main label in the dropdown * **User Email**: Shows below the name in a smaller, muted text ```typescript theme={null} // User object structure interface LogtoUser { id: string name?: string email?: string avatar?: string [key: string]: any } ``` ## Behavior ### Sign-In Flow 1. User clicks on the guest avatar 2. Dropdown shows "Sign in to your account" 3. Click "Sign in" button 4. Triggers `signIn()` from `useAuth` hook 5. Redirects to Logto authentication ### Sign-Out Flow 1. User clicks "Sign out" button 2. Calls `signOut({ callbackUrl, global })` from `useAuth` hook 3. If `globalSignOut={true}`: Logs out from entire Logto ecosystem 4. If `globalSignOut={false}`: Only clears local session 5. Redirects to `signoutCallbackUrl` or current page ## Customization ### Custom Icons You can use any icon library with `additionalPages`: ```tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' import { IoSettingsOutline, IoCreditCardOutline } from 'react-icons/io5' ;, }, { link: '/billing', text: 'Billing', icon: , }, ]} /> ``` ### Dark Mode Support The default theme classes support dark mode: ```tsx theme={null} // Default theme themeClassnames = 'dark:bg-[#171717] dark:text-slate-200 bg-white text-slate-900' ``` Customize for your design system: ```tsx theme={null} ``` ## SSR Considerations The component includes built-in SSR handling to prevent hydration mismatches. It waits for client-side mounting before rendering user-specific content. ```tsx theme={null} // Internal handling const [hasMounted, setHasMounted] = useState(false) useEffect(() => { setHasMounted(true) }, []) if (!hasMounted || isLoadingUser) { return } ``` ## Dependencies The component requires these shadcn/ui components: * `Avatar`, `AvatarFallback`, `AvatarImage` * `DropdownMenu` family * `Button` Icons from `lucide-react`: * `User` * `LogOut` * `UserCircle` ## Best Practices Ensure `UserCenter` is used within an `AuthProvider` to access authentication context. Set `signoutCallbackUrl` to control where users land after signing out. Keep `globalSignOut={true}` (default) for better security, clearing all sessions across devices. Use `additionalPages` to provide quick access to user-related pages like settings, profile, and billing. ## Troubleshooting If the component doesn't show user information after sign-in, ensure the `AuthProvider` is properly configured and wrapping your app. For popup sign-in, make sure your `/signin` page properly handles the `?popup=true` query parameter. ## Related Main authentication provider Access authentication in any component Handle authentication callbacks Protect routes from unauthorized access # Installation Source: https://docs.ouim.me/logto-authkit/installation Install logto-authkit using your preferred package manager # Installation logto-authkit is available as a single npm package that includes both frontend and server features. ## Install the package ```bash npm theme={null} npm install @ouim/logto-authkit ``` ```bash yarn theme={null} yarn add @ouim/logto-authkit ``` ```bash pnpm theme={null} pnpm add @ouim/logto-authkit ``` ## Peer dependencies logto-authkit requires the following peer dependencies for frontend features: * `@logto/react` - ^3.0.0 || ^4.0.0 * `react` - ^17.0.0 || ^19.0.0 * `react-dom` - ^17.0.0 || ^19.0.0 These should be installed automatically by your package manager, but if not, install them manually: ```bash npm theme={null} npm install @logto/react react react-dom ``` ```bash yarn theme={null} yarn add @logto/react react react-dom ``` ```bash pnpm theme={null} pnpm add @logto/react react react-dom ``` Server features work independently and don't require React dependencies. ## Package exports logto-authkit provides three main exports: ### Main entry (frontend) ```typescript theme={null} import { AuthProvider, useAuth, UserCenter, CallbackPage } from '@ouim/logto-authkit' ``` Includes all frontend components and hooks for React applications. ### Server entry ```typescript theme={null} import { verifyAuth, createExpressAuthMiddleware, verifyNextAuth } from '@ouim/logto-authkit/server' ``` Includes JWT verification, Express middleware, and Next.js helpers. ### Bundler config entry ```typescript theme={null} import { viteConfig, webpackConfig, nextjsConfig } from '@ouim/logto-authkit/bundler-config' ``` Pre-configured bundler settings for Vite, Webpack, and Next.js. When editing build-time scripts, import from `@ouim/logto-authkit/bundler-config` to avoid executing the main library bundle. ## TypeScript support logto-authkit is written in TypeScript and provides comprehensive type definitions out of the box. ```typescript theme={null} import type { LogtoUser, AuthOptions, AuthMiddleware, CallbackPageProps, NavigationOptions, AdditionalPage } from '@ouim/logto-authkit' // Server types import type { AuthContext, AuthPayload, VerifyAuthOptions } from '@ouim/logto-authkit/server' ``` ## Next steps Get up and running with authentication in minutes # Introduction Source: https://docs.ouim.me/logto-authkit/introduction A simpler way to use @logto/react with prebuilt UI components and hooks for fast authentication setup in React apps # Welcome to logto-authkit A plug-and-play authentication solution that simplifies [@logto/react](https://github.com/logto-io/logto) with prebuilt UI components and hooks for fast authentication setup in React applications. logto-authkit is designed to save you the hassle of setting up authentication from scratch and connecting frontend and server authentication flows. Start quickly with minimal configuration, and migrate to the official Logto SDK later if you need more advanced features. ## Key features Prebuilt AuthProvider, UserCenter, CallbackPage, and useAuth hook for seamless React integration JWT verification with JWKS caching, Express middleware, and Next.js support Built-in guest user support with fingerprinting for anonymous access Pre-configured settings for Vite, Webpack, and Next.js ## Frontend features * **AuthProvider** - Easy context provider for Logto authentication with custom navigation support * **UserCenter** - Prebuilt user dropdown/avatar component for your navbar * **CallbackPage** - Handles OAuth callback and popup flows automatically * **useAuth** - React hook for accessing user data and auth actions with middleware support * **Custom navigation** - Integrates with React Router, Next.js, and other routing libraries * **Guest mode** - Built-in guest user support with fingerprinting ## Server features * **JWT verification** - Manual JWT verification with JWKS caching * **Express.js middleware** - Ready-to-use Express middleware with built-in cookie parsing * **Next.js support** - API routes and middleware helpers for Next.js applications * **TypeScript support** - Full TypeScript definitions included ## Guest mode Allow users to browse your app without authentication using the built-in guest mode with fingerprinting: ```tsx src/components/Dashboard.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function Dashboard() { const { user } = useAuth({ middleware: 'guest', // Allow guest users }) return (
{user ? (

Welcome back, {user.name}!

) : (

You're browsing as a guest

)}
) } ``` ## Bundler support Pre-configured bundler settings resolve common issues with the `jose` library and other dependencies: * **Vite** - Pre-configured Vite settings via `viteConfig` * **Webpack** - Webpack configuration helpers via `webpackConfig` * **Next.js** - Next.js bundler configuration via `nextjsConfig` ## Get started Install logto-authkit using npm, yarn, or pnpm Get up and running with authentication in minutes # Quick start Source: https://docs.ouim.me/logto-authkit/quickstart Get up and running with logto-authkit authentication in minutes # Quick start This guide will help you set up logto-authkit authentication in your React application in just a few steps. ## Prerequisites Before you begin, make sure you have: * A Logto account and application configured * Your Logto endpoint URL and app ID * logto-authkit installed in your project Don't have a Logto account? [Sign up for free](https://logto.io) and create your first application. ## Setup steps Wrap your application with the `AuthProvider` component and pass your Logto configuration: ```tsx src/App.tsx theme={null} import { AuthProvider } from '@ouim/logto-authkit' const config = { endpoint: 'https://your-logto-endpoint.com', appId: 'your-app-id', } function App() { return ( ) } ``` For single-page applications, pass a `customNavigate` function to integrate with your router: ```tsx theme={null} { // Your navigation logic here }} > ``` Drop the `UserCenter` component into your navbar for a ready-to-use user menu: ```tsx src/components/Navbar.tsx theme={null} import { UserCenter } from '@ouim/logto-authkit' function Navbar() { return ( ) } ``` The `UserCenter` component automatically shows: * Avatar, name, and sign out button when authenticated * Sign in button when not authenticated Customize the `UserCenter` with additional pages: ```tsx theme={null} ``` Create a route at `/callback` and render the `CallbackPage` component to handle OAuth redirects: ```tsx src/pages/Callback.tsx theme={null} import { CallbackPage } from '@ouim/logto-authkit' export default function Callback() { return } ``` The `CallbackPage` handles: * OAuth callback flow * Popup authentication flow * Redirects after successful authentication Customize the callback experience with optional props: ```tsx theme={null} console.log('Auth success!')} onError={(error) => console.error('Auth error:', error)} loadingComponent={} /> ``` Access user data and authentication actions anywhere in your app with the `useAuth` hook: ```tsx src/components/Dashboard.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function Dashboard() { const { user, isLoadingUser, signIn, signOut } = useAuth() if (isLoadingUser) return
Loading...
if (!user) return return (

Welcome, {user.name}!

) } ```
## Route protection Protect routes by requiring authentication with the `middleware` option: ```tsx src/pages/ProtectedPage.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function ProtectedPage() { const { user } = useAuth({ middleware: 'auth', redirectTo: '/login', // Redirect if not authenticated }) if (!user) return null // or loading indicator return
Protected content
} ``` ## Guest mode Allow anonymous users to access your app with guest mode: ```tsx src/pages/PublicPage.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function PublicPage() { const { user } = useAuth({ middleware: 'guest', // Allow guest users }) return (
{user ? (

Welcome back, {user.name}!

) : (

You're browsing as a guest

)}
) } ``` ## Refresh authentication Manually refresh the authentication state when needed: ```tsx src/components/RefreshButton.tsx theme={null} import { useAuth } from '@ouim/logto-authkit' function RefreshButton() { const { refreshAuth } = useAuth() return ( ) } ``` ## Popup sign-in (optional) Enable popup-based sign-in for a better user experience: ```tsx src/App.tsx theme={null} ``` With popup sign-in enabled: * Authentication happens in a popup window * Main page doesn't reload * Users stay on the same page after signing in Popup sign-in requires a `/signin` route. Make sure to create one if using this feature. ## Next steps Secure your API endpoints with JWT verification Configure Vite, Webpack, or Next.js bundlers # Express Middleware Source: https://docs.ouim.me/logto-authkit/server/express-middleware Protect Express.js routes with Logto authentication ## Quick Start The `createExpressAuthMiddleware` function creates an Express middleware that automatically verifies Logto tokens and attaches user information to the request object. ```javascript theme={null} import express from 'express'; import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server'; const app = express(); const authMiddleware = createExpressAuthMiddleware({ logtoUrl: 'https://your-tenant.logto.app', audience: 'https://api.yourapp.com', }); // Protected route app.get('/api/user/profile', authMiddleware, (req, res) => { // Access authenticated user info const { userId, payload } = req.auth; res.json({ userId, email: payload.email, name: payload.name, }); }); app.listen(3000); ``` ## Configuration The middleware accepts a `VerifyAuthOptions` object: Your Logto server URL ```typescript theme={null} logtoUrl: 'https://your-tenant.logto.app' ``` The API resource identifier you registered in Logto ```typescript theme={null} audience: 'https://api.yourapp.com' ``` Custom cookie name if you changed it in the frontend ```typescript theme={null} cookieName: 'my_custom_token' ``` Require a specific scope to be present in the token ```typescript theme={null} requiredScope: 'read:admin' ``` Allow unauthenticated users with guest context ```typescript theme={null} allowGuest: true ``` ## Request Object The middleware adds an `auth` property to the Express request: ```typescript theme={null} interface ExpressRequest { auth?: AuthContext; // ... other Express request properties } interface AuthContext { userId: string | null; // Logto user ID isAuthenticated: boolean; // true if authenticated payload: AuthPayload | null; // Full JWT claims isGuest?: boolean; // true in guest mode guestId?: string; // UUID for guest users } ``` ## Usage Examples ### Basic Protected Route ```javascript theme={null} import { createExpressAuthMiddleware } from '@ouim/logto-authkit/server'; const auth = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, }); app.get('/api/protected', auth, (req, res) => { res.json({ message: 'This is protected!', userId: req.auth.userId }); }); ``` ### Route with Required Scope ```javascript theme={null} const adminAuth = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, requiredScope: 'admin:write', }); app.delete('/api/admin/users/:id', adminAuth, (req, res) => { // Only users with 'admin:write' scope can access res.json({ success: true }); }); ``` ### Multiple Middleware Use different authentication requirements for different routes: ```javascript theme={null} const basicAuth = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, }); const adminAuth = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, requiredScope: 'admin', }); // Regular authenticated routes app.use('/api/user', basicAuth); app.get('/api/user/profile', (req, res) => { /* ... */ }); app.put('/api/user/settings', (req, res) => { /* ... */ }); // Admin-only routes app.use('/api/admin', adminAuth); app.get('/api/admin/users', (req, res) => { /* ... */ }); app.post('/api/admin/settings', (req, res) => { /* ... */ }); ``` ### Guest Mode Allow both authenticated and guest users: ```javascript theme={null} const flexibleAuth = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, allowGuest: true, }); app.get('/api/cart', flexibleAuth, (req, res) => { if (req.auth.isAuthenticated) { // Load user's saved cart from database const cart = await getCartByUserId(req.auth.userId); res.json(cart); } else { // Load guest cart from session const cart = await getCartByGuestId(req.auth.guestId); res.json(cart); } }); ``` ### TypeScript Usage ```typescript theme={null} import express, { Request, Response } from 'express'; import { createExpressAuthMiddleware, AuthContext } from '@ouim/logto-authkit/server'; // Extend Express Request type declare global { namespace Express { interface Request { auth?: AuthContext; } } } const app = express(); const authMiddleware = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); app.get('/api/profile', authMiddleware, (req: Request, res: Response) => { // TypeScript knows about req.auth const userId = req.auth!.userId; res.json({ userId }); }); ``` ## Error Responses The middleware returns `401 Unauthorized` for authentication failures: ### Missing Token ```json theme={null} { "error": "Authentication required", "message": "No token found in cookies or Authorization header" } ``` ### Invalid Token ```json theme={null} { "error": "Authentication failed", "message": "Token verification failed: Token has expired" } ``` ### Missing Scope ```json theme={null} { "error": "Authentication failed", "message": "Token verification failed: Missing required scope: admin:write" } ``` When `allowGuest: true`, the middleware never returns 401 errors. Instead, it sets `req.auth` with guest context. ## How It Works The middleware performs these steps on each request: Automatically parses cookies using `cookie-parser` if not already available Checks for token in cookies (`logto_authtoken`) then Authorization header * Fetches JWKS from Logto server (cached for 5 minutes) * Verifies JWT signature using the appropriate public key * Validates issuer, audience, expiration, and scopes Attaches `AuthContext` to `req.auth` and calls `next()` Returns 401 JSON response or guest context (if `allowGuest` enabled) ## Best Practices Store configuration in environment variables: ```javascript theme={null} const auth = createExpressAuthMiddleware({ logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, }); ``` Use different middleware instances for different authorization levels: ```javascript theme={null} const userAuth = createExpressAuthMiddleware({ ... }); const adminAuth = createExpressAuthMiddleware({ ..., requiredScope: 'admin' }); ``` Always check `isAuthenticated` when using `allowGuest`: ```javascript theme={null} app.post('/api/checkout', flexibleAuth, (req, res) => { if (!req.auth.isAuthenticated) { return res.status(401).json({ error: 'Please login to checkout' }); } // Process checkout }); ``` The full JWT payload is available in `req.auth.payload`: ```javascript theme={null} app.get('/api/user', auth, (req, res) => { const { email, name, picture } = req.auth.payload; res.json({ email, name, picture }); }); ``` ## Cookie Parsing The middleware automatically handles cookie parsing: * If `req.cookies` exists (already parsed), uses it directly * If not, applies `cookie-parser` middleware internally * No need to add `cookie-parser` to your app separately ```javascript theme={null} // This works even without app.use(cookieParser()) app.get('/api/protected', authMiddleware, (req, res) => { res.json({ userId: req.auth.userId }); }); ``` While the middleware handles cookie parsing internally, you can still use `cookie-parser` globally if needed for other routes. ## Related Server-side auth for Next.js Use in any Node.js environment # Generic Usage Source: https://docs.ouim.me/logto-authkit/server/generic-usage Use verifyAuth in any Node.js environment ## Overview The `verifyAuth` function is a flexible authentication utility that works in any Node.js environment. It accepts either a raw JWT token string or a request object with cookies/headers. ## Function Signature ```typescript theme={null} function verifyAuth( tokenOrRequest: string | { cookies?: any; headers?: any }, options: VerifyAuthOptions ): Promise ``` ### Parameters Either a JWT token string or a request object containing cookies and/or headers ```typescript theme={null} { cookies?: { [key: string]: string }, // Cookie object headers?: { [key: string]: string } // Headers object } ``` Authentication configuration options Your Logto server URL (e.g., `https://your-tenant.logto.app`) API resource identifier registered in Logto Custom cookie name if changed in frontend Required scope that must be present in the token Enable guest mode for unauthenticated users ### Return Value User authentication context Logto user ID from the `sub` claim (null for guests) Whether the user is authenticated Full JWT payload with all claims (null for guests) Whether user is in guest mode Generated UUID for guest users ### Errors Throws an error when: * No token found in request and `allowGuest` is false * Invalid JWT format * Token signature verification fails * Token has expired * Invalid issuer or audience * Missing required scope ## Usage Examples ### With Raw Token String Verify a JWT token directly: ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server'; const token = 'eyJhbGciOiJSUzI1NiIs...'; try { const auth = await verifyAuth(token, { logtoUrl: 'https://your-tenant.logto.app', audience: 'https://api.yourapp.com', }); console.log('User ID:', auth.userId); console.log('Email:', auth.payload.email); } catch (error) { console.error('Authentication failed:', error.message); } ``` ### With Request Object Extract token automatically from cookies or headers: ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server'; // Example request object const request = { cookies: { logto_authtoken: 'eyJhbGciOiJSUzI1NiIs...' }, headers: { authorization: 'Bearer eyJhbGciOiJSUzI1NiIs...' }, }; try { const auth = await verifyAuth(request, { logtoUrl: process.env.LOGTO_URL, audience: process.env.LOGTO_API_RESOURCE, }); console.log('Authenticated:', auth.isAuthenticated); console.log('User ID:', auth.userId); } catch (error) { console.error('Authentication failed:', error.message); } ``` ### AWS Lambda Function ```typescript theme={null} import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; import { verifyAuth } from '@ouim/logto-authkit/server'; export const handler = async ( event: APIGatewayProxyEvent ): Promise => { try { // Extract token from Lambda event const token = event.headers.Authorization?.replace('Bearer ', ''); if (!token) { return { statusCode: 401, body: JSON.stringify({ error: 'No token provided' }), }; } const auth = await verifyAuth(token, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); // Your Lambda logic return { statusCode: 200, body: JSON.stringify({ message: 'Success', userId: auth.userId }), }; } catch (error) { return { statusCode: 401, body: JSON.stringify({ error: 'Authentication failed', message: error.message }), }; } }; ``` ### Cloudflare Workers ```typescript theme={null} import { verifyAuth } from '@ouim/logto-authkit/server'; export default { async fetch(request: Request): Promise { try { // Extract token from Authorization header const authHeader = request.headers.get('Authorization'); const token = authHeader?.replace('Bearer ', ''); if (!token) { return new Response( JSON.stringify({ error: 'No token provided' }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } const auth = await verifyAuth(token, { logtoUrl: 'https://your-tenant.logto.app', audience: 'https://api.yourapp.com', }); // Your worker logic return new Response( JSON.stringify({ userId: auth.userId }), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } catch (error) { return new Response( JSON.stringify({ error: error.message }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } }, }; ``` ### GraphQL Resolver Context ```typescript theme={null} import { ApolloServer } from '@apollo/server'; import { verifyAuth } from '@ouim/logto-authkit/server'; const server = new ApolloServer({ typeDefs, resolvers, context: async ({ req }) => { try { const auth = await verifyAuth( { cookies: req.cookies, headers: req.headers, }, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, allowGuest: true, // Allow unauthenticated GraphQL queries } ); return { auth }; } catch (error) { return { auth: null }; } }, }); // In resolvers const resolvers = { Query: { me: async (_parent, _args, context) => { if (!context.auth?.isAuthenticated) { throw new Error('Not authenticated'); } return getUserById(context.auth.userId); }, }, }; ``` ### tRPC Middleware ```typescript theme={null} import { initTRPC } from '@trpc/server'; import { verifyAuth } from '@ouim/logto-authkit/server'; const t = initTRPC.context().create(); const isAuthed = t.middleware(async ({ ctx, next }) => { const auth = await verifyAuth( { cookies: ctx.req.cookies, headers: ctx.req.headers, }, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, } ); if (!auth.isAuthenticated) { throw new Error('Not authenticated'); } return next({ ctx: { auth, }, }); }); export const protectedProcedure = t.procedure.use(isAuthed); // Usage export const appRouter = t.router({ getProfile: protectedProcedure.query(({ ctx }) => { return getUserById(ctx.auth.userId); }), }); ``` ### Fastify Plugin ```typescript theme={null} import Fastify from 'fastify'; import { verifyAuth } from '@ouim/logto-authkit/server'; const fastify = Fastify(); // Register auth decorator fastify.decorateRequest('auth', null); // Auth hook fastify.addHook('preHandler', async (request, reply) => { if (request.routeOptions.config?.auth === false) { return; // Skip auth for public routes } try { const auth = await verifyAuth( { cookies: request.cookies, headers: request.headers, }, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, } ); request.auth = auth; } catch (error) { reply.code(401).send({ error: 'Unauthorized' }); } }); // Protected route fastify.get('/api/profile', async (request) => { return { userId: request.auth.userId }; }); // Public route fastify.get('/api/health', { config: { auth: false } }, async () => { return { status: 'ok' }; }); ``` ### Hono Middleware ```typescript theme={null} import { Hono } from 'hono'; import { verifyAuth } from '@ouim/logto-authkit/server'; const app = new Hono(); // Auth middleware const authMiddleware = async (c, next) => { try { const auth = await verifyAuth( { cookies: c.req.cookies, headers: c.req.headers, }, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, } ); c.set('auth', auth); await next(); } catch (error) { return c.json({ error: 'Unauthorized' }, 401); } }; // Use middleware app.use('/api/*', authMiddleware); app.get('/api/profile', (c) => { const auth = c.get('auth'); return c.json({ userId: auth.userId }); }); ``` ### Next.js Server Actions ```typescript theme={null} 'use server' import { cookies } from 'next/headers'; import { verifyAuth } from '@ouim/logto-authkit/server'; export async function createPost(formData: FormData) { const cookieStore = cookies(); const token = cookieStore.get('logto_authtoken')?.value; if (!token) { throw new Error('Unauthorized'); } const auth = await verifyAuth(token, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); // Create post with authenticated user const post = await db.post.create({ data: { title: formData.get('title') as string, content: formData.get('content') as string, authorId: auth.userId, }, }); return post; } ``` ## Guest Mode When `allowGuest: true`, the function returns guest context instead of throwing errors: ```typescript theme={null} const auth = await verifyAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, allowGuest: true, }); if (auth.isAuthenticated) { // Authenticated user console.log('User ID:', auth.userId); console.log('Email:', auth.payload.email); } else if (auth.isGuest) { // Guest user console.log('Guest ID:', auth.guestId); } ``` ## Error Handling ### Without Guest Mode ```typescript theme={null} try { const auth = await verifyAuth(token, { ... }); // Use auth.userId, auth.payload, etc. } catch (error) { if (error.message.includes('expired')) { // Handle expired token } else if (error.message.includes('Invalid audience')) { // Handle wrong audience } else { // Handle other errors } } ``` ### With Guest Mode ```typescript theme={null} const auth = await verifyAuth(request, { allowGuest: true, // ... other options }); // Never throws - always returns auth context if (auth.isAuthenticated) { // Proceed with authenticated user } else { // Proceed with guest user } ``` ## Token Extraction Priority When passing a request object, tokens are checked in this order: 1. **Cookie**: `cookies[cookieName]` (default: `logto_authtoken`) 2. **Authorization Header**: `headers.authorization` (Bearer token) ```typescript theme={null} const request = { cookies: { logto_authtoken: 'token-from-cookie' }, headers: { authorization: 'Bearer token-from-header' }, }; // Will use 'token-from-cookie' (cookies take precedence) const auth = await verifyAuth(request, { ... }); ``` ## Best Practices Store Logto configuration in environment variables: ```typescript theme={null} const auth = await verifyAuth(token, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); ``` Always wrap calls in try-catch unless using `allowGuest`: ```typescript theme={null} try { const auth = await verifyAuth(token, { ... }); } catch (error) { // Return appropriate error response } ``` When using guest mode, always check authentication status: ```typescript theme={null} if (!auth.isAuthenticated) { throw new Error('Authentication required'); } ``` Create a reusable configuration object: ```typescript theme={null} const authOptions = { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }; const auth = await verifyAuth(token, authOptions); ``` ## Type Definitions ```typescript theme={null} import type { AuthContext, AuthPayload, VerifyAuthOptions, } from '@ouim/logto-authkit/server'; // AuthPayload - JWT token claims interface AuthPayload { sub: string; // User ID scope: string; // Space-separated scopes [key: string]: any; // Additional custom claims } // AuthContext - Authentication result interface AuthContext { userId: string | null; isAuthenticated: boolean; payload: AuthPayload | null; isGuest?: boolean; guestId?: string; } // VerifyAuthOptions - Configuration interface VerifyAuthOptions { logtoUrl: string; audience: string; cookieName?: string; requiredScope?: string; allowGuest?: boolean; } ``` ## Related Ready-to-use Express middleware Next.js-specific authentication # Next.js Integration Source: https://docs.ouim.me/logto-authkit/server/nextjs-integration Authenticate Next.js API routes and middleware ## Quick Start Use `verifyNextAuth` to authenticate Next.js App Router API routes and middleware. It handles both cookies and Authorization headers. ```typescript theme={null} import { NextRequest, NextResponse } from 'next/server'; import { verifyNextAuth } from '@ouim/logto-authkit/server'; export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); if (!result.success) { return NextResponse.json( { error: result.error }, { status: 401 } ); } const { userId, payload } = result.auth; return NextResponse.json({ userId, email: payload.email, }); } ``` ## Function Signature ```typescript theme={null} function verifyNextAuth( request: NextRequest, options: VerifyAuthOptions ): Promise< | { success: true; auth: AuthContext } | { success: false; error: string; auth?: AuthContext } > ``` ### Parameters The Next.js request object from your API route or middleware Configuration options for authentication Your Logto server URL (e.g., `https://your-tenant.logto.app`) API resource identifier registered in Logto Custom cookie name if changed in frontend Required scope that must be present in the token Enable guest mode for unauthenticated users ### Return Value Whether authentication was successful User authentication context (always present when `success: true`, optional with guest mode) Logto user ID from the `sub` claim Whether the user is authenticated Full JWT payload with all claims Whether user is in guest mode (when `allowGuest: true`) Generated UUID for guest users Error message when `success: false` ## Usage Examples ### API Route (App Router) ```typescript theme={null} // app/api/user/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verifyNextAuth } from '@ouim/logto-authkit/server'; export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); if (!result.success) { return NextResponse.json( { error: result.error }, { status: 401 } ); } // Fetch user data from database const user = await db.user.findUnique({ where: { id: result.auth.userId }, }); return NextResponse.json({ user }); } ``` ### POST Route with Data ```typescript theme={null} // app/api/posts/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verifyNextAuth } from '@ouim/logto-authkit/server'; export async function POST(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); if (!result.success) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } const body = await request.json(); const post = await db.post.create({ data: { ...body, authorId: result.auth.userId, }, }); return NextResponse.json({ post }); } ``` ### Route with Required Scope ```typescript theme={null} // app/api/admin/users/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verifyNextAuth } from '@ouim/logto-authkit/server'; export async function DELETE(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, requiredScope: 'admin:users:delete', }); if (!result.success) { return NextResponse.json( { error: 'Insufficient permissions' }, { status: 403 } ); } // Delete user logic return NextResponse.json({ success: true }); } ``` ### Next.js Middleware Protect multiple routes with Next.js middleware: ```typescript theme={null} // middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { verifyNextAuth } from '@ouim/logto-authkit/server'; export async function middleware(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); if (!result.success) { return NextResponse.json( { error: 'Unauthorized' }, { status: 401 } ); } // Add user ID to response headers for downstream use const response = NextResponse.next(); response.headers.set('x-user-id', result.auth.userId!); return response; } export const config = { matcher: '/api/protected/:path*', }; ``` ### Guest Mode Allow both authenticated and guest users: ```typescript theme={null} // app/api/cart/route.ts import { NextRequest, NextResponse } from 'next/server'; import { verifyNextAuth } from '@ouim/logto-authkit/server'; export async function GET(request: NextRequest) { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, allowGuest: true, }); // result.auth is always present with allowGuest const cart = result.auth!.isAuthenticated ? await getCartByUserId(result.auth.userId!) : await getCartByGuestId(result.auth.guestId!); return NextResponse.json({ cart }); } ``` ### Reusable Auth Helper Create a helper function for consistent authentication: ```typescript theme={null} // lib/auth.ts import { NextRequest, NextResponse } from 'next/server'; import { verifyNextAuth, AuthContext } from '@ouim/logto-authkit/server'; type AuthHandler = ( request: NextRequest, auth: AuthContext ) => Promise | NextResponse; export function withAuth(handler: AuthHandler) { return async (request: NextRequest) => { const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); if (!result.success) { return NextResponse.json( { error: result.error }, { status: 401 } ); } return handler(request, result.auth); }; } // Usage in routes // app/api/profile/route.ts import { withAuth } from '@/lib/auth'; export const GET = withAuth(async (request, auth) => { const user = await db.user.findUnique({ where: { id: auth.userId }, }); return NextResponse.json({ user }); }); ``` ## Server Actions (Experimental) For Next.js Server Actions, extract the token from cookies: ```typescript theme={null} 'use server' import { cookies } from 'next/headers'; import { verifyAuth } from '@ouim/logto-authkit/server'; export async function updateProfile(formData: FormData) { const cookieStore = cookies(); const token = cookieStore.get('logto_authtoken')?.value; if (!token) { throw new Error('Unauthorized'); } const auth = await verifyAuth(token, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); // Update user profile await db.user.update({ where: { id: auth.userId }, data: { name: formData.get('name'), }, }); } ``` For Server Actions, use the generic `verifyAuth` function instead of `verifyNextAuth`. See [Generic Usage](/logto-authkit/server/generic-usage) for details. ## Error Handling ### With allowGuest: false (default) ```typescript theme={null} const result = await verifyNextAuth(request, { ... }); if (!result.success) { // result.error contains the error message // result.auth is undefined return NextResponse.json( { error: result.error }, { status: 401 } ); } // result.auth is guaranteed to exist and be authenticated const userId = result.auth.userId; ``` ### With allowGuest: true ```typescript theme={null} const result = await verifyNextAuth(request, { allowGuest: true, // ... other options }); if (result.success) { // Authenticated user const userId = result.auth.userId; } else { // Guest user (result.auth contains guest context) if (result.auth?.isGuest) { const guestId = result.auth.guestId; } } ``` ## Common Error Messages No authentication token was provided in the request. **Solution**: Ensure the frontend is setting cookies or sending Authorization header. The JWT token's expiration time has passed. **Solution**: Refresh the token on the frontend or prompt user to re-authenticate. Token wasn't issued by your Logto server. **Solution**: Verify `logtoUrl` matches your Logto tenant URL. Token's audience claim doesn't match your API resource. **Solution**: Verify `audience` matches the API resource registered in Logto. Token doesn't include the required scope. **Solution**: Ensure the scope is requested during frontend authentication. ## Best Practices Store Logto configuration in environment variables: ```typescript theme={null} const result = await verifyNextAuth(request, { logtoUrl: process.env.LOGTO_URL!, audience: process.env.LOGTO_API_RESOURCE!, }); ``` Wrap `verifyNextAuth` in helper functions for consistent error handling: ```typescript theme={null} export const withAuth = (handler: AuthHandler) => { ... }; ``` * `401 Unauthorized`: Authentication failed or missing * `403 Forbidden`: Authenticated but insufficient permissions Always check `isAuthenticated` when using guest mode: ```typescript theme={null} if (!result.auth?.isAuthenticated) { return NextResponse.json({ error: 'Login required' }, { status: 401 }); } ``` ## Related Middleware for Express.js applications Flexible verifyAuth for any environment # Server Authentication Source: https://docs.ouim.me/logto-authkit/server/overview Secure your server routes with Logto authentication ## Overview logto-authkit provides flexible server authentication utilities that work with Express.js, Next.js, and any Node.js environment. All authentication functions verify JWT tokens issued by your Logto server and provide a consistent `AuthContext` object. ## Key Features Ready-to-use middleware for Express.js applications Server-side authentication for Next.js API routes and middleware Flexible verifyAuth function for any Node.js environment Optional guest mode for unauthenticated users ## Authentication Flow All server authentication functions follow this flow: 1. **Token Extraction**: Checks for JWT token in cookies (default: `logto_authtoken`) or `Authorization` header 2. **JWKS Fetching**: Retrieves public keys from your Logto server (with 5-minute caching) 3. **Signature Verification**: Verifies JWT signature using the appropriate public key 4. **Claims Validation**: Validates issuer, audience, expiration, and required scopes 5. **Context Creation**: Returns an `AuthContext` object with user information ## AuthContext Object All authentication functions return or set an `AuthContext` object: ```typescript theme={null} interface AuthContext { userId: string | null // Logto user ID (sub claim) isAuthenticated: boolean // Whether user is authenticated payload: AuthPayload | null // Full JWT payload isGuest?: boolean // Whether user is in guest mode guestId?: string // Generated UUID for guest users } ``` ## Configuration Options All authentication functions accept a `VerifyAuthOptions` object: Your Logto server URL (e.g., `https://your-tenant.logto.app`) The API resource identifier registered in Logto Name of the cookie containing the JWT token Optional scope that must be present in the token Enable guest mode for unauthenticated users. When enabled, failed authentication returns a guest context instead of throwing an error. ## Guest Mode When `allowGuest` is enabled, the authentication functions handle unauthenticated users gracefully: * No token found: Returns guest context with generated `guestId` * Invalid token: Falls back to guest context * Guest ID stored in cookie: `guest_logto_authtoken` (auto-generated UUID) ```typescript theme={null} // Example guest context { userId: null, isAuthenticated: false, payload: null, isGuest: true, guestId: "550e8400-e29b-41d4-a716-446655440000" } ``` Guest mode is useful for applications that support both authenticated and anonymous users, such as e-commerce sites or content platforms. ## Token Sources Authentication tokens can be provided in two ways (checked in order): ### 1. Cookie (Recommended) Set by the frontend authentication SDK: ```http theme={null} Cookie: logto_authtoken=eyJhbGciOiJSUzI1NiIs... ``` ### 2. Authorization Header Useful for API clients and mobile apps: ```http theme={null} Authorization: Bearer eyJhbGciOiJSUzI1NiIs... ``` ## Error Handling Authentication functions throw errors in these scenarios: * **No token found**: When `allowGuest` is disabled and no token is present * **Invalid JWT format**: Malformed token structure * **Signature verification failed**: Token signature doesn't match public key * **Token expired**: Token's `exp` claim is in the past * **Invalid issuer**: Token wasn't issued by your Logto server * **Invalid audience**: Token's `aud` claim doesn't match your API resource * **Missing scope**: Required scope not present in token Always handle authentication errors appropriately in production. Return 401 status codes for authentication failures. ## Security Features Public keys are cached for 5 minutes to reduce load on your Logto server while maintaining security. The cache is automatically refreshed when expired. Express middleware automatically parses cookies using `cookie-parser` if not already available, ensuring seamless integration. All JWT claims are validated including issuer, audience, expiration (`exp`), not-before (`nbf`), and custom scopes. Supports both cookie-based (for web apps) and header-based (for APIs) authentication in the same endpoint. ## Next Steps Add middleware to Express routes Protect Next.js API routes Use in any Node.js environment # Domain allowlist Source: https://docs.ouim.me/reacher/configuration/domain-allowlist How PROXY_ALLOWED_DOMAINS and FETCH_EXTERNAL_TOKEN_MAP control which APIs fetch_external can reach and how auth tokens are injected automatically. The `fetch_external` tool is Reacher's HTTP proxy for calling external APIs. Two environment variables control its behavior: `PROXY_ALLOWED_DOMAINS` defines which domains Claude is permitted to reach, and `FETCH_EXTERNAL_TOKEN_MAP` tells the server which credential to inject for each domain. Together they give Claude authenticated access to any REST API without you ever pasting a token into a prompt. *** ## Why domain whitelisting matters Without a domain restriction, a compromised prompt or an unintended instruction could cause Reacher to proxy requests to arbitrary hosts on the internet — potentially leaking data or triggering unintended side effects on external services. `PROXY_ALLOWED_DOMAINS` is a strict allowlist. The server parses the hostname from the requested URL and checks it against the list before making any outbound connection. If the domain is not listed, the request is rejected immediately and Claude receives an error — no HTTP call is made. ``` Domain "api.attacker.com" is not in PROXY_ALLOWED_DOMAINS → rejected Domain "api.github.com" is in PROXY_ALLOWED_DOMAINS → proceeds ``` This means the set of APIs Claude can reach is always explicit and operator-controlled. *** ## How token injection works `FETCH_EXTERNAL_TOKEN_MAP` is a JSON object that maps domain hostnames to the names of environment variables holding credentials: ```bash theme={null} FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_API_TOKEN"} ``` When `fetch_external` receives a request for `api.github.com`, it: 1. Confirms the domain is in `PROXY_ALLOWED_DOMAINS` 2. Looks up `"api.github.com"` in `FETCH_EXTERNAL_TOKEN_MAP` → finds `"GITHUB_TOKEN"` 3. Reads the value of the `GITHUB_TOKEN` environment variable from the server process 4. Injects `Authorization: Bearer ` into the outbound request headers 5. Forwards the request and returns the response to Claude Claude never sees the token value. It only sees the API response. If a domain is in `PROXY_ALLOWED_DOMAINS` but not in `FETCH_EXTERNAL_TOKEN_MAP`, the request proceeds without injecting any authorization header. This is correct for public APIs that do not require authentication. *** ## JSON format `FETCH_EXTERNAL_TOKEN_MAP` must be valid JSON. The keys are exact hostnames (not URLs or patterns), and the values are the names of other environment variables — not the token values themselves. ```json theme={null} { "api.github.com": "GITHUB_TOKEN", "api.linear.app": "LINEAR_API_TOKEN", "api.notion.com": "NOTION_TOKEN", "your-instance.atlassian.net": "JIRA_API_TOKEN" } ``` In `.env`, the entire JSON object must be on a single line: ```bash theme={null} FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_API_TOKEN"} ``` If the JSON is malformed, `fetch_external` will fail to parse the token map and no tokens will be injected. Check the server logs on startup if you suspect a parsing issue. *** ## Adding a new API integration Adding support for a new API is a two-line change to your `.env`: ```bash theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.notion.com ``` Append the new hostname to the existing comma-separated list. ```bash theme={null} FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_API_TOKEN","api.notion.com":"NOTION_TOKEN"} ``` Add the hostname-to-variable mapping. If the API is public and needs no auth, skip this step. ```bash theme={null} NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxx ``` The name here must match the value you used in `FETCH_EXTERNAL_TOKEN_MAP`. ```bash theme={null} docker compose restart reacher # or: pm2 restart reacher ``` Environment variable changes require a server restart to take effect. *** ## Real examples ### GitHub ```bash theme={null} GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx PROXY_ALLOWED_DOMAINS=api.github.com FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN"} ``` Once configured, Claude can call any GitHub API endpoint: ```text Example Claude prompt theme={null} List my open pull requests across all repos. ``` Claude will call `fetch_external` with something like: ```json theme={null} { "url": "https://api.github.com/search/issues?q=is:pr+is:open+author:@me", "method": "GET" } ``` Reacher injects the `Authorization: Bearer ghp_xxx` header automatically and returns the GitHub API response to Claude. ### Linear ```bash theme={null} LINEAR_API_TOKEN=lin_api_xxxxxxxxxxxxxxxxxxxx PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_API_TOKEN"} ``` Linear's API is GraphQL. Claude can POST to `https://api.linear.app/graphql` with the appropriate query body. ### Notion ```bash theme={null} NOTION_TOKEN=secret_xxxxxxxxxxxxxxxxxxxx PROXY_ALLOWED_DOMAINS=api.github.com,api.notion.com FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.notion.com":"NOTION_TOKEN"} ``` Notion's REST API uses `Bearer` auth — Reacher's injection pattern matches exactly. ### Jira (Atlassian Cloud) Jira Cloud hostnames are instance-specific (e.g., `your-org.atlassian.net`). Use your exact subdomain: ```bash theme={null} JIRA_API_TOKEN=your_atlassian_api_token PROXY_ALLOWED_DOMAINS=your-org.atlassian.net FETCH_EXTERNAL_TOKEN_MAP={"your-org.atlassian.net":"JIRA_API_TOKEN"} ``` Jira Cloud uses HTTP Basic auth, not Bearer tokens. The standard token injection adds a `Bearer` header. For Jira, you may need to pass credentials differently — check the [Atlassian REST API authentication docs](https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/) and construct the auth header manually if needed. *** ## How the domain check works The allowlist check in `fetch_external` parses the full URL to extract the hostname, then checks exact membership in the allowed list: ```javascript theme={null} const allowedList = (allowedDomains || '') .split(',') .map(d => d.trim()) .filter(d => d) if (!allowedList.includes(hostname)) { return { success: false, error: 'Domain not allowed', hostname } } ``` This is exact hostname matching — `api.github.com` does not match `github.com` or `gist.github.com`. If you need access to multiple subdomains of the same service, add each one explicitly. ### What happens when a domain is blocked When Claude calls `fetch_external` for a domain not in the allowlist, the tool returns an error object immediately: ```json theme={null} { "success": false, "error": "Domain not allowed", "hostname": "api.example.com" } ``` No outbound HTTP request is made. Claude sees this error and can tell you that the domain is not configured — it will not retry or find a workaround. If Claude reports a "Domain not allowed" error for a call you expected to work, double-check that the hostname in `PROXY_ALLOWED_DOMAINS` exactly matches the hostname in the URL (no trailing slashes, no protocol prefix, no path). # Environment variables Source: https://docs.ouim.me/reacher/configuration/environment-variables Complete reference for all environment variables that configure Reacher's authentication, tools, safety, and server behavior. Reacher is configured primarily through environment variables in a `.env` file at the project root. Copy `.env.example` to `.env` and fill in your values before starting the server. ```bash theme={null} cp .env.example .env ``` `MCP_SECRET`, `TAILSCALE_API_KEY`, and `GITHUB_TOKEN` are validated at startup. The server will exit immediately if any of these are missing. *** ## Authentication Shared secret that Claude.ai sends with every request as a URL query parameter (`?token=...`). All requests without the correct token are rejected with `401 Unauthorized`. Generate a secure value with: ```bash theme={null} openssl rand -hex 32 ``` Never reuse a token across environments. Treat this like a password. *** ## Tailscale API key for the Tailscale control plane. Used by `tailscale_status` to query device list, IP addresses, and online/offline status. **Required scope:** Devices (read) Create one at [login.tailscale.com/admin/settings/keys](https://login.tailscale.com/admin/settings/keys). *** ## GitHub Personal access token for GitHub API calls. Used by: * `gist_kb` — read and write private Gists * `github_search` — search pull requests and commits * `fetch_external` — inject auth on requests to `api.github.com` (when configured in `FETCH_EXTERNAL_TOKEN_MAP`) **Required scope:** `gist` (read + write). Add `repo` if you need to search private repositories. Create one at [github.com/settings/tokens](https://github.com/settings/tokens). *** ## HTTP proxy Comma-separated list of hostnames that `fetch_external` is permitted to call. Requests to any domain not in this list are rejected before the HTTP call is made. ```bash theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.notion.com ``` Matching is done against the exact hostname parsed from the request URL. See [Domain allowlist](/reacher/configuration/domain-allowlist) for a full explanation. JSON object that maps hostnames to environment variable names. When `fetch_external` makes a request to a matching domain, it reads the named environment variable and injects it as an `Authorization: Bearer ` header. ```bash theme={null} FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_API_TOKEN"} ``` The token value never leaves the server — Claude only sees the result of the API call. See [Domain allowlist](/reacher/configuration/domain-allowlist) for examples. *** ## SSH safety Comma-separated list of command substrings to block from `ssh_exec`. Matching is case-insensitive substring matching — if any blocked string appears anywhere in the command, the command is rejected. ```bash theme={null} SSH_BLOCKED_COMMANDS=rm -rf /,shutdown,reboot,mkfs,dd,format ``` This variable overrides `ssh.blocked_commands` in `reacher.config.yaml`. The same setting can also be managed in the YAML file as a list, which supports comments and easier editing. Comma-separated list of directory prefixes. When set, `ssh_exec` will only allow commands that operate on paths starting with one of the listed directories. ```bash theme={null} SSH_ALLOWED_DIRS=/home/deploy,/tmp,/var/log ``` When empty (the default), there are no directory restrictions. This variable overrides `ssh.allowed_dirs` in `reacher.config.yaml`. *** ## Audit logging Set to `false` to disable audit logging entirely. Any other value (or the absence of this variable) leaves auditing enabled. ```bash theme={null} AUDIT_ENABLED=false ``` When enabled, every tool call is written as a JSON line to `AUDIT_LOG_PATH`. Sensitive keys (anything containing `token`, `password`, `secret`, or `key`) are stripped from the log entry automatically. This variable overrides `audit.enabled` in `reacher.config.yaml`. Path to the audit log file. Accepts both relative paths (resolved from the project root) and absolute paths. ```bash theme={null} AUDIT_LOG_PATH=/var/log/reacher/audit.log ``` The file is created automatically if it does not exist. Entries are appended, so the file grows continuously — rotate it with a tool like `logrotate` in production. This variable overrides `audit.log_path` in `reacher.config.yaml`. *** ## Server TCP port that the Express HTTP server listens on. ```bash theme={null} PORT=8080 ``` When deploying behind a reverse proxy (Caddy, Nginx, Traefik), this is the internal port the proxy forwards to. Claude.ai connects to the public HTTPS URL, not this port directly. Set to `true` to put `ssh_exec` into dry-run mode. In this mode, `ssh_exec` logs the command it would have run but does not actually execute it. ```bash theme={null} DRY_RUN=true ``` Useful for testing prompts and validating what Claude would do before granting live SSH access. The `/health` endpoint reports the current dry-run state. This variable overrides `dry_run` in `reacher.config.yaml`. *** ## Browser Hostname or IP address of the Chrome DevTools Protocol (CDP) compatible browser that the `browser` tool connects to. ```bash theme={null} BROWSER_CDP_HOST=127.0.0.1 ``` Requires a running CDP-compatible browser (such as Lightpanda or Chrome with `--remote-debugging-port`) and the `agent-browser` CLI installed globally. Port of the CDP-compatible browser. ```bash theme={null} BROWSER_CDP_PORT=9222 ``` *** ## Precedence rules When the same setting exists in both `.env` and `reacher.config.yaml`, the environment variable always wins. This makes it safe to commit a base `reacher.config.yaml` to version control and override specific values per deployment via environment variables. See [reacher.config.yaml reference](/reacher/configuration/reacher-config) for full details on the YAML config file. # reacher.config.yaml Source: https://docs.ouim.me/reacher/configuration/reacher-config Reference for the optional YAML configuration file that manages safety and behavior settings. `reacher.config.yaml` is an optional configuration file for managing Reacher's safety and behavior settings. It is designed for settings you want to keep in version control and review as code — particularly the SSH blocklist and audit configuration. ## Setup ```bash theme={null} cp reacher.config.example.yaml reacher.config.yaml ``` Edit the file, then restart the server. There is no hot-reload — changes take effect on the next startup. Environment variables always take precedence over YAML values. If `DRY_RUN=true` is set in your environment, the `dry_run: false` in the YAML file has no effect. *** ## Full annotated example ```yaml reacher.config.yaml theme={null} # Reacher Configuration File # This file is optional. All settings can be overridden via environment variables. # Environment variables always take precedence over YAML config. # # To use this file: # 1. Copy to reacher.config.yaml in the project root # 2. Edit the values as needed # 3. Restart the server for changes to take effect # SSH Configuration ssh: # List of commands to block from execution. # Supports substring matching (case-insensitive). # If "rm -rf" is in the list, any command containing "rm -rf" is rejected. blocked_commands: - "rm -rf" - "dd" - ":(){ :|:& };:" # fork bomb # List of directories where SSH commands are allowed to operate. # If empty (or omitted), all directories are allowed — no restriction. # Uses prefix matching: "/home/user" allows "/home/user/file.txt". allowed_dirs: # - "/home/deploy" # - "/tmp" # - "/var/log" # Audit Configuration audit: # Enable or disable audit logging. # All tool calls are written as JSON lines to audit.log_path. # Sensitive keys (token, password, secret, key) are stripped automatically. enabled: true # Path to audit log file. # Accepts relative paths (resolved from project root) or absolute paths. log_path: "./reacher-audit.log" # Dry-run Mode # When true, ssh_exec logs commands but does not execute them. # Useful for testing prompts before granting live SSH access. dry_run: false ``` *** ## Settings reference ### `ssh.blocked_commands` | | | | ---------------- | ---------------------------------------- | | **Type** | `string[]` | | **Default** | `[]` (no commands blocked) | | **Env override** | `SSH_BLOCKED_COMMANDS` (comma-separated) | A list of command substrings to block from `ssh_exec`. Matching is case-insensitive and substring-based: if any string in this list appears anywhere in the submitted command, the command is rejected before it is sent over SSH. ```yaml theme={null} ssh: blocked_commands: - "rm -rf" - "shutdown" - "reboot" - "mkfs" - "dd" - ":(){ :|:& };:" # fork bomb ``` Start with a conservative blocklist and expand it based on what your use case requires. Blocking `dd`, `mkfs`, and fork bombs covers the most destructive one-liners. ### `ssh.allowed_dirs` | | | | ---------------- | ------------------------------------ | | **Type** | `string[]` | | **Default** | `[]` (no directory restriction) | | **Env override** | `SSH_ALLOWED_DIRS` (comma-separated) | Optional list of directory prefixes. When set, `ssh_exec` only permits commands whose working path starts with one of the listed directories. An empty list (the default) applies no restriction. Prefix matching means `/home/deploy` permits `/home/deploy/app/` and `/home/deploy/logs/`, but not `/home/other/`. ```yaml theme={null} ssh: allowed_dirs: - "/home/deploy" - "/tmp" - "/var/log" ``` ### `audit.enabled` | | | | ---------------- | --------------------------------------------- | | **Type** | `boolean` | | **Default** | `true` | | **Env override** | `AUDIT_ENABLED` (set to `"false"` to disable) | Controls whether tool calls are written to the audit log. When `true`, every call to any Reacher tool produces a JSON log entry containing the timestamp, tool name, sanitized input, and success status. ### `audit.log_path` | | | | ---------------- | ----------------------- | | **Type** | `string` | | **Default** | `"./reacher-audit.log"` | | **Env override** | `AUDIT_LOG_PATH` | File path for the audit log. The file is created if it does not exist and entries are appended on each tool call. In production, use an absolute path and configure log rotation. ```yaml theme={null} audit: log_path: "/var/log/reacher/audit.log" ``` ### `dry_run` | | | | ---------------- | ------------------------------------- | | **Type** | `boolean` | | **Default** | `false` | | **Env override** | `DRY_RUN` (set to `"true"` to enable) | When `true`, `ssh_exec` returns a simulated response describing the command it would have run, without making any SSH connection. All other tools continue to operate normally. The current dry-run state is visible in the `/health` endpoint response: ```json theme={null} { "status": "ok", "timestamp": "...", "dry_run": true } ``` *** ## How the config system works On startup, Reacher reads `reacher.config.yaml` from the project root (if it exists) using `js-yaml`. The parsed YAML values are merged with environment variables according to the following precedence rules: 1. **Environment variable is set** → use the environment variable value, ignore YAML 2. **Environment variable is not set, YAML value exists** → use the YAML value 3. **Neither is set** → use the built-in default The merge logic in `src/lib/config.js` handles type coercion for boolean-like variables. For `AUDIT_ENABLED`, only the exact string `"false"` disables auditing. For `DRY_RUN`, only the exact string `"true"` enables it. The YAML file is only read once at startup. Editing `reacher.config.yaml` while the server is running has no effect until you restart the process. *** ## Reloading configuration Make your changes and save the file. ```bash Docker theme={null} docker compose restart reacher ``` ```bash Bare Node theme={null} # If running with PM2: pm2 restart reacher # If running directly: # Send SIGINT (Ctrl+C) and restart: node index.js ``` Check the startup log output, or query the health endpoint to confirm dry-run state: ```bash theme={null} curl "http://localhost:3000/health?token=YOUR_MCP_SECRET" ``` # Connecting to Claude Source: https://docs.ouim.me/reacher/connecting-to-claude Link your running Reacher server to Claude.ai as a custom MCP connector. ## Prerequisites * Reacher must be running and publicly reachable over HTTPS. If you haven't done that yet, complete the [Quickstart](/reacher/quickstart) first. * Your server URL must use HTTPS — Claude.ai will not connect to plain HTTP endpoints. * You'll need your `MCP_SECRET` value from your `.env` file. *** ## Add the connector Go to [Claude.ai](https://claude.ai) and click your profile avatar, then select **Settings**. In the Settings sidebar, select **Integrations**. Click **Add custom connector**. Paste your Reacher URL in the following format: ``` https://yourdomain.com/mcp?token=YOUR_MCP_SECRET ``` Replace `yourdomain.com` with your actual domain and `YOUR_MCP_SECRET` with the value from your `.env` file. The `token` query parameter must exactly match the `MCP_SECRET` in your `.env`. If they don't match, every request returns a 401 Unauthorized error and Claude will show a connection failure. Save the connector. Claude.ai will attempt to contact your server and retrieve the tools list. If the connection succeeds, you'll see Reacher listed as an active integration. Open a new conversation and you should see the Reacher tools available. *** ## Verify Claude can call tools Start a new conversation and try a prompt that exercises a tool: > "What devices are on my Tailscale network?" Claude should call `tailscale_status` and return a list of your devices with their hostnames, IP addresses, OS, and online/offline status. If Claude responds without calling any tools, the connector may not be active in that conversation. Look for a tools icon or connector indicator in the Claude.ai UI and confirm Reacher is enabled. *** ## Bootstrap Claude with AGENT.MD The repository includes an `AGENT.MD` file that tells Claude how to use Reacher's tools effectively: how to discover your devices, when to use each tool, how to handle Windows vs. Linux targets, and what to save to the knowledge base. Drop the contents of `AGENT.MD` into the start of a new conversation. Claude will run the first-time setup checklist automatically: discover your Tailscale devices, probe SSH access on each one, check the `gist_kb` knowledge base for any saved context, and write a device map so future sessions don't start from scratch. The first-time checklist Claude follows from `AGENT.MD`: 1. Call `tailscale_status` to discover all devices, their hostnames, OS, and online status 2. Run a test command via `ssh_exec` on each online device to confirm SSH access 3. Call `gist_kb` with `action: list` to check for saved notes from previous sessions 4. Write a device map to a gist (`RC: device-map`) so future sessions resume with full context After this runs once, every new session can pick up where the last one left off. *** ## Troubleshooting **Claude.ai shows "connection failed" when adding the connector** * Confirm the server is publicly reachable: `curl "https://yourdomain.com/health?token=YOUR_MCP_SECRET"` * Check that your reverse proxy is forwarding to port 3000 (or whatever `PORT` you set) * Verify your SSL certificate is valid and not expired * Make sure port 443 is open in your VPS firewall **Tools are listed but Claude gets errors when calling them** * Check the server logs: `docker compose logs -f reacher` * Confirm `MCP_SECRET` in the URL matches the one in `.env` exactly (it's case-sensitive) * Look at `reacher-audit.log` for logged tool calls and their results **`fetch_external` returns "Domain not allowed"** The domain isn't in your `PROXY_ALLOWED_DOMAINS` list. Add it to `.env` and restart the server: ```bash theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.notion.com ``` **`ssh_exec` fails to connect to a device** * Run `tailscale_status` to confirm the device shows online (note: status can lag — try SSH anyway) * Verify Tailscale SSH is enabled on the target device: `sudo tailscale up --ssh` * Test the connection manually from your Reacher server: `ssh user@device-hostname` **`gist_kb` or `github_search` returns an authentication error** Your `GITHUB_TOKEN` is missing or doesn't have the required scopes. The token needs `gist` scope for `gist_kb` and any additional API scopes needed for `fetch_external` targets. Create or update the token at [github.com/settings/tokens](https://github.com/settings/tokens). # Bare Node.js Source: https://docs.ouim.me/reacher/deployment/bare-node Run Reacher directly with Node.js on any machine without Docker. Running Reacher directly with Node.js is the lightest-weight option and works well on any VPS where you already manage the process lifecycle. ## Prerequisites * Node.js 18 or later (`node --version` to check) * npm (bundled with Node.js) * Git ## Setup ```bash theme={null} git clone --branch v0.1.0 https://github.com/thezem/reacher.git cd reacher ``` ```bash theme={null} npm install ``` ```bash theme={null} cp .env.example .env cp reacher.config.example.yaml reacher.config.yaml ``` Open `.env` and fill in your credentials. The required variables are: ```bash theme={null} MCP_SECRET= # openssl rand -hex 32 TAILSCALE_API_KEY= GITHUB_TOKEN= PROXY_ALLOWED_DOMAINS=api.github.com ``` See [`.env.example`](https://github.com/thezem/reacher/blob/main/.env.example) for the full list of options. ```bash theme={null} node index.js ``` The server starts on port 3000 by default (or `PORT` from your `.env`). ``` ✅ MCP Server started on http://localhost:3000 POST http://localhost:3000/mcp GET http://localhost:3000/health ``` ```bash theme={null} curl "http://localhost:3000/health?token=YOUR_MCP_SECRET" ``` Expected response: `{"status":"ok",...}` ## Start options ```bash npm start theme={null} npm start ``` ```bash node directly theme={null} node index.js ``` Both are equivalent — `npm start` is defined as `node index.js` in `package.json`. ## Development mode with auto-reload During development, use `--watch` mode so the server restarts automatically when you edit files: ```bash theme={null} npm run dev ``` This runs `node --watch index.js` (built into Node.js 18+, no extra tools needed). ## Production with PM2 For a long-running production deployment, PM2 manages the process, restarts it on crash, and survives reboots. ```bash theme={null} npm install -g pm2 ``` ```bash Start theme={null} pm2 start index.js --name reacher ``` ```bash Start with log file theme={null} pm2 start index.js --name reacher --log ./reacher.log ``` ```bash theme={null} pm2 save pm2 startup ``` Run the command that `pm2 startup` prints — this registers PM2 to launch on system boot. ### Useful PM2 commands ```bash theme={null} pm2 status # show all managed processes pm2 logs reacher # stream live logs pm2 logs reacher --lines 50 # last 50 log lines pm2 restart reacher # restart the process pm2 stop reacher # stop without removing pm2 delete reacher # stop and remove from PM2 ``` ## Health check ```bash theme={null} curl "http://localhost:3000/health?token=YOUR_MCP_SECRET" ``` ```json theme={null} { "status": "ok", "timestamp": "2026-03-18T12:00:00.000Z", "dry_run": false } ``` ## Updating ```bash theme={null} git pull ``` ```bash theme={null} npm install ``` ```bash PM2 theme={null} pm2 restart reacher ``` ```bash Manual theme={null} # Stop the running process (Ctrl+C or kill), then: node index.js ``` ## Exposing Reacher publicly The server listens on `http://localhost:3000` by default. Claude.ai requires a public HTTPS URL to connect. The standard approach on a VPS is a reverse proxy. Caddy handles HTTPS certificate provisioning automatically: ``` mcp.yourdomain.com { reverse_proxy localhost:3000 } ``` With Nginx, add a server block that proxies to port 3000 and configure Certbot for TLS. Cloud platforms like EasyPanel, Railway, and Render handle HTTPS automatically if you prefer not to manage a reverse proxy yourself. See the other deployment guides for those options. # Docker Source: https://docs.ouim.me/reacher/deployment/docker Deploy Reacher using Docker or Docker Compose on any VPS or local machine. Docker is the recommended way to run Reacher in production. It handles dependencies, provides automatic restarts, and isolates the process cleanly. ## Prerequisites * Docker installed on your host machine * `.env` file configured with your credentials (copy from `.env.example`) * `reacher.config.yaml` configured (copy from `reacher.config.example.yaml`) Generate a strong `MCP_SECRET` before you start: `openssl rand -hex 32` ## Docker Compose (recommended) Docker Compose is the simplest path for most deployments. It builds the image, maps ports, loads your `.env`, and restarts automatically on crash or reboot. ```bash theme={null} git clone --branch v0.1.0 https://github.com/thezem/reacher.git cd reacher ``` ```bash theme={null} cp .env.example .env cp reacher.config.example.yaml reacher.config.yaml ``` Edit both files with your credentials. At minimum, set: ```bash theme={null} MCP_SECRET= TAILSCALE_API_KEY= GITHUB_TOKEN= PROXY_ALLOWED_DOMAINS=api.github.com ``` ```bash theme={null} docker compose up -d ``` Docker Compose will build the image and start the container in the background. ```bash theme={null} docker logs mcp-server curl "http://localhost:3000/health?token=YOUR_MCP_SECRET" ``` You should see `{"status":"ok",...}` from the health check endpoint. ### docker-compose.yml This is the full Compose file included in the repository: ```yaml docker-compose.yml theme={null} version: '3.8' services: mcp-server: build: context: . dockerfile: Dockerfile container_name: mcp-server ports: - "${PORT:-3000}:${PORT:-3000}" environment: PORT: ${PORT:-3000} TAILSCALE_API_KEY: ${TAILSCALE_API_KEY} # TELEGRAM_BOT_TOKEN and DEFAULT_CHAT_ID are unused leftover vars in the compose file TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN} DEFAULT_CHAT_ID: ${DEFAULT_CHAT_ID} env_file: - .env restart: unless-stopped healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://localhost:' + (process.env.PORT || 3000), (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"] interval: 30s timeout: 3s retries: 3 start_period: 5s volumes: - ./src:/app/src - ./index.js:/app/index.js # Uncomment for local development with auto-reload # command: npm run dev ``` ## Manual docker run If you prefer to manage the container directly without Compose: ```bash theme={null} docker build -t reacher . ``` ```bash theme={null} docker run -d \ -p 3000:3000 \ --env-file .env \ --restart unless-stopped \ --name reacher \ reacher ``` The flags do the following: * `-d` — run in the background (detached) * `-p 3000:3000` — map host port 3000 to container port 3000 * `--env-file .env` — inject all variables from your `.env` file * `--restart unless-stopped` — restart automatically on crash or host reboot * `--name reacher` — give the container a stable name for log and management commands ## Checking logs ```bash theme={null} docker logs reacher docker logs reacher --follow # stream live output ``` For Docker Compose deployments, use the service name: ```bash theme={null} docker compose logs -f mcp-server ``` ## Health check The `/health` endpoint returns the current server status. It requires the same token as the `/mcp` endpoint: ```bash theme={null} curl "http://localhost:3000/health?token=YOUR_MCP_SECRET" ``` ```json theme={null} { "status": "ok", "timestamp": "2026-03-18T12:00:00.000Z", "dry_run": false } ``` Docker runs its own built-in health check every 30 seconds against this endpoint. You can inspect it with: ```bash theme={null} docker inspect --format='{{.State.Health.Status}}' reacher ``` ## Updating to a new version ```bash theme={null} git pull ``` ```bash Docker Compose theme={null} docker compose up -d --build ``` ```bash Manual docker run theme={null} docker build -t reacher . docker stop reacher && docker rm reacher docker run -d \ -p 3000:3000 \ --env-file .env \ --restart unless-stopped \ --name reacher \ reacher ``` ## Dockerfile reference The included Dockerfile produces a minimal production image: ```dockerfile Dockerfile theme={null} FROM node:22-alpine # Install openssh-client for ssh_exec tool RUN apk add --no-cache openssh-client # Set working directory WORKDIR /app # Copy package files COPY package*.json ./ # Install dependencies with production flag RUN npm install --omit=dev # Copy application code COPY . . # Expose port (default 3000, can be overridden) EXPOSE ${PORT:-3000} # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://localhost:' + (process.env.PORT || 3000), (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})" # Start the server CMD ["node", "index.js"] ``` Key details: * **Base image**: `node:22-alpine` — small Alpine-based Node 22 image * **`openssh-client`**: required for the `ssh_exec` tool to reach Tailscale devices * **`npm install --omit=dev`**: installs only production dependencies to keep the image lean * **Built-in health check**: polls the server's HTTP port every 30 seconds # EasyPanel Source: https://docs.ouim.me/reacher/deployment/easypanel Deploy Reacher on EasyPanel with automatic builds from GitHub and a managed HTTPS URL. EasyPanel is a server management panel that connects directly to your GitHub repository and handles image builds, deployments, and HTTPS termination automatically. It runs on your own VPS, so you retain full control of the infrastructure. ## Prerequisites * An EasyPanel instance running on a VPS ([easypanel.io](https://easypanel.io)) * Your Reacher fork or clone pushed to a GitHub repository * Your credentials ready (see [`.env.example`](https://github.com/thezem/reacher/blob/main/.env.example)) ## Deployment In the EasyPanel dashboard, click **Create Project** and give it a name (e.g. `reacher`). Inside the project, click **Create Service** and choose **App**. Select **GitHub** as the source. Authorize EasyPanel to access your account if prompted, then choose your Reacher repository and the branch you want to deploy (e.g. `main` or `v0.1.0`). EasyPanel will detect the `Dockerfile` automatically and use it to build the image. In the service settings, set the **App Port** to `3000`. This tells EasyPanel which container port to route traffic to. Navigate to the **Environment** tab of your service and add each variable: | Variable | Required | Value | | ----------------------- | -------- | ------------------------------------------- | | `MCP_SECRET` | Yes | A random secret (`openssl rand -hex 32`) | | `TAILSCALE_API_KEY` | Yes | Your Tailscale API key | | `GITHUB_TOKEN` | Yes | Your GitHub personal access token | | `PROXY_ALLOWED_DOMAINS` | Yes | Comma-separated list, e.g. `api.github.com` | | `PORT` | No | Defaults to `3000` | Add any additional variables from `.env.example` that you need. In the service settings, you can set a **Health Check Path**. Because the `/health` endpoint requires the `MCP_SECRET` token as a query parameter, the easiest approach is to skip the HTTP health check and rely on EasyPanel's container restart policy instead. Click **Deploy**. EasyPanel builds the Docker image from your repository and starts the container. You can monitor build and runtime logs from the **Logs** tab of the service. Once the service is running, EasyPanel assigns it a public HTTPS URL (e.g. `https://reacher.your-easypanel-domain.com`). Use this URL to connect Claude.ai: ``` https://reacher.your-easypanel-domain.com/mcp?token=YOUR_MCP_SECRET ``` Go to **Claude.ai** > **Settings** > **Integrations** > **Add custom connector** and paste the URL above. ## Automatic deploys on push EasyPanel can rebuild and redeploy your service automatically whenever you push to GitHub. Enable this in the service's **Source** settings by turning on **Auto Deploy**. If you are iterating on the server configuration without changing code, you can trigger a manual redeploy from the EasyPanel dashboard without a git push. ## Viewing logs Logs are available in real time from the **Logs** tab of your service in the EasyPanel dashboard. There is no separate CLI step required. # Railway & Render Source: https://docs.ouim.me/reacher/deployment/railway-render Deploy Reacher on Railway or Render with automatic HTTPS and GitHub-based deployments. Railway and Render are managed platforms that deploy directly from a GitHub repository. Both handle HTTPS, DNS, and container orchestration automatically — no VPS configuration needed. Both platforms offer free tiers that **spin down idle services** after a period of inactivity. A sleeping server cannot receive requests from Claude.ai. For reliable operation, use a paid plan or deploy to an always-on host (a VPS with Docker or bare Node.js). ## Deploy on Railway Go to [railway.app](https://railway.app) and click **New Project**. Choose **Deploy from GitHub repo** and select your Reacher repository. Railway detects the `Dockerfile` automatically and uses it to build the image. In the service settings under **Deploy**, confirm the start command is: ```bash theme={null} node index.js ``` If Railway does not detect it automatically, set it explicitly. Navigate to the **Variables** tab of your service and add the following: | Variable | Required | Value | | ----------------------- | -------- | ------------------------------------------------------- | | `MCP_SECRET` | Yes | A random secret (`openssl rand -hex 32`) | | `TAILSCALE_API_KEY` | Yes | Your Tailscale API key | | `GITHUB_TOKEN` | Yes | Your GitHub personal access token | | `PROXY_ALLOWED_DOMAINS` | Yes | Comma-separated list, e.g. `api.github.com` | | `PORT` | No | Railway injects `PORT` automatically; you can omit this | Add any additional variables from `.env.example` that apply to your setup. Railway builds and deploys automatically after you save the variables. Watch the build logs from the **Deployments** tab. Once deployed, go to **Settings** > **Networking** and click **Generate Domain**. Railway assigns a public HTTPS URL. Use this to connect Claude.ai: ``` https://your-app.up.railway.app/mcp?token=YOUR_MCP_SECRET ``` Go to **Claude.ai** > **Settings** > **Integrations** > **Add custom connector** and paste the URL from the previous step. ### Auto-deploys Railway redeploys automatically on every push to your connected branch. No manual trigger needed. ## Deploy on Render Go to [render.com](https://render.com), click **New**, and select **Web Service**. Authorize Render to access your GitHub account if prompted, then select your Reacher repository and branch. Set the following in the service configuration: * **Runtime**: `Node` * **Root Directory**: leave blank (or `.` if your repo root contains `index.js`) * **Build Command**: `npm install` * **Start Command**: `node index.js` Scroll to the **Environment Variables** section and add: | Variable | Required | Value | | ----------------------- | -------- | ------------------------------------------- | | `MCP_SECRET` | Yes | A random secret (`openssl rand -hex 32`) | | `TAILSCALE_API_KEY` | Yes | Your Tailscale API key | | `GITHUB_TOKEN` | Yes | Your GitHub personal access token | | `PROXY_ALLOWED_DOMAINS` | Yes | Comma-separated list, e.g. `api.github.com` | Add any additional variables from `.env.example` that apply to your setup. Render injects `PORT` automatically. Do not set it manually — Render assigns the port and expects your app to listen on `process.env.PORT`. Click **Create Web Service**. Render builds from your repository and starts the container. Monitor progress in the **Logs** tab. Render assigns a public HTTPS URL in the format `https://your-app.onrender.com`. It is shown at the top of the service dashboard. Use this to connect Claude.ai: ``` https://your-app.onrender.com/mcp?token=YOUR_MCP_SECRET ``` Go to **Claude.ai** > **Settings** > **Integrations** > **Add custom connector** and paste the URL from the previous step. ### Auto-deploys Render redeploys automatically on every push to your connected branch. This can be disabled in the service settings if you prefer manual deploys. ## Health check path For both platforms, set the health check path to `/health`. Note that this endpoint requires the `MCP_SECRET` token as a query parameter (`?token=YOUR_SECRET`) — configure your platform's health check accordingly, or use a platform-level TCP check on port 3000 instead. ## Environment variable reference See [`.env.example`](https://github.com/thezem/reacher/blob/main/.env.example) for the full list of supported variables and descriptions. # Adding tools Source: https://docs.ouim.me/reacher/extending/adding-tools Add custom tools to Reacher — each tool is a self-contained file with a name, description, schema, and handler. Every tool in Reacher is a single file in `src/tools/`. There is no framework to configure, no plugin system to learn. You create a file, register it in one place, and it appears in Claude's tool list. This guide walks through the complete pattern: the file structure, handler signatures, registration, audit logging, and how to test your new tool. ## The tool file pattern Every tool exports four things: | Export | Type | Purpose | | ------------- | ---------------- | -------------------------------------------------------------------- | | `name` | `string` | Tool identifier Claude uses when calling it | | `description` | `string` | Natural language description Claude uses to decide when to invoke it | | `schema` | Zod shape object | Parameter definitions — descriptions become Claude's parameter docs | | `handler` | `async function` | The implementation | Here's the minimal shape, taken directly from the codebase: ```javascript src/tools/gist_kb.js theme={null} export const name = 'gist_kb' export const description = 'Manage a private personal knowledge base backed by GitHub Gists. ' + 'All entries are namespaced with the cc-- filename prefix automatically. ' + 'Supports list, get, create, update, and delete operations.' export const schema = { action: z.enum(['list', 'get', 'create', 'update', 'delete']), id: z.string().optional().describe('Gist ID - required for get, update, delete'), title: z.string().optional().describe('Filename without prefix - tool adds cc-- automatically'), content: z.string().optional().describe('File content - required for create and update'), description: z.string().optional().describe('Gist description'), } export async function handler(args, env) { const token = env.GITHUB_TOKEN // ... } ``` Put `.describe()` on every Zod field. These strings are what Claude reads when it decides how to fill in parameters — they are your tool's inline documentation. A field without a description leaves Claude guessing. ## Handler signature options Different tools receive different parameters depending on what they need. The server passes only what's required — this limits each tool's access to credentials it doesn't use. | Signature | Used by | When to use | | ------------------------------------ | --------------------------------- | ----------------------------------- | | `handler(args)` | `ssh_exec` | No environment access needed | | `handler(args, apiKey)` | `tailscale_status` | Needs one specific API key | | `handler(args, allowedDomains, env)` | `fetch_external`, `github_search` | Needs domain allowlist + env tokens | | `handler(args, env)` | `gist_kb`, `browser` | Needs full environment object | Choose the most restrictive signature that covers your tool's actual needs. ## Step-by-step: creating a new tool This example builds a `disk_usage` tool that checks free disk space on a remote host. It's new — not already in the codebase — and demonstrates the full pattern cleanly. Create `src/tools/disk_usage.js`: ```javascript src/tools/disk_usage.js theme={null} /** * Disk Usage tool * Returns disk space summary for one or more paths on a remote host via SSH */ import { z } from 'zod' import { spawn } from 'child_process' import { auditLog } from '../lib/audit.js' export const name = 'disk_usage' export const description = 'Check disk space usage on a remote Tailscale device. ' + 'Returns human-readable output for one or more paths. ' + 'Use this before running operations that write large files.' export const schema = { hostname: z .string() .describe('Tailscale hostname of the target device (e.g. "myserver")'), paths: z .array(z.string()) .optional() .default(['/']) .describe('Filesystem paths to check — defaults to root partition'), user: z .string() .optional() .default('ubuntu') .describe('SSH user to connect as (default: ubuntu)'), } /** * @param {{ hostname: string, paths: string[], user: string }} args */ export async function handler({ hostname, paths = ['/'], user = 'ubuntu' }) { const pathList = paths.join(' ') const command = `df -h ${pathList}` return new Promise((resolve) => { const sshArgs = [ '-o', 'StrictHostKeyChecking=no', '-o', 'IdentitiesOnly=yes', '-i', '/root/.ssh/reacher-key', `${user}@${hostname}`, command, ] let stdout = '' let stderr = '' const proc = spawn('/usr/bin/ssh', sshArgs, { timeout: 15_000 }) proc.stdout.on('data', (data) => { stdout += data.toString() }) proc.stderr.on('data', (data) => { stderr += data.toString() }) proc.on('close', (code) => { resolve({ success: code === 0, hostname, user, paths, stdout: stdout.trim(), stderr: stderr.trim(), exitCode: code ?? 1, }) }) proc.on('error', (error) => { resolve({ success: false, hostname, user, paths, error: error.message, exitCode: 1, }) }) }) } ``` The file is entirely self-contained. It imports only what it needs (`z` from Zod, `spawn` from Node's `child_process`), defines its own schema, and handles its own errors. Open `src/mcp-server.js` and add two things: the import at the top, and a `server.tool(...)` call in the body. ```javascript src/mcp-server.js (import) theme={null} // Add this line with the other imports at the top of the file import * as diskUsage from './tools/disk_usage.js' ``` ```javascript src/mcp-server.js (registration) theme={null} // Add this block inside createMCPServer(), following the same pattern // ------------------------------------------------------------------------- // disk_usage - no env vars needed, uses SSH key from filesystem // ------------------------------------------------------------------------- server.tool(diskUsage.name, diskUsage.description, diskUsage.schema, async args => { const result = await diskUsage.handler(args) await auditLog(diskUsage.name, args, result) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } }) ``` The four arguments to `server.tool()` are always: `name`, `description`, `schema`, and an async wrapper that calls the handler and passes the result to `auditLog`. Every tool registration in `mcp-server.js` follows this wrapper pattern: ```javascript src/mcp-server.js theme={null} server.tool(myTool.name, myTool.description, myTool.schema, async args => { const result = await myTool.handler(args) await auditLog(myTool.name, args, result) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } }) ``` `auditLog` writes to `reacher-audit.log` with the tool name, timestamp, arguments, and result. Sensitive keys (authorization headers, tokens) are stripped automatically before writing. You do not need to redact values yourself — just always call `auditLog` in the wrapper, never inside the tool handler. ```bash theme={null} docker restart reacher ``` ```bash theme={null} docker compose restart ``` ```bash theme={null} npm run dev ``` If you're running with `--watch` (`npm run dev`), the server restarts automatically on file changes. Send a `tools/list` request to confirm your tool appears: ```bash theme={null} curl -s -X POST http://localhost:3000/mcp?token=YOUR_MCP_SECRET \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' \ | jq '.result.tools[] | select(.name == "disk_usage")' ``` You should see your tool's name, description, and the full parameter schema in the response. Start a new Claude conversation and ask something that naturally invokes your tool: > "How much disk space is left on homelab?" Claude will call `disk_usage` with `hostname: "homelab"` and return the result. If it doesn't pick up the tool, check that your `description` clearly states the tool's purpose and when to use it — Claude reads that string to decide whether to invoke it. ## Zod schema reference The `schema` export is a plain object whose values are Zod validators. The MCP SDK converts it to a JSON Schema for Claude automatically. ```javascript theme={null} export const schema = { // Required string hostname: z.string().describe('Tailscale hostname of the target device'), // Optional string with default user: z.string().optional().default('ubuntu').describe('SSH user (default: ubuntu)'), // Enum format: z.enum(['json', 'text']).optional().default('json') .describe('Output format — json returns parsed object, text returns raw string'), // Optional array paths: z.array(z.string()).optional().default(['/']) .describe('Paths to check — defaults to root partition'), // Optional object (for POST bodies, etc.) body: z.record(z.any()).optional().describe('Request body for POST requests'), } ``` Write descriptions from Claude's perspective. `"SSH user (default: ubuntu)"` tells Claude what the value is and what to assume when the user doesn't specify. `"string"` tells Claude nothing. ## Accessing environment variables If your tool needs API keys or config values from `.env`, accept `env` as a second parameter and read from it: ```javascript theme={null} export async function handler(args, env) { const apiKey = env.MY_SERVICE_API_KEY if (!apiKey) throw new Error('MY_SERVICE_API_KEY is not set') // ... } ``` Then in `mcp-server.js`, pass `env` when calling the handler: ```javascript theme={null} server.tool(myTool.name, myTool.description, myTool.schema, async args => { const result = await myTool.handler(args, env) await auditLog(myTool.name, args, result) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } }) ``` The `env` object is `process.env` (or a subset of it) passed into `createMCPServer(env)` at startup. Add any new environment variables to `.env.example` with a comment explaining what they're for. This keeps your setup reproducible. # Token injection Source: https://docs.ouim.me/reacher/extending/token-injection How fetch_external injects API credentials per domain — tokens stay server-side, Claude never sees them. Token injection is the mechanism that makes `fetch_external` work as a general-purpose API proxy. Instead of pasting tokens into every prompt, or building a dedicated connector for each API, you map a domain to an environment variable. When Claude calls `fetch_external` with a URL, Reacher looks up that domain, reads the token from the server environment, and injects it into the request automatically. Claude sends the URL. Reacher sends the credential. Claude never sees the token. ## The problem it solves Without token injection, every API call would require one of: * Pasting the token into the Claude conversation (exposed in chat history, prompt injections possible) * Building a dedicated MCP tool per API (maintenance overhead, still needs token storage) * Storing tokens client-side in Claude Desktop config (not available in Claude.ai) Token injection moves credential management entirely to the server. You set a token once in `.env`. Every subsequent call to that domain gets it injected transparently. ## How FETCH\_EXTERNAL\_TOKEN\_MAP works `FETCH_EXTERNAL_TOKEN_MAP` is a JSON string in your `.env` that maps domain names to environment variable names: ```bash .env theme={null} FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_TOKEN"} GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx LINEAR_TOKEN=lin_api_xxxxxxxxxxxxxxxxxxxx ``` The key is the exact hostname (no scheme, no path). The value is the name of the environment variable that holds the token — not the token itself. This indirection matters: you can rotate a token by updating a single env var, without touching the map. You can also share a token across multiple tools (`gist_kb` and `fetch_external` both use `GITHUB_TOKEN`) without duplicating it. ## The token injection flow Claude constructs a `fetch_external` call and sends it to the MCP server: ```json theme={null} { "tool": "fetch_external", "arguments": { "url": "https://api.github.com/repos/thezem/reacher/pulls?state=open", "method": "GET" } } ``` No token, no credentials. Just a URL and a method. The handler extracts the hostname from the URL using the built-in `URL` class: ```javascript src/tools/fetch_external.js theme={null} const parsedUrl = new URL(url) const hostname = parsedUrl.hostname // hostname = "api.github.com" ``` Before any token lookup, the domain is verified against `PROXY_ALLOWED_DOMAINS`: ```javascript src/tools/fetch_external.js theme={null} const allowedList = (allowedDomains || '') .split(',') .map(d => d.trim()) .filter(d => d) if (!allowedList.includes(hostname)) { return { success: false, error: 'Domain not allowed', url, hostname, } } ``` If the domain is not in the allowlist, the request is rejected immediately — no network call, no token lookup. The token map is loaded once at module initialization from `FETCH_EXTERNAL_TOKEN_MAP`: ```javascript src/tools/fetch_external.js theme={null} const TOKEN_INJECTION_MAP = JSON.parse(process.env.FETCH_EXTERNAL_TOKEN_MAP || '{}') ``` Then the hostname is looked up to find which env var holds its token: ```javascript src/tools/fetch_external.js theme={null} const tokenEnvVar = TOKEN_INJECTION_MAP[hostname] // tokenEnvVar = "GITHUB_TOKEN" ``` If the lookup finds a variable name, the token is read from `env` and injected into the request headers: ```javascript src/tools/fetch_external.js theme={null} const finalHeaders = { ...headers } if (tokenEnvVar && env[tokenEnvVar]) { finalHeaders['Authorization'] = `Bearer ${env[tokenEnvVar]}` } ``` `env` here is `process.env` — the token value is only ever read server-side. It is never returned to Claude in any response. The assembled request goes out with the full headers: ```javascript src/tools/fetch_external.js theme={null} const fetchOptions = { method, headers: finalHeaders, } if (body && ['POST', 'PUT', 'PATCH'].includes(method)) { fetchOptions.body = JSON.stringify(body) if (!finalHeaders['Content-Type']) { finalHeaders['Content-Type'] = 'application/json' } } const response = await fetch(url, fetchOptions) ``` The upstream API receives `Authorization: Bearer ghp_xxx` as if a human had set it manually. The response body, status, and headers are returned to Claude. The `Authorization` header is never echoed back — Claude only sees the API's response data, not the credential used to obtain it. The audit log also strips token values before writing, so they do not appear in `reacher-audit.log`. ## Custom header formats The default injection uses `Authorization: Bearer `. Some APIs use different authentication schemes. You have two options: ### Pass the header manually from Claude For one-off requests, Claude can set a custom `Authorization` header directly: ```json theme={null} { "url": "https://api.example.com/resource", "headers": { "X-API-Key": "hardcoded-key-here" } } ``` This works, but the value is visible in the conversation. Use it only for non-sensitive keys or during development. ### Add a custom header format in the tool For APIs that use a scheme other than Bearer (Jira Basic auth, Linear `Authorization: ` without "Bearer", etc.), modify `fetch_external.js` to detect those domains and format the header accordingly. Here is an example that adds support for Jira's Base64 Basic auth: ```javascript src/tools/fetch_external.js theme={null} // After the existing Bearer injection block: const tokenEnvVar = TOKEN_INJECTION_MAP[hostname] if (tokenEnvVar && env[tokenEnvVar]) { // Jira uses Basic auth: base64("email:token") if (hostname.endsWith('atlassian.net')) { const email = env.JIRA_EMAIL const encoded = Buffer.from(`${email}:${env[tokenEnvVar]}`).toString('base64') finalHeaders['Authorization'] = `Basic ${encoded}` } else { finalHeaders['Authorization'] = `Bearer ${env[tokenEnvVar]}` } } ``` Add the corresponding env vars to `.env`: ```bash .env theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,yourcompany.atlassian.net FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","yourcompany.atlassian.net":"JIRA_TOKEN"} GITHUB_TOKEN=ghp_xxx JIRA_TOKEN=your_jira_api_token JIRA_EMAIL=you@yourcompany.com ``` For `api-key` style headers (used by some services like Datadog or Algolia), the same pattern applies — change `Authorization: Bearer` to the appropriate header name and format: ```javascript theme={null} finalHeaders['DD-API-KEY'] = env[tokenEnvVar] ``` ## Adding a new token mapping ```bash .env theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app ``` `fetch_external` will refuse to call any domain not in this list, regardless of what's in the token map. Both values must be set. ```bash .env theme={null} FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_TOKEN"} ``` The key is the exact hostname. The value is the name of the env var that holds the token — not the token itself. ```bash .env theme={null} LINEAR_TOKEN=lin_api_xxxxxxxxxxxxxxxxxxxx ``` The token map is loaded at startup (`JSON.parse(process.env.FETCH_EXTERNAL_TOKEN_MAP || '{}')`). Changes to `.env` require a restart to take effect. ```bash theme={null} docker restart reacher # or docker compose restart ``` Ask Claude to make a test call to the new API: > "Call the Linear API at [https://api.linear.app/graphql](https://api.linear.app/graphql) with a query for my assigned issues." If the token is injected correctly, you'll get a valid API response. If it fails with a 401, check that the hostname in the token map exactly matches the hostname in the URL — including subdomains. ## PROXY\_ALLOWED\_DOMAINS and FETCH\_EXTERNAL\_TOKEN\_MAP interaction These two settings are independent but complementary: | Scenario | PROXY\_ALLOWED\_DOMAINS | FETCH\_EXTERNAL\_TOKEN\_MAP | Outcome | | ------------------------ | ----------------------- | ----------------------------------- | ------------------------------------ | | Domain in both | `api.github.com` | `{"api.github.com":"GITHUB_TOKEN"}` | Request made with injected token | | Domain in allowlist only | `api.github.com` | `{}` | Request made with no auth header | | Domain in map only | *(not listed)* | `{"api.github.com":"GITHUB_TOKEN"}` | Request blocked — domain not allowed | | Domain in neither | *(not listed)* | `{}` | Request blocked | A domain only needs a token map entry if the API requires authentication. Public APIs (e.g. a public REST endpoint with no auth) can appear in `PROXY_ALLOWED_DOMAINS` without a corresponding entry in `FETCH_EXTERNAL_TOKEN_MAP`. ## Security properties The token injection design provides these guarantees: **Tokens never leave the server.** The token value is read from `process.env` inside the handler and written to a request header. It is never included in any MCP response, never logged to `reacher-audit.log`, and never visible in the Claude conversation. **Claude cannot exfiltrate tokens.** Claude can call `fetch_external` with any URL — but it cannot read what token was injected, because the handler does not return that information. The audit log confirms the call happened, not the credential value. **The allowlist prevents open-proxy abuse.** Even with a token map configured, `fetch_external` will not proxy requests to arbitrary domains. The domain check happens before the token lookup, so a misused call fails before any credentials are touched. **Token rotation requires no code changes.** Rotating a credential is a one-line `.env` update followed by a server restart. The token map does not change — only the value of the referenced env var does. If a user-supplied `Authorization` header is passed in the `headers` argument, the injected token overwrites it. Claude cannot override token injection by passing a conflicting header — the server-side token always wins for mapped domains. # Introduction Source: https://docs.ouim.me/reacher/introduction Your own infrastructure layer for Claude. SSH, APIs, memory — all authenticated, all yours. ## What is Reacher? Reacher is a self-hosted MCP (Model Context Protocol) server that turns Claude into a personal infrastructure agent. It gives Claude authenticated access to your machines, your APIs, and persistent memory — across every conversation. You run it on a VPS you own. Claude reaches it over HTTPS. Everything in between — SSH keys, API tokens, Tailscale mesh access — stays on your server. Get Reacher running in under 10 minutes with Docker or bare Node. Link your running server to Claude.ai as a custom connector. Explore all six tools: ssh\_exec, fetch\_external, gist\_kb, and more. Every tool is a single file. Add your own in minutes. *** ## The problem with official MCP connectors Official connectors give you 40 tools when you need 3. They live in someone else's sandbox, they don't know your machines, and they reset when the conversation ends. Reacher is the alternative. One server you own. One authenticated proxy. Every API you care about is just an allowed domain away — no new connector, no new tool, just a new line in your config. *** ## What Reacher actually is A trust boundary with tools attached. The VPS is neutral ground — not your laptop, not Anthropic's servers, yours. When Claude calls `ssh_exec` to reach one of your machines, it goes through a server you control, authenticated with a key you own, over your Tailscale mesh. The whole chain is yours. ### The fetch\_external insight Adding GitHub support to Reacher is not "install the GitHub MCP connector." It's adding `api.github.com` to your allowed domains list. Same tool, same authenticated proxy, new target. Claude already knows REST APIs — it doesn't need a dedicated `github_list_prs` tool. It just needs a way to call the API with your token, without you pasting it into every prompt. ```bash theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.notion.com ``` That's three integrations. *One tool.* *** ## Available tools | Tool | What it does | | ------------------ | ---------------------------------------------------------- | | `ssh_exec` | Run shell commands on any Tailscale device | | `tailscale_status` | List all devices with online/offline status, IPs, OS | | `fetch_external` | Proxy HTTP requests with injected auth per domain | | `github_search` | Search GitHub for pull requests or commits | | `gist_kb` | Read/write a private knowledge base backed by GitHub Gists | | `browser` | Control a headless browser via CDP | *** ## Architecture overview ``` Claude.ai ──HTTPS──► Reacher (your VPS) ──Tailscale SSH──► your machines │ ├──► fetch_external ──► api.github.com (with token) ├──► gist_kb ──────────► GitHub Gists (with token) └──► browser ──────────► headless browser (CDP) ``` Reacher is stateless — a new MCP transport handler is created per request. No session state is stored server-side. Your tokens never leave your server. *** ## Prerequisites * A [Tailscale](https://tailscale.com) account with your devices enrolled * Node.js 18+ or Docker * A VPS or always-on machine reachable over HTTPS from Claude.ai * A Tailscale API key and a GitHub personal access token # Audit log Source: https://docs.ouim.me/reacher/operations/audit-log Every tool call Reacher executes is written to a structured log file with sensitive keys stripped automatically. The audit log gives you a complete, tamper-evident record of what Claude asked Reacher to do and whether it succeeded. It is enabled by default. *** ## What is logged Every tool call — regardless of which tool — produces one JSON line in the audit log: ```json theme={null} {"timestamp":"2026-03-18T14:23:01.456Z","tool":"ssh_exec","input":{"hostname":"prod-server","command":"df -h /","user":"deploy"},"success":true} {"timestamp":"2026-03-18T14:23:15.891Z","tool":"fetch_external","input":{"url":"https://api.github.com/repos/myorg/myrepo/issues","method":"GET"},"success":true} {"timestamp":"2026-03-18T14:24:02.123Z","tool":"gist_kb","input":{"action":"list"},"success":true} {"timestamp":"2026-03-18T14:25:44.310Z","tool":"ssh_exec","input":{"hostname":"prod-server","command":"rm -rf /tmp/cache","user":"deploy"},"success":false} ``` Each entry contains: * `timestamp` — ISO 8601 timestamp of the call * `tool` — name of the tool that was invoked * `input` — sanitized arguments passed to the tool * `success` — `true` if the tool returned successfully, `false` on error or block ## What is not logged Sensitive keys are stripped from `input` before anything is written to disk. Any argument whose key name contains one of the following substrings (case-insensitive) is removed: * `token` * `password` * `secret` * `key` This means `GITHUB_TOKEN`, `MCP_SECRET`, API keys, and similar values never appear in the log file — even if they were passed as tool arguments. The result value from the tool is not logged — only the `success` boolean. Raw tool output (stdout, API responses, file contents) does not appear in the audit log. *** ## Configuration ### Enable or disable ```bash .env theme={null} # Enable (default) AUDIT_ENABLED=true # Disable AUDIT_ENABLED=false ``` ```yaml reacher.config.yaml theme={null} audit: enabled: true ``` Audit logging is enabled by default. Set `AUDIT_ENABLED=false` (the exact string `"false"`) to disable it. Disabling the audit log means you have no record of what commands Claude ran on your infrastructure. Only disable it if you have an alternative logging mechanism in place. ### Log file location ```bash .env theme={null} AUDIT_LOG_PATH=./reacher-audit.log ``` ```yaml reacher.config.yaml theme={null} audit: log_path: "./reacher-audit.log" ``` The default path is `./reacher-audit.log` relative to the project root. The file is created automatically if it does not exist. Entries are appended — the file is never truncated by Reacher. In production, use an absolute path and set up external log rotation: ```yaml reacher.config.yaml theme={null} audit: log_path: "/var/log/reacher/audit.log" ``` *** ## Reading the log The log is newline-delimited JSON (NDJSON). Each line is a valid JSON object. **Follow in real time:** ```bash Bare Node theme={null} tail -f reacher-audit.log ``` ```bash Docker theme={null} docker exec reacher tail -f reacher-audit.log ``` **Pretty-print with `jq`:** ```bash theme={null} tail -f reacher-audit.log | jq . ``` **Filter to a specific tool:** ```bash theme={null} jq 'select(.tool == "ssh_exec")' reacher-audit.log ``` **Show only failures:** ```bash theme={null} jq 'select(.success == false)' reacher-audit.log ``` **Count calls by tool:** ```bash theme={null} jq -r '.tool' reacher-audit.log | sort | uniq -c | sort -rn ``` *** ## How it works The `auditLog` function in `src/lib/audit.js` is called after every tool execution in `src/mcp-server.js`. It: 1. Checks `config.audit.enabled` — if `false`, returns immediately 2. Copies the input arguments and deletes any key whose name contains a sensitive substring 3. Builds a log entry with `timestamp`, `tool`, `input`, and `success` 4. Appends the JSON-serialized entry plus a newline to the log file using `fs.appendFile` If the write fails (disk full, permissions issue), the error is printed to stderr but does not crash the server or affect the tool response. # Dry-run mode Source: https://docs.ouim.me/reacher/operations/dry-run Have ssh_exec report what it would execute without making any SSH connection. Dry-run mode lets you verify your setup, test blocklist rules, and onboard new users — all without touching your infrastructure. Dry-run mode only affects `ssh_exec`. All other tools (`fetch_external`, `gist_kb`, `browser`, etc.) continue to execute normally when dry-run is enabled. *** ## How it works When dry-run is enabled, `ssh_exec` still evaluates all safety checks — the blocklist and directory allowlist run as normal. If the command passes those checks, instead of opening an SSH connection, the tool returns a response describing what it would have run: ```json theme={null} { "success": true, "dry_run": true, "would_execute": "df -h /", "hostname": "prod-server", "user": "deploy" } ``` Blocked commands are still blocked in dry-run mode — they return the same `blocked: true` response regardless: ```json theme={null} { "success": false, "blocked": true, "reason": "Command blocked by reacher config", "matched_rule": "rm -rf", "hostname": "prod-server", "user": "deploy", "command": "rm -rf /tmp/cache" } ``` *** ## Enabling dry-run ```bash .env theme={null} DRY_RUN=true ``` ```yaml reacher.config.yaml theme={null} dry_run: true ``` Set `DRY_RUN=true` (the exact string `"true"`) in your `.env` file, or set `dry_run: true` in `reacher.config.yaml`. Restart the server for the change to take effect. To disable, set `DRY_RUN=false` or remove the variable. The default is `false`. Environment variables take precedence over YAML values. If `DRY_RUN=true` is set in your environment, `dry_run: false` in the YAML file has no effect. *** ## Use cases **Validating your setup** Before granting Claude live SSH access, enable dry-run to confirm the server is running, Claude can reach it, and `ssh_exec` is receiving commands correctly. The `would_execute` response in Claude's output confirms the tool invocation path is working end to end. **Testing blocklist rules** When adding or changing entries in `ssh.blocked_commands`, dry-run lets you verify your rules without executing anything. Ask Claude to run the commands you want blocked — confirm the blocklist triggers — then disable dry-run. ```bash theme={null} # In .env DRY_RUN=true SSH_BLOCKED_COMMANDS=rm -rf /,shutdown,reboot,mkfs ``` **Onboarding new users** Share your Reacher instance with a new team member with dry-run enabled. They can explore and issue commands to learn the tool surface without any risk to live infrastructure. Disable dry-run when you're confident in the guardrails. **Auditing planned actions** If you want to review what Claude plans to do before committing, enable dry-run, run your workflow, check the audit log for the `would_execute` commands, then disable dry-run and re-run. *** ## Limitations * Dry-run only intercepts `ssh_exec`. It does not affect `fetch_external`, `gist_kb`, `github_search`, or `browser`. * There is no partial dry-run — it is all-or-nothing for the `ssh_exec` tool. * The dry-run flag is read at startup. Changing it requires a server restart. # Safety mechanisms Source: https://docs.ouim.me/reacher/operations/safety Configure SSH guardrails, directory restrictions, and domain whitelisting to define Reacher's access boundaries. Reacher gives Claude real access to your infrastructure — that's the point. The mechanisms described here let you define the boundaries. You own the risk tolerance. Safety in Reacher is opt-in and additive. A default installation with no safety configuration is fully functional but fully open. Start with the defaults below and tighten them to match your environment. *** ## SSH command blocklist The blocklist prevents `ssh_exec` from running commands that match any entry in the list. Matching happens **before** the SSH connection is made — a blocked command never leaves the server. ### How it works Matching is **substring-based** and **case-insensitive**. If `rm -rf` is in the blocklist, all of the following are blocked: ``` rm -rf /tmp/cache RM -RF / sudo rm -rf /home/user ``` The tool returns immediately with `success: false` and `blocked: true`: ```json theme={null} { "success": false, "blocked": true, "reason": "Command blocked by reacher config", "matched_rule": "rm -rf", "hostname": "myserver", "user": "deploy", "command": "rm -rf /tmp/old" } ``` ### Configuration ```yaml reacher.config.yaml theme={null} ssh: blocked_commands: - "rm -rf" - "shutdown" - "reboot" - "mkfs" - "dd" - "format" - ":(){ :|:& };:" # fork bomb ``` ```bash .env theme={null} SSH_BLOCKED_COMMANDS=rm -rf /,shutdown,reboot,mkfs,dd,format ``` The `.env.example` ships with `rm -rf /,shutdown,reboot,mkfs,dd,format` as sensible starting defaults. Extend this list based on the commands your environment should never run. *** ## Directory allowlist The directory allowlist restricts `ssh_exec` to paths under specific prefixes. When the list is non-empty, `ssh_exec` parses path tokens from the command and rejects any that fall outside the allowed set. ### How it works Matching is **prefix-based**. The tool extracts tokens from the command that start with `/`, `~`, or `./`, then checks each one: ``` Allowed: /home/deploy ✓ /home/deploy/scripts/release.sh ✓ /home/deploy/logs/app.log ✗ /home/other/file ✗ /etc/passwd ``` An empty `allowed_dirs` list (the default) applies no restriction — all paths are permitted. ### Configuration ```yaml reacher.config.yaml theme={null} ssh: allowed_dirs: - "/home/deploy" - "/tmp" - "/var/log" ``` ```bash .env theme={null} SSH_ALLOWED_DIRS=/home/deploy,/tmp,/var/log ``` *** ## Domain whitelisting for fetch\_external `fetch_external` and `github_search` only proxy requests to domains listed in `PROXY_ALLOWED_DOMAINS`. Requests to any other hostname are rejected before any network call is made. ```bash .env theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.notion.com ``` This prevents the server from being used as an open proxy. Add only the API domains your workflows actually need. See [Domain allowlist](/reacher/configuration/domain-allowlist) for the full configuration reference. *** ## Principle of least exposure Different tools receive different subsets of the environment to minimize what each one can access: | Tool | What it receives | | --------------------------------- | ------------------------------------------------- | | `ssh_exec` | No env — operates on config only | | `tailscale_status` | Tailscale API key only | | `fetch_external`, `github_search` | `allowedDomains` + full env (for token injection) | | `gist_kb`, `browser` | Full env object | This is enforced in handler signatures rather than runtime checks. A tool that doesn't receive `GITHUB_TOKEN` in its arguments cannot use it, regardless of what's in the process environment. *** ## Audit logging Every tool call is logged to a file with timestamp, tool name, sanitized arguments, and result. Sensitive keys (`token`, `password`, `secret`, `key`) are stripped automatically before writing. See [Audit log](/reacher/operations/audit-log) for the full reference. *** ## Dry-run mode When `DRY_RUN=true`, `ssh_exec` evaluates safety rules and returns a `would_execute` response without making any SSH connection. Useful for validating your setup or testing a new blocklist configuration. See [Dry-run mode](/reacher/operations/dry-run) for details. # Troubleshooting Source: https://docs.ouim.me/reacher/operations/troubleshooting Diagnose and fix common issues with Reacher's connection, SSH, tools, and browser integration. Find your symptom below. Each section covers the most common cause and how to resolve it. *** ## Connection issues Claude requires a public HTTPS URL to reach your Reacher server. 1. Confirm the server is running and reachable: ```bash theme={null} curl "https://mcp.yourdomain.com/health?token=YOUR_MCP_SECRET" ``` You should get `{"status":"ok",...}`. If this fails, the problem is network/DNS, not Reacher. 2. Verify your reverse proxy is forwarding to the correct port (default `3000`). 3. Make sure the full URL you gave Claude includes the token: ``` https://mcp.yourdomain.com/mcp?token=YOUR_MCP_SECRET ``` 4. Check that port `3000` is not blocked by a firewall on your VPS. The `MCP_SECRET` in the URL does not match the one the server has loaded. 1. Check what secret the server is using: ```bash theme={null} docker exec reacher printenv MCP_SECRET ``` 2. Compare it to the token in the URL you registered in Claude.ai. They must match exactly — including case and any trailing whitespace. 3. If you changed `MCP_SECRET` in `.env`, restart the container: ```bash theme={null} docker compose restart reacher ``` Check the container logs for the actual error: ```bash theme={null} docker logs reacher ``` Common causes: * **Missing required env vars** — `MCP_SECRET`, `TAILSCALE_API_KEY`, and `GITHUB_TOKEN` must all be set. A missing variable causes startup to fail with an explicit error message listing the missing keys. * **Port already in use** — another process is bound to port `3000`. Change `PORT` in `.env` or stop the other process. * **Malformed `.env`** — quotes around values can cause parsing issues. Values in `.env` do not need quotes. *** ## SSH issues Ask Claude to run `tailscale_status` and confirm the target device shows as `online: true`. An offline device cannot be reached over SSH. Tailscale SSH must be explicitly enabled on each target machine. SSH to the device manually and run: ```bash theme={null} sudo tailscale up --ssh ``` From the Reacher server itself, test that SSH works: ```bash theme={null} docker exec -it reacher ssh user@hostname ``` If this fails, the issue is with SSH credentials or Tailscale connectivity, not Reacher's tool layer. Reacher uses `/root/.ssh/reacher-key`. Verify it exists and is mounted correctly: ```bash theme={null} docker exec reacher ls -la /root/.ssh/reacher-key ``` The key must have `600` permissions. Reacher sets this automatically, but verify if you mounted it manually. The command matched an entry in `SSH_BLOCKED_COMMANDS`. The response includes `matched_rule` showing which rule triggered. To see your current blocklist: ```bash theme={null} docker exec reacher printenv SSH_BLOCKED_COMMANDS ``` If the block is incorrect — for example, a legitimate command contains a blocked substring — you have two options: 1. **Remove the specific entry** from `SSH_BLOCKED_COMMANDS` or `ssh.blocked_commands` in your YAML config, then restart. 2. **Reword the command** to avoid the substring. Remember that matching is substring-based, so `dd if=...` matches the blocked entry `dd`. After changing the config, restart the server: ```bash theme={null} docker compose restart reacher ``` `ssh_exec` returned `blocked: true` with `reason: "Path not in allowed directories"`. This means `SSH_ALLOWED_DIRS` (or `ssh.allowed_dirs`) is set and the command references a path outside the allowed prefixes. Options: 1. **Add the path** to your `SSH_ALLOWED_DIRS` list: ```bash theme={null} # .env SSH_ALLOWED_DIRS=/home/deploy,/tmp,/var/log,/new/allowed/path ``` 2. **Clear the allowlist** if you no longer want directory restrictions: ```bash theme={null} SSH_ALLOWED_DIRS= ``` Restart the server after any change. *** ## Tool errors The target domain is not in `PROXY_ALLOWED_DOMAINS`. Add it to your `.env`: ```bash theme={null} PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.newdomain.com ``` Restart the server. The full hostname must match — `api.github.com` and `github.com` are treated as separate entries. The `GITHUB_TOKEN` in your `.env` is either missing, expired, or lacks the required scopes. 1. Verify the token is set: ```bash theme={null} docker exec reacher printenv GITHUB_TOKEN ``` 2. Check that the token has not expired in [GitHub Settings → Tokens](https://github.com/settings/tokens). 3. Confirm the token has the right scopes: * `repo` (or specific repo access) for `github_search` and API calls via `fetch_external` * `gist` for `gist_kb` 4. Generate a new token and update `.env`, then restart the container. `gist_kb` requires `GITHUB_TOKEN` with `gist` scope (read and write). A token with only `repo` scope will fail. 1. Go to [GitHub Settings → Tokens](https://github.com/settings/tokens) 2. Edit your token and enable the `gist` scope 3. Save and update `GITHUB_TOKEN` in `.env` 4. Restart the server If the token was recently regenerated and you updated `.env`, confirm the container picked up the new value: ```bash theme={null} docker compose restart reacher docker exec reacher printenv GITHUB_TOKEN ``` *** ## Browser tool issues The `browser` tool depends on the `agent-browser` CLI being installed globally on the server. ```bash theme={null} npm install -g agent-browser ``` If you're running Reacher in Docker, this must be included in your Docker image or installed in the container. The default `Dockerfile` does not include it — add the install step if you need browser support: ```dockerfile theme={null} RUN npm install -g agent-browser ``` Rebuild and restart the container after making this change. The browser tool connects to a CDP-compatible browser at `ws://BROWSER_CDP_HOST:BROWSER_CDP_PORT`. Connection refused means no browser is listening on that address. 1. Confirm the browser process is running. 2. Check the default endpoint: `ws://127.0.0.1:9222`. 3. If your browser is on a different host or port, update `.env`: ```bash theme={null} BROWSER_CDP_HOST=127.0.0.1 BROWSER_CDP_PORT=9222 ``` 4. Restart Reacher after changing the config. [Lightpanda](https://github.com/lightpanda-io/lightpanda) is a lightweight headless browser with CDP support. To use it with Reacher: 1. Download and install Lightpanda on your server following the instructions in its repository. 2. Start Lightpanda with CDP enabled on port `9222`: ```bash theme={null} lightpanda serve --host 127.0.0.1 --port 9222 ``` 3. Leave `BROWSER_CDP_HOST` and `BROWSER_CDP_PORT` at their defaults, or set them to match your Lightpanda configuration. 4. Verify the connection: ```bash theme={null} curl http://127.0.0.1:9222/json/version ``` # Quickstart Source: https://docs.ouim.me/reacher/quickstart Get Reacher running in under 10 minutes with Docker or bare Node. ## Prerequisites Before you start, make sure you have: * A [Tailscale](https://tailscale.com) account with your devices enrolled in a mesh network * A VPS or always-on machine to host the server (must be reachable from Claude.ai) * Node.js 18+ or Docker installed on that machine * A Tailscale API key and a GitHub personal access token ```bash theme={null} git clone --branch v0.1.0 https://github.com/thezem/reacher.git cd reacher ``` Copy both config files from their examples: ```bash theme={null} cp .env.example .env cp reacher.config.example.yaml reacher.config.yaml ``` Open `.env` and fill in your credentials. Every required variable is listed below. **Required environment variables** | Variable | Required | Description | | -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MCP_SECRET` | Yes | Shared secret that Claude.ai sends with every request. Generate with `openssl rand -hex 32`. | | `TAILSCALE_API_KEY` | Yes | API key for querying your Tailscale network. Needs "Devices (read)" scope. Get one at [tailscale.com/admin/settings/keys](https://login.tailscale.com/admin/settings/keys). | | `GITHUB_TOKEN` | Yes | Personal access token for GitHub API calls and `gist_kb` read/write. Needs `gist` scope. Create at [github.com/settings/tokens](https://github.com/settings/tokens). | | `PROXY_ALLOWED_DOMAINS` | Yes | Comma-separated list of domains `fetch_external` is allowed to call (e.g. `api.github.com,api.linear.app`). | | `PORT` | No | HTTP port to listen on. Defaults to `3000`. | | `DRY_RUN` | No | Set to `true` to have `ssh_exec` log commands without executing them. Defaults to `false`. | | `AUDIT_ENABLED` | No | Enable audit logging. Defaults to `true`. | | `AUDIT_LOG_PATH` | No | Path to the audit log file. Defaults to `./reacher-audit.log`. | | `SSH_BLOCKED_COMMANDS` | No | Comma-separated list of commands to block from `ssh_exec` (e.g. `rm -rf /,shutdown,reboot`). | | `SSH_ALLOWED_DIRS` | No | Comma-separated list of directories SSH operations are restricted to. Empty means no restriction. | | `FETCH_EXTERNAL_TOKEN_MAP` | No | JSON mapping of domain → env var name for automatic token injection (e.g. `{"api.github.com":"GITHUB_TOKEN"}`). | | `BROWSER_CDP_HOST` | No | Host of the CDP-compatible browser. Defaults to `127.0.0.1`. | | `BROWSER_CDP_PORT` | No | Port of the CDP-compatible browser. Defaults to `9222`. | `MCP_SECRET`, `TAILSCALE_API_KEY`, and `GITHUB_TOKEN` are validated at startup. The server will refuse to start if any of these are missing. **Optional: edit `reacher.config.yaml`** The YAML config controls safety settings. The defaults are reasonable, but you can tighten them: ```yaml theme={null} ssh: blocked_commands: - "rm -rf" - "dd" - ":(){ :|:& };:" # fork bomb allowed_dirs: [] # empty = no directory restriction audit: enabled: true log_path: "./reacher-audit.log" dry_run: false ``` Environment variables always take precedence over YAML config values. For `ssh_exec` to reach a machine, Tailscale SSH must be enabled on it. Run this on each device you want Reacher to control: ```bash theme={null} sudo tailscale up --ssh ``` Then verify SSH access works from your server: ```bash theme={null} ssh user@device-hostname ``` If that succeeds manually, Reacher will be able to reach it too. You can skip this step initially and come back to it. The `tailscale_status` tool works without SSH being enabled, and you can enable SSH per device as you need it. Choose your runtime: ```bash Docker (recommended) theme={null} docker compose up -d ``` ```bash Bare Node theme={null} npm install node index.js ``` Check the server started correctly: ```bash Docker theme={null} docker compose logs -f reacher ``` ```bash Bare Node theme={null} # The server prints a startup message: # ✅ MCP Server started on http://localhost:3000 # POST http://localhost:3000/mcp # GET http://localhost:3000/health ``` If startup fails with "Missing required environment variables", check that `MCP_SECRET`, `TAILSCALE_API_KEY`, and `GITHUB_TOKEN` are all set in your `.env` file. Run a `tools/list` request against the local server to confirm everything is working: ```bash theme={null} curl -X POST "http://localhost:3000/mcp?token=YOUR_MCP_SECRET" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' ``` Replace `YOUR_MCP_SECRET` with the value you set in `.env`. You should get back a JSON response listing all six tools: `ssh_exec`, `tailscale_status`, `fetch_external`, `github_search`, `gist_kb`, and `browser`. You can also check the health endpoint (requires the same token): ```bash theme={null} curl "http://localhost:3000/health?token=YOUR_MCP_SECRET" ``` Expected response: ```json theme={null} { "status": "ok", "timestamp": "...", "dry_run": false } ``` Claude.ai requires a public HTTPS URL to connect to your server. If you're running on a VPS, set up a reverse proxy in front of port 3000. ```text Caddy theme={null} # /etc/caddy/Caddyfile mcp.yourdomain.com { reverse_proxy localhost:3000 } ``` ```nginx Nginx theme={null} # /etc/nginx/sites-available/reacher server { listen 80; server_name mcp.yourdomain.com; location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` Caddy handles HTTPS certificate provisioning automatically via Let's Encrypt. With Nginx, you'll need to run Certbot separately to obtain and renew a certificate. After setting up the proxy, confirm it's reachable from the public internet: ```bash theme={null} curl -X POST "https://mcp.yourdomain.com/mcp?token=YOUR_MCP_SECRET" \ -H "Content-Type: application/json" \ -d '{"jsonrpc": "2.0", "method": "tools/list", "id": 1}' ``` 1. Go to **Claude.ai** > **Settings** > **Integrations** 2. Click **Add custom connector** 3. Paste your server URL: `https://mcp.yourdomain.com/mcp?token=YOUR_MCP_SECRET` 4. Save and start a new conversation Try asking: *"What devices are on my Tailscale network?"* — Claude should call `tailscale_status` and list your devices. See [Connecting to Claude](/reacher/connecting-to-claude) for full instructions and troubleshooting. *** ## Next steps Step-by-step instructions for adding the connector in Claude.ai settings. Learn what each tool does and how to use it effectively. Full reference for all environment variables and YAML config options. Configure blocked commands, directory restrictions, and audit logging. # browser Source: https://docs.ouim.me/reacher/tools/browser CDP-based headless browser control via the agent-browser CLI — navigate, click, fill, and snapshot. `browser` controls a headless browser by running `agent-browser` CLI commands. It connects to any CDP-compatible browser (such as [Lightpanda](https://github.com/lightpanda-io/browser)) over the Chrome DevTools Protocol WebSocket interface. `agent-browser` maintains its own daemon session between calls, so a `navigate` in one call and a `click` in the next operate on the same browser state. ## Prerequisites Install the `agent-browser` CLI globally on the server where Reacher is deployed: ```bash theme={null} npm install -g agent-browser ``` Run a CDP-compatible browser and expose its WebSocket debugger. Lightpanda is a lightweight option: ```bash theme={null} lightpanda serve --host 127.0.0.1 --port 9222 ``` Any Chromium-based browser launched with `--remote-debugging-port=9222` also works. By default Reacher connects to `ws://127.0.0.1:9222`. Override with environment variables if your browser is on a different host or port: ```bash theme={null} BROWSER_CDP_HOST=127.0.0.1 BROWSER_CDP_PORT=9222 ``` ## Parameters An `agent-browser` command string. The tool parses this into arguments respecting quoted strings before passing to the CLI. Supported commands: * `open ` — navigate to a URL * `snapshot -i` — take an accessibility/DOM snapshot of the current page * `click @` — click an element by its agent-browser element ID * `fill @ ` — fill a form field with a value * `close` — close the current page/session Example values: `"open https://example.com"`, `"snapshot -i"`, `"click @e2"`, `"fill @e3 hello world"` ## Configuration | Variable | Default | Description | | ------------------ | ----------- | ---------------------------------- | | `BROWSER_CDP_HOST` | `127.0.0.1` | Host of the CDP-compatible browser | | `BROWSER_CDP_PORT` | `9222` | WebSocket debugger port | The tool connects to `ws://:` and passes it to `agent-browser` via the `--cdp` flag. ## Return value `true` if `agent-browser` exited with code 0. The command string that was executed. Trimmed standard output from `agent-browser`. For `snapshot`, this contains the page's accessibility tree or DOM representation. Trimmed standard error output. If `agent-browser` is not installed, this will contain a helpful installation message. Process exit code from `agent-browser`. `0` means success. ## Usage examples ```json Navigate to a page theme={null} { "command": "open https://example.com" } ``` ```json Snapshot the current page theme={null} { "command": "snapshot -i" } ``` ```json Click an element theme={null} { "command": "click @e5" } ``` ```json Fill a form field theme={null} { "command": "fill @e3 user@example.com" } ``` ```json Close the browser session theme={null} { "command": "close" } ``` ## Common use cases **Scrape a page** Navigate to a URL, take a snapshot to see the page structure, then extract the relevant content from `stdout`. ``` 1. open https://status.myservice.com 2. snapshot -i ``` **Fill and submit a form** Navigate to a page, take a snapshot to discover element IDs, then fill and submit the form. ``` 1. open https://app.example.com/login 2. snapshot -i ← read element IDs from output 3. fill @e2 myusername 4. fill @e3 mypassword 5. click @e4 ← submit button 6. snapshot -i ← verify logged-in state ``` **Take a visual snapshot for monitoring** Navigate to a dashboard or status page and snapshot it to check current state as part of an automated check. **Automate repetitive web tasks** Sequence multiple commands across calls to automate multi-step flows — the `agent-browser` daemon preserves session state between tool calls. The `agent-browser` binary must be installed on the server where Reacher runs, not on the machine Claude is accessed from. If it's missing, `stderr` will contain: `agent-browser binary not found. Run npm install -g agent-browser on the server where Reacher is deployed.` # fetch_external Source: https://docs.ouim.me/reacher/tools/fetch-external Authenticated HTTP proxy with per-domain token injection — add any API with one line in your config. `fetch_external` is Reacher's general-purpose HTTP proxy. It forwards requests to external APIs, automatically injecting the right authentication token based on the target domain. Tokens stay on the server — Claude never sees them. This is the core insight behind Reacher's design: Claude already knows REST APIs. It doesn't need a dedicated tool for GitHub, Linear, or Notion. It needs a way to call those APIs with your credentials. `fetch_external` is that mechanism. ``` PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app,api.notion.com ``` That's three API integrations. One tool. ## Prerequisites * The target domain must be listed in `PROXY_ALLOWED_DOMAINS` (comma-separated) * For authenticated APIs, add the domain-to-token mapping in `FETCH_EXTERNAL_TOKEN_MAP` ```bash theme={null} # .env PROXY_ALLOWED_DOMAINS=api.github.com,api.linear.app FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN","api.linear.app":"LINEAR_TOKEN"} GITHUB_TOKEN=ghp_xxxxxxxxxxxx LINEAR_TOKEN=lin_api_xxxxxxxxxxxx ``` When a request hits `api.github.com`, the tool looks up `"api.github.com"` in `FETCH_EXTERNAL_TOKEN_MAP`, finds `"GITHUB_TOKEN"`, reads that env var, and injects `Authorization: Bearer ghp_xxx` automatically. ## Parameters The full URL to fetch. Must be a valid URL including scheme (e.g. `https://api.github.com/user`). HTTP method. Accepted values: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS`. Defaults to `GET`. Request body for `POST`, `PUT`, and `PATCH` requests. Serialized as JSON. `Content-Type: application/json` is added automatically if not already present. Additional headers to include in the request. Merged with any injected auth headers. If you specify `Authorization` here it will be overridden by the injected token for the domain. ## Return value `true` if the HTTP response status was in the 2xx range (`response.ok`). HTTP status code returned by the upstream server. HTTP status text (e.g. `"OK"`, `"Not Found"`). Response headers as a key-value object. Parsed response body. JSON responses are returned as a parsed object. All other content types are returned as a string. The URL that was fetched. ## Security When a request targets a domain not in `PROXY_ALLOWED_DOMAINS`, the tool returns immediately without making any network request: ```json theme={null} { "success": false, "error": "Domain not allowed", "url": "https://evil.example.com/steal", "hostname": "evil.example.com" } ``` This prevents the server from being used as a proxy to arbitrary destinations. Only explicitly allowed domains are reachable. ## Usage examples ```json GitHub — get authenticated user theme={null} { "url": "https://api.github.com/user", "method": "GET" } ``` ```json GitHub — list open PRs theme={null} { "url": "https://api.github.com/repos/thezem/reacher/pulls?state=open", "method": "GET" } ``` ```json Linear — create an issue theme={null} { "url": "https://api.linear.app/graphql", "method": "POST", "body": { "query": "mutation { issueCreate(input: { title: \"Fix the thing\", teamId: \"TEAM_ID\" }) { success } }" } } ``` ```json Notion — query a database theme={null} { "url": "https://api.notion.com/v1/databases/DATABASE_ID/query", "method": "POST", "headers": { "Notion-Version": "2022-06-28" }, "body": { "filter": { "property": "Status", "select": { "equals": "In Progress" } } } } ``` For GitHub-specific searches (PRs by author, commits by date), use the dedicated `github_search` tool. It returns clean minimal output optimized for that use case. # gist_kb Source: https://docs.ouim.me/reacher/tools/gist-kb Persistent knowledge base backed by private GitHub Gists, namespaced with the cc-- prefix. `gist_kb` gives Claude a persistent, writable memory store that survives across conversations. It wraps the GitHub Gist API, storing each entry as a private gist with a `cc--` filename prefix to namespace all Reacher-managed content. Unlike in-context memory, entries written to `gist_kb` are available in any future session — no need to re-establish context, re-discover devices, or re-paste configuration. ## How it works Each entry is a private GitHub Gist where the main file's name starts with `cc--`. The prefix is enforced automatically — if you pass `title: "device-map"`, the gist file is created as `cc--device-map`. This namespacing ensures `list` only returns Reacher-managed entries and never clutters other gists. Operations map directly to GitHub Gist API calls: * `list` — paginate all gists and filter to those with `cc--` files * `get` — fetch the full content of a gist by ID * `create` — create a new private gist * `update` — patch an existing gist's file content * `delete` — delete a gist permanently Requires `GITHUB_TOKEN` with the `gist` OAuth scope. ## Parameters The operation to perform. One of: `"list"`, `"get"`, `"create"`, `"update"`, `"delete"`. Gist ID. Required for `get`, `update`, and `delete`. Obtain from a `list` or `create` response. Filename without the `cc--` prefix — the tool adds it automatically. Required for `create` and `update`. Example: `"device-map"` creates a file named `cc--device-map`. File content as a string. Required for `create` and `update`. Optional gist description. Used in `create`. Visible in the GitHub Gists UI. ## Return value Return shape varies by action. `true` on success. **`list` response** Number of matching gists found. Array of matching gist summaries. Gist ID. Gist description. Array of filenames with the `cc--` prefix. ISO 8601 creation timestamp. ISO 8601 last-updated timestamp. **`get` response** Gist ID. Gist description. ISO 8601 creation timestamp. ISO 8601 last-updated timestamp. Key-value map of filename → file content string. **`create` response** ID of the newly created gist. Gist description. Full filename including the `cc--` prefix. ISO 8601 creation timestamp. **`update` response** Gist ID. Full filename including the `cc--` prefix. ISO 8601 last-updated timestamp. **`delete` response** ID of the deleted gist. `true` on successful deletion. ## Usage examples ```json Save a device map theme={null} { "action": "create", "title": "device-map", "content": "homelab: home NAS and media server (linux)\nprod-server: production VPS running reacher (linux)\nwin-workstation: Windows dev machine", "description": "Tailscale device map" } ``` ```json List all knowledge base entries theme={null} { "action": "list" } ``` ```json Read a specific entry theme={null} { "action": "get", "id": "abc123def456" } ``` ```json Update an existing entry theme={null} { "action": "update", "id": "abc123def456", "title": "device-map", "content": "homelab: home NAS and media server (linux)\nprod-server: production VPS (linux) — reacher deployed here\nwin-workstation: Windows dev machine\nnew-rpi: Raspberry Pi 4 in the office" } ``` ```json Delete an entry theme={null} { "action": "delete", "id": "abc123def456" } ``` ## Common use cases **Saving a device map after first-time setup** After running `tailscale_status` and probing SSH access for each device, save the results. Future sessions pick up the map without re-running discovery. **Storing project configs** Save deploy commands, environment-specific notes, or service endpoints so they're available in any conversation without re-pasting. **Persisting notes across conversations** Write observations, decisions, or action items during a session. Read them back at the start of the next session to restore context. **Bootstrapping with AGENT.MD** The `AGENT.MD` file included in Reacher is a Claude-readable guide that instructs Claude to run `gist_kb list` at the start of each session to check for existing device maps and configs. Drop `AGENT.MD` into a new conversation to trigger automatic context restoration. Use descriptive `title` values like `"device-map"`, `"deploy-commands"`, or `"project-notes-reacher"` so `list` output is self-explanatory. # github_search Source: https://docs.ouim.me/reacher/tools/github-search Search GitHub for pull requests or commits by author and date, with clean minimal output. `github_search` is a focused tool for querying GitHub activity. It searches for pull requests or commits by author within a date range and returns only the essential fields — no pagination tokens, no nested objects, no raw API noise. Unlike calling the GitHub API through `fetch_external`, this tool handles query construction, header setup (commits search requires a special `Accept` header), and response shaping automatically. ## Prerequisites * `GITHUB_TOKEN` must be set in your environment * `api.github.com` must be in `PROXY_ALLOWED_DOMAINS` * The token must be configured for injection in `FETCH_EXTERNAL_TOKEN_MAP`: ```bash theme={null} PROXY_ALLOWED_DOMAINS=api.github.com FETCH_EXTERNAL_TOKEN_MAP={"api.github.com":"GITHUB_TOKEN"} GITHUB_TOKEN=ghp_xxxxxxxxxxxx ``` ## Parameters What to search for. Must be either `"prs"` (pull requests) or `"commits"`. Repository in `owner/repo` format (e.g. `"thezem/reacher"`). GitHub username to filter by (e.g. `"thezem"`). ISO date string. Returns items created on or after this date (e.g. `"2026-03-01"`). For commits this filters by committer date. Number of results to return. Min `1`, max `100`. Defaults to `25`. ## Return value `true` on a successful GitHub API response. Array of result objects. Shape depends on `type`. Pull request number. PR title. HTML URL to the PR on GitHub. `"open"` or `"closed"`. Whether the PR is a draft. Whether the PR has been merged. ISO date (date portion only, e.g. `"2026-03-10"`). Short SHA (first 7 characters). First line of the commit message only. ISO date of the commit (date portion only, e.g. `"2026-03-10"`). HTML URL to the commit on GitHub. ## Usage examples ```json Find open PRs by author theme={null} { "type": "prs", "repo": "thezem/reacher", "author": "thezem", "created_after": "2026-01-01" } ``` ```json Find recent merged PRs theme={null} { "type": "prs", "repo": "thezem/reacher", "author": "thezem", "created_after": "2026-03-01", "per_page": 10 } ``` ```json Find commits this week theme={null} { "type": "commits", "repo": "thezem/reacher", "author": "thezem", "created_after": "2026-03-10" } ``` ## Example output ```json PR results theme={null} { "success": true, "items": [ { "number": 42, "title": "Add browser tool with CDP support", "url": "https://github.com/thezem/reacher/pull/42", "state": "closed", "draft": false, "merged": true, "created_at": "2026-03-12" } ] } ``` ```json Commit results theme={null} { "success": true, "items": [ { "sha": "a1b2c3d", "message": "fix: handle CDP connection timeout gracefully", "date": "2026-03-15", "url": "https://github.com/thezem/reacher/commit/a1b2c3d" } ] } ``` The GitHub commits search API requires the `Accept: application/vnd.github.cloak-preview` header. This is handled automatically by the tool — you don't need to pass it. # Tools Overview Source: https://docs.ouim.me/reacher/tools/overview All six Reacher tools — what they do, how they're registered, and when to use each one. Reacher exposes six tools to Claude. Each tool is a single self-contained file in `src/tools/`. They share a common registration pattern but are otherwise fully independent — no shared state, no cross-tool dependencies. ## Design philosophy The surface area is intentional. Rather than shipping a dedicated tool for every API (GitHub, Linear, Notion, Jira…), Reacher ships one authenticated HTTP proxy — `fetch_external` — and lets you add any API by adding its domain to an allowlist. Claude already knows REST APIs. It just needs a way to call them with your credentials without you pasting tokens into every prompt. The result is six tools that cover the full infrastructure loop: * **Discover** your machines (`tailscale_status`) * **Execute** commands on them (`ssh_exec`) * **Call** any allowed API (`fetch_external`) * **Search** GitHub activity (`github_search`) * **Persist** knowledge across conversations (`gist_kb`) * **Automate** web tasks (`browser`) ## Tool registration pattern Every tool in `src/tools/*.js` exports four things: ```js theme={null} export const name = 'tool_name' export const description = '...' export const schema = { param: z.string().describe('...') } // Zod shape export async function handler(args, ...envArgs) { ... } ``` All tools are registered in `src/mcp-server.js` via `server.tool(name, description, schema, handler)`. The server wraps each handler to inject the appropriate environment variables (API keys, allowed domains) so Claude never receives them directly. ```js theme={null} // Example from mcp-server.js server.tool(sshExec.name, sshExec.description, sshExec.schema, async args => { const result = await sshExec.handler(args) await auditLog(sshExec.name, args, result) return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] } }) ``` Each handler receives only the env vars it needs — not the full environment — following a least-privilege principle. ## Tools summary | Tool | What it does | Key use case | | ------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | `ssh_exec` | Run shell commands on any Tailscale device | Manage servers, check logs, run deployments | | `tailscale_status` | List all devices with online/offline status, IPs, OS | Discover hostnames before SSHing, debug connectivity | | `fetch_external` | Proxy HTTP requests with injected auth per domain | Call GitHub, Jira, or any API without pasting tokens | | `github_search` | Search GitHub for pull requests or commits | Find work by author and date range with minimal output | | `gist_kb` | Read/write a private knowledge base backed by GitHub Gists | Persist notes, configs, and context across conversations | | `browser` | Control a headless browser via CDP using `agent-browser` CLI | Scrape pages, fill forms, take snapshots, automate web tasks | ## All tools Execute shell commands on remote Tailscale devices. Supports Linux and Windows (cmd/PowerShell), with a command blocklist and directory allowlist. List every device in your Tailscale network with IPs, OS, online status, and last-seen time. Authenticated HTTP proxy with per-domain token injection. Add any API by adding one line to your config. Search GitHub for pull requests or commits by author and date range, with clean minimal output. Persistent knowledge base backed by private GitHub Gists. Read, write, list, and delete entries across conversations. CDP-based headless browser control via the `agent-browser` CLI. Navigate, click, fill forms, and take snapshots. # ssh_exec Source: https://docs.ouim.me/reacher/tools/ssh-exec Execute shell commands on remote Tailscale devices — no manual SSH key setup required. `ssh_exec` runs a shell command on a remote device over Tailscale SSH. It uses the Tailscale hostname directly — no IP addresses, no manual `known_hosts` management. The server spawns a real `ssh` process using a dedicated key mounted at `/root/.ssh/reacher-key`. Windows targets are supported via `cmd` (default) or `powershell`. PowerShell commands are automatically encoded as Base64 UTF-16LE before being sent, which prevents quoting and escaping issues. Tailscale SSH must be explicitly enabled on each target device before this tool can connect. Run `sudo tailscale up --ssh` on the device to enable it. ## Parameters Tailscale hostname of the target device (e.g. `"myserver"`). Use `tailscale_status` to list available hostnames. Shell command to execute on the remote device. SSH user to connect as. The schema default is `hazem` (the project author's username — you will almost certainly need to override this). Always specify the correct user for your target device, e.g. `ubuntu`, `root`, or your own username. Shell to use on Windows targets. Accepted values: `cmd` or `powershell`. Defaults to `cmd`. Ignored on non-Windows hosts. ## Return value `true` if the command exited with code 0. The target hostname as provided. The SSH user used for the connection. The command that was executed. The shell used (`cmd` or `powershell`). Trimmed standard output from the command. Trimmed standard error output. Process exit code. `0` means success. Present and `true` when the command was blocked by a safety rule. Also includes `reason` and `matched_rule` fields. Present and `true` when `DRY_RUN=true` is set. Includes a `would_execute` field instead of running the command. ## Usage examples ```json Check disk usage theme={null} { "hostname": "homelab", "command": "df -h /" } ``` ```json Tail application logs theme={null} { "hostname": "prod-server", "command": "tail -n 50 /var/log/myapp/app.log" } ``` ```json List running processes theme={null} { "hostname": "prod-server", "command": "ps aux --sort=-%cpu | head -20" } ``` ```json Run a deployment script theme={null} { "hostname": "deploy-box", "command": "/home/deploy/scripts/deploy.sh" } ``` ```json PowerShell on Windows theme={null} { "hostname": "win-workstation", "command": "Get-Process | Sort-Object CPU -Descending | Select-Object -First 10", "shell": "powershell" } ``` ## Safety considerations Reacher enforces two optional safety layers configured in `reacher.config.yaml`: ### Command blocklist `ssh.blocked_commands` is a list of substrings. If any blocked string appears in the command (case-insensitive), the tool returns immediately with `success: false` and `blocked: true` — the SSH connection is never made. ```yaml theme={null} ssh: blocked_commands: - 'rm -rf' - 'shutdown' - 'reboot' ``` ### Directory allowlist `ssh.allowed_dirs` restricts SSH operations to specific paths. The tool parses path tokens from the command (tokens starting with `/`, `~`, or `./`) and checks each one against the list. An empty list means no restriction. ```yaml theme={null} ssh: allowed_dirs: - /home/deploy - /var/log/myapp ``` ### Dry-run mode Set `DRY_RUN=true` to have `ssh_exec` evaluate safety rules and return a `would_execute` response without making any SSH connection. Useful for testing configurations. All tool calls are written to `reacher-audit.log` with timestamp, arguments, and result. Sensitive keys are stripped automatically. ## Common use cases * **Check logs** — `tail`, `journalctl`, `cat` on log files * **Inspect processes** — `ps`, `top`, `htop` snapshots * **Run deployments** — trigger deploy scripts, `git pull`, `docker compose up` * **System health** — `df`, `free`, `uptime`, `systemctl status` * **File inspection** — read configs, check file permissions, list directory contents Run `tailscale_status` first to discover available hostnames and verify a device is online before attempting SSH. # tailscale_status Source: https://docs.ouim.me/reacher/tools/tailscale-status List all devices in your Tailscale network with IPs, OS, online status, and last-seen time. `tailscale_status` queries the Tailscale API and returns every device enrolled in your tailnet. It separates devices into online and offline, and returns key metadata for each one. This tool is the natural starting point for any SSH workflow — use it to discover available hostnames before calling `ssh_exec`, or to debug why a device isn't reachable. Requires `TAILSCALE_API_KEY` to be set in your environment. Generate a key at [tailscale.com/admin/settings/keys](https://tailscale.com/admin/settings/keys). ## Parameters This tool takes no parameters. It fetches your entire tailnet automatically. ## Return value Always `true` on a successful API call. Aggregate counts for the tailnet. Total number of devices enrolled. Number of devices currently online. Number of devices currently offline. Array of device objects, one per enrolled device. Full device name as registered in Tailscale (includes tailnet domain suffix). Short hostname of the device. Use this value as the `hostname` parameter in `ssh_exec`. Either `"online"` or `"offline"`. Operating system reported by Tailscale (e.g. `linux`, `windows`, `macOS`). Array of Tailscale IP addresses assigned to the device. Tailscale client version running on the device. ISO 8601 timestamp of when the device was last seen by the Tailscale coordination server. ## Example output ```json theme={null} { "success": true, "summary": { "total": 4, "online": 3, "offline": 1 }, "devices": [ { "name": "homelab.tail1234.ts.net", "hostname": "homelab", "status": "online", "os": "linux", "ips": ["100.64.0.1"], "clientVersion": "1.58.2-t8b4a2e60f-g4b2e60f", "lastSeen": "2026-03-18T10:22:00Z" }, { "name": "win-workstation.tail1234.ts.net", "hostname": "win-workstation", "status": "online", "os": "windows", "ips": ["100.64.0.2"], "clientVersion": "1.58.2", "lastSeen": "2026-03-18T10:21:45Z" }, { "name": "old-vps.tail1234.ts.net", "hostname": "old-vps", "status": "offline", "os": "linux", "ips": ["100.64.0.3"], "clientVersion": "1.52.0", "lastSeen": "2026-02-10T08:00:00Z" } ] } ``` ## Common use cases **Discover hostnames before SSH** Call `tailscale_status` at the start of a session to build a map of available devices. The `hostname` field is what you pass directly to `ssh_exec`. **Check online status before running commands** If `ssh_exec` fails to connect, `tailscale_status` will show whether the device is offline vs. a real SSH configuration problem. **Audit your network** Spot devices running old client versions (`clientVersion`), identify machines that haven't been seen recently (`lastSeen`), or verify a new device enrolled successfully. **Save a device map to the knowledge base** After discovering your devices, use `gist_kb` to save the hostname-to-purpose mapping so future sessions don't need to re-run discovery. ```json theme={null} // Follow-up gist_kb call to save the device map { "action": "create", "title": "device-map", "content": "homelab: home NAS and media server\nprod-server: production VPS\nwin-workstation: Windows dev machine", "description": "Tailscale device map" } ```