Farm.js

Auth0 Integration

Use Auth0 when the identity provider should host login and enterprise connections while Farm owns the application-facing OAuth routes and local session.

The built-in flow uses Authorization Code with PKCE, validates signed state, reads the user profile, and stores that profile in a signed HTTP-only cookie.

Add Auth0

farm add integration auth0 --ui

Configure

src/lib/integrations.ts
import { auth0 } from "@farm.js/auth0";

export const appIntegrations = {
  auth: auth0({
    domain: process.env.AUTH0_DOMAIN,
    clientId: process.env.AUTH0_CLIENT_ID,
    clientSecret: process.env.AUTH0_CLIENT_SECRET,
    secret: process.env.AUTH0_SECRET,
    appBaseUrl: process.env.APP_BASE_URL,
    callbackPath: "/auth/callback",
    protectedRoutes: ["/dashboard(.*)"],
  }),
} as const;

export type AppIntegrations = typeof appIntegrations;

callbackPath stays root-relative. Farm combines it with APP_BASE_URL, or the incoming request origin, to create the callback URL sent to Auth0.

You can use callbackUrl instead, but it must be absolute:

auth0({
  callbackUrl: "https://app.example.com/auth/callback",
});

Choose route ownership

Let Farm construct the Auth0 flow

The configuration above is the default path. When instance is omitted, Farm constructs the OAuth flow from the Auth0 domain, client credentials, session secret, callback settings, and scopes. Farm then owns the login, signup, callback, logout, profile, and protected-route behavior.

Provide application-owned middleware

auth0({
  instance: {
    matcher: ["/auth(.*)", "/dashboard(.*)"],
    middleware(request) {
      return myAuth0Middleware(request);
    },
  },
});

This advanced path accepts a compatible middleware adapter rather than the Auth0 SDK itself. The supplied instance wins and Farm only mounts its middleware; it does not create the built-in login, callback, logout, or profile routes.

Environment variables

VariableRequiredPurpose
AUTH0_DOMAINYesTenant domain without a required protocol, such as tenant.us.auth0.com.
AUTH0_CLIENT_IDYesOAuth application client ID.
AUTH0_CLIENT_SECRETDepends on client typeUsed by confidential clients during code exchange.
AUTH0_SECRETProductionSigns state and local session cookies.
APP_BASE_URLRecommended in productionPublic app origin used for callbacks and protected-route redirects.

Without AUTH0_SECRET, development generates a random secret once per app in .farm/auth0-secret (gitignored) and reuses it across restarts. Production startup fails when no secret is configured.

Routes and methods

MethodDefault routePurpose
GET/auth/loginStarts login. Accepts returnTo.
GET/auth/signupStarts signup with Auth0's signup screen hint.
GET/auth/callbackValidates state, exchanges the code, and writes the local session.
GET/auth/logoutClears the local session and redirects through Auth0 logout.
GET/auth/profileReturns the current local profile or 401.

Every path can be changed with loginPath, signUpPath, callbackPath, logoutPath, or profilePath.

Start login

Normal document navigation redirects directly:

<a href="/auth/login?returnTo=/dashboard">Sign in</a>

The typed integration client asks for the redirect URL as JSON:

const result = await apiClient.auth.login.get({
  query: {
    returnTo: "/dashboard",
  },
});

if (result.data) {
  window.location.assign(result.data.redirectTo);
}

Signup uses the same shape through apiClient.auth.signup.get(...).

Read the profile

const result = await api.auth.profile.get();

if (result.error && "status" in result.error && result.error.status === 401) {
  // No valid local Auth0 session.
}

const user = result.data?.user;

The session cookie contains the fetched Auth0 profile and an expiry derived from the token response. The integration does not persist refresh tokens, so an expired local session requires a new login.

Session lifetime and sign-out

The session is a signed cookie, not a server-side record. Sign-out clears the cookie in that browser, but a copy of it (from another device, a log, or malware) keeps working until it expires, which by default is the token lifetime Auth0 returns (often 24 hours). Two options narrow that:

const revoked = new Set<string>(); // use a shared store (Redis, your database) in production

auth0({
  // Sign-ins last at most an hour, whatever the token lifetime.
  sessionMaxAge: 60 * 60,
  // Record the ending session on sign-out, and reject it on every later read.
  onSignOut(session) {
    if (session.sessionId) revoked.add(session.sessionId);
  },
  isSessionRevoked(session) {
    return session.sessionId !== undefined && revoked.has(session.sessionId);
  },
});

isSessionRevoked runs for the profile route and protected routes. Sessions issued before these options existed carry no sessionId or issue time; they end at their original expiry.

Protect app routes

auth0({
  protectedRoutes: ["/dashboard(.*)", "/settings(.*)"],
});

A signed-out request is redirected with status 303, so the browser follows it with a GET, to:

/auth/login?returnTo=/the/original/path

Only root-relative returnTo values are accepted. Invalid or external values fall back to /dashboard.

Options

OptionDefaultUse
instanceNoneApplication-owned middleware adapter; disables the built-in flow.
domainAUTH0_DOMAINAuth0 tenant domain.
clientIdAUTH0_CLIENT_IDOAuth client ID.
clientSecretAUTH0_CLIENT_SECRETOAuth client secret for confidential clients.
secretAUTH0_SECRETCookie and state signing secret.
appBaseUrlAPP_BASE_URLPublic app origin.
callbackUrlNoneAbsolute callback URL.
callbackPath/auth/callbackCallback route when callbackUrl is not supplied.
audienceNoneOptional Auth0 API audience.
scopesopenid profile emailRequested OAuth scopes.
tokenEndpointAuthMethodautoclient_secret_basic, client_secret_post, none, or automatic selection.
protectedRoutesNoneOne matcher or a list of matchers.
allowedOriginsApp origin onlyExtra origins allowed to drive sign-out.
cookieNamefarm_auth0_sessionSession cookie name.
stateCookieNamefarm_auth0_stateLogin state cookie name.
sessionMaxAgeToken lifetimeLongest a sign-in lasts, in seconds.
isSessionRevokedNoneReject a session the app revoked.
onSignOutNoneCalled with the session being ended on sign-out.

A sibling subdomain (evil.example.com next to app.example.com) can set a cookie for .example.com with a longer Path. Browsers send that cookie first, so it would pick which session or login state the app reads. A __Host- name cannot be set from another host, so use one in production:

auth0({
  cookieName: "__Host-farm_auth0_session",
  stateCookieName: "__Host-farm_auth0_state",
});

Farm adds the Secure, Path=/, and no-Domain attributes the prefix requires. Browsers accept such cookies over https and from http://localhost; on another plain-http dev host keep the defaults. Renaming the session cookie signs existing users out once.

Cross-site request protection

The sign-out route (GET /auth/logout) only accepts requests from the app's own origin. A cross-site request is answered with 403 before the session is cleared, so another site cannot force a visitor to sign out.

List any other trusted origin explicitly. The pattern syntax matches serverActions.allowedOrigins:

auth0({
  allowedOrigins: ["https://portal.example.com", "*.example.com"],
});

A sign-out link or bookmark keeps working; only an explicitly cross-site navigation is rejected.

Production checklist

  • Allow the exact callback URL in Auth0.
  • Allow the app origin as a logout URL.
  • Use a strong AUTH0_SECRET.
  • Set APP_BASE_URL behind proxies or custom domains.
  • Test bad state, callback errors, expired cookies, login return paths, and logout.