@wocha/nextjs
v2.1.0
Published
Next.js App Router adapter for Wocha authentication
Maintainers
Readme
@wocha/nextjs
Next.js App Router adapter for Wocha authentication. Implements a BFF (backend-for-frontend) pattern: OAuth runs server-side with a confidential client, and sessions are stored in encrypted httpOnly cookies.
Install
npm install @wocha/nextjs
# or: pnpm add / yarn add / bun add @wocha/nextjsPeer dependencies: Next.js 14+, React 18+.
Environment variables
Set these in .env.local — all bundles (route handler, middleware, server helpers) read them automatically:
WOCHA_CLIENT_ID=your-client-id
WOCHA_CLIENT_SECRET=your-client-secret
WOCHA_ISSUER=https://tenant.auth.wocha.ai
WOCHA_API_URL=https://tenant.api.wocha.ai # optional
WOCHA_COOKIE_SECRET=your-cookie-secret # optional, defaults to client secretRegister the callback URI https://your-app.com/api/auth/callback in the Wocha Console.
Quick start
Three pieces of wiring: a route handler, middleware, and a client session provider.
1. Route handler — app/api/auth/[...wocha]/route.ts:
Important: The catch-all segment must be named
wocha(i.e.[...wocha]). The SDK readsparams.wochato route login, callback, logout, and other auth actions.
import { createWochaHandler } from "@wocha/nextjs";
export const { GET, POST } = createWochaHandler({
clientId: process.env.WOCHA_CLIENT_ID!,
clientSecret: process.env.WOCHA_CLIENT_SECRET!,
issuer: process.env.WOCHA_ISSUER!,
});When env vars are set, you can omit the config object entirely — createWochaHandler() will resolve from env:
import { createWochaHandler, wochaAuthConfigFromEnv } from "@wocha/nextjs";
export const { GET, POST } = createWochaHandler(wochaAuthConfigFromEnv()!);2. Middleware — middleware.ts:
When WOCHA_CLIENT_ID, WOCHA_CLIENT_SECRET, and WOCHA_ISSUER are set, middleware works without passing config:
import { withWochaAuth } from "@wocha/nextjs/middleware";
export default withWochaAuth(undefined, {
publicPaths: ["/", "/about"],
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};To protect all application routes:
export default withWochaAuth(undefined, { publicPaths: [] });Note:
withWochaAuth()is public by default. Protect routes by callingauth.protect()in the middleware callback, or by explicitly supplyingpublicPaths(use[]to protect every route except/api/authand its descendants).
Expired access tokens are refreshed silently using the refresh token before redirecting to login.
Overlapping refreshes for the same token, OAuth client and DPoP key share one token request within the same loaded SDK module. Results are discarded when the request finishes. Separate bundles, server instances and requests arriving after completion are not coordinated; rotating refresh tokens still require care in distributed deployments.
Persist refreshes in middleware or Route Handlers before rendering Server Components. Server Components cannot write cookies, so refreshing only inside a Server Component can leave the browser holding an old refresh token. authorizedFetch saves refreshed credentials in writable contexts before fetching the resource, including when that resource request fails.
OIDC discovery, signing-key retrieval and token requests each have a 15-second network deadline. A DPoP nonce challenge permits one additional token request.
3. Client provider — wrap your layout:
"use client";
import { WochaSessionProvider } from "@wocha/nextjs/client";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<WochaSessionProvider refetchOnWindowFocus refetchInterval={0}>
{children}
</WochaSessionProvider>
);
}| Prop | Default | Description |
|------|---------|-------------|
| refetchOnWindowFocus | true | Refetch session when the browser window regains focus. |
| refetchInterval | 0 | Poll interval in ms; 0 disables polling. |
Visit /api/auth/login to start the sign-in flow.
Subpath imports
| Import path | Purpose |
|-------------|---------|
| @wocha/nextjs | Route handler (createWochaHandler), middleware (withWochaAuth), server helpers |
| @wocha/nextjs/middleware | Middleware only (for Edge runtime bundles) |
| @wocha/nextjs/server | Server Components helpers (getSession, getUser, …) |
| @wocha/nextjs/client | Client hooks and WochaSessionProvider |
Configuration reference
Config can be passed explicitly or resolved from environment variables (WOCHA_CLIENT_ID, WOCHA_CLIENT_SECRET, WOCHA_ISSUER). Each bundle entry (index, middleware, server) resolves config independently — there is no shared singleton across bundles.
Pass a WochaAuthConfig to createWochaHandler() or use wochaAuthConfigFromEnv():
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| clientId | string | Yes | OAuth client ID. |
| clientSecret | string | Yes | OAuth client secret (server-side only). |
| issuer | string | Yes | OIDC issuer URL. |
| baseUrl | string | No | Application base URL for redirect URIs. Defaults to the request origin. |
| apiUrl | string | No | Platform API base URL for org switching. Defaults to the issuer origin. |
| scopes | string[] | No | OIDC scopes. Default: openid, profile, email, org_id, offline_access. |
| postLoginRedirect | string | No | Default path after login when no return_to is provided. Default: /. |
| postLogoutRedirect | string | No | URL after logout. Defaults to the application base URL. |
| authErrorRedirect | string | No | Path for OAuth error redirects (default: postLoginRedirect or /). Receives ?auth_error= query params. |
| sessionCookieName | string | No | Encrypted session cookie name. Default: greet_session. |
| sessionSecret | string | No | Separate secret for cookie encryption. Defaults to clientSecret. Set via WOCHA_COOKIE_SECRET. |
| dpop | boolean \| { enabled, algorithm? } | No | Enable RFC 9449 DPoP sender-constrained tokens. Default: false. |
DPoP (Sender-Constrained Tokens)
When dpop: true is set, the SDK:
- Generates an ECDSA P-256 key pair at the callback step
- Includes DPoP proofs in all token requests
- Stores the private key in a separate encrypted cookie (
{sessionCookieName}_dpop) - Includes DPoP proofs when calling resource servers via
buildResourceRequestHeaders()
The DPoP key is stored separately from the session cookie to keep both under the 4096-byte browser limit.
Pushed Authorization Requests (PAR)
When the OIDC discovery document advertises a pushed_authorization_request_endpoint, the SDK automatically uses it. This is required by Wocha production deployments. No configuration is needed — PAR support is transparent.
Route handler reference
Mount at app/api/auth/[...wocha]/route.ts. Routes are derived from the catch-all segment:
| Route | Method | Description |
|-------|--------|-------------|
| /api/auth/login | GET | Starts OAuth flow. Accepts ?return_to=/path to redirect after login (same-origin paths only, see below). ?prompt=none requests a silent re-authorise (no hosted UI). Sets an encrypted PKCE cookie. |
| /api/auth/callback | GET | Exchanges the authorisation code, writes the session cookie, redirects to return_to (validated again). If the PKCE cookie is missing, restarts login once with prompt=none instead of returning missing_verifier. |
| /api/auth/logout | GET, POST | Revokes tokens, clears session cookie, redirects to the OIDC end-session endpoint. |
| /api/auth/session | GET | Returns the current session (see below), renewing it when it expires within 60 seconds. |
| /api/auth/refresh | POST | Refreshes tokens server-side and updates the session cookie. 401 when the refresh token is refused (see Session renewal); 503 with Retry-After (cookies kept) when the identity provider is unavailable. |
| /api/auth/switch-org | POST | Switches organisation. Body: { targetOrgId: string }. May return { redirect } for re-authorisation. |
Session response
GET /api/auth/session returns:
{
"session": {
"user": { "id": "...", "email": "...", "name": "...", "orgId": "..." },
"accessToken": "...",
"expiresAt": 1717000000,
"orgId": "...",
"orgIds": ["..."]
}
}When unauthenticated: { "session": null }, also when the refresh token was refused (see Session renewal).
When the session could not be renewed because the identity provider is unreachable or failing (network error, timeout, 5xx, 429), the route answers 503 with Retry-After and { "session": null, "error": "session_unavailable", "retryable": true }, and keeps the session cookie. WochaSessionProvider treats that (and any network error) as "retry later": a signed-in user stays signed in with error set.
return_to
return_to must be a same-origin relative path. The login, callback and BYO routes refuse anything else and fall back to postLoginRedirect (or /); logout falls back to postLogoutRedirect (or the app origin). Refused: absolute and protocol-relative URLs, backslashes (/\evil.com), tab, newline and other control characters (/\t/evil.com), paths that dot segments collapse into //host, and any of these percent-encoded (/%5Cevil.com, /%2F%2Fevil.com, double-encoded forms included).
Refresh tokens are not exposed to the browser — they remain encrypted in the httpOnly session cookie and are used only by the /refresh route.
Server helpers
Import from @wocha/nextjs/server (or the main package):
import { getSession, getUser, requireSession, getAccessToken } from "@wocha/nextjs/server";| Function | Return type | Description |
|----------|-------------|-------------|
| getSession(config?) | Promise<GreetSession \| null> | Reads and decrypts the session cookie. Renews it in Route Handlers and Server Actions; null when there is no usable session. |
| getSessionState(config?) | Promise<WochaSessionState> | Like getSession, but says why a session is not usable: active, needs_refresh, unavailable or signed_out. |
| getUser(config?) | Promise<GreetUser \| null> | Returns session.user or null. |
| requireSession(config?) | Promise<GreetSession> | Returns the session or redirects to /api/auth/login?return_to=.... In a Route Handler or Server Action, throws WochaAuthError("session_unavailable") when renewal failed transiently (for up to SESSION_UNAVAILABLE_MAX_SECONDS after expiry, then redirects). |
| getAccessToken(config?) | Promise<string \| null> | Returns the access token from the session. |
Sign-in / sign-out URL helpers
import { signInUrl, signOutUrl } from "@wocha/nextjs/server";
signInUrl("/dashboard"); // → /api/auth/login?return_to=%2Fdashboard
signUpUrl("/welcome"); // → /api/auth/login?signup=1&return_to=%2Fwelcome
signOutUrl("/"); // → /api/auth/logout?return_to=%2F
// Custom auth base path (default: /api/auth)
signInUrl("/app", "/auth"); // → /auth/login?return_to=%2FappServer helpers resolve config from env vars when not passed explicitly.
Session renewal
Access tokens are renewed with the refresh token 60 seconds before they expire (SESSION_RENEWAL_MARGIN_SECONDS). Identity providers rotate the refresh token on every renewal, so the renewed session must reach the browser, or its next request presents a spent token and is signed out.
- Middleware (
withWochaAuth) renews first. The new cookie goes to the browser and to Server Components and Route Handlers in the same request, so they never see an expired token. This needs Next.js 14.2.8 or later (x-middleware-set-cookie), the SDK's minimum. - Server Components cannot set cookies, so the SDK never renews there. An expired session is reported as
needs_refreshbygetSessionState();getSession()returnsnull,requireSession()sends the user through sign-in (the identity provider skips the form while its own session is alive), andauthorizedFetch()throwssession_refresh_required. - Route Handlers and Server Actions renew themselves, at most once per request:
getSession()followed byauthorizedFetch()makes one token request. - A refused refresh token (
invalid_grant) means the session is dead, but the refusing response does not expire the session cookies: another request may have rotated that same token a moment earlier, and its new cookie must survive. The refusing response marks the token instead (an httpOnly<cookie>_refusedcookie holding a 64-bit SHA-256 fingerprint, never the token) and answers "sign in" (a redirect, a401,{ "session": null }orsigned_out). The browser's next request settles it: if it still carries the refused token, the session cookies are cleared without asking the identity provider again; if it carries a newer token, the session carries on. Do not expire the session cookies yourself onsigned_out. - Other failures that no retry can fix (an
id_tokenthat fails verification, a stored DPoP key that no longer signs) clear the session at once. - A transient failure keeps the session: a network error, timeout, 5xx, 408 or 429, and also a
4xxthat refuses the client rather than the grant (invalid_clientafter a secret rotation, an HTML page from a WAF), so a misconfiguration does not sign every user out. A token still inside the margin keeps working. An expired one gets503+Retry-Afterfrom middleware and the session route,unavailablefromgetSessionState(), andsession_unavailablefromrequireSession()andauthorizedFetch(). The delay grows with the time since expiry (5 s up to 60 s, jittered). Once the token has been expired forSESSION_UNAVAILABLE_MAX_SECONDS(300 s), middleware andrequireSession()send the user through sign-in instead, still keeping the cookies.
Overlapping renewals of the same refresh token within one server instance share a single token request. Parallel requests on different instances can still both present the same refresh token; configure a refresh-token rotation grace period on the identity provider so the second one is not refused (and, with reuse detection, the whole token family revoked).
Use in Server Components, Route Handlers, and Server Actions:
import { requireSession } from "@wocha/nextjs/server";
export default async function DashboardPage() {
const session = await requireSession();
return <h1>Hello, {session.user.email}</h1>;
}Client hooks
Import from @wocha/nextjs/client. Requires WochaSessionProvider.
useSession()
const { data, status, error, refresh } = useSession();
// data: GreetSession | null
// status: "loading" | "authenticated" | "unauthenticated"
// error: Error | null
// refresh: () => Promise<void>useUser()
const { user, status } = useUser();
// user: GreetUser | null
// status: SessionStatususeOrg()
const { orgId, orgIds, switchOrg, isLoading } = useOrg();switchOrg(targetOrgId, endpoint?)
Standalone function (does not require a hook):
import { switchOrg } from "@wocha/nextjs/client";
await switchOrg("org-abc123");usePermission(check)
Checks a SpiceDB permission using the session access token. Requires apiUrl in handler config.
const { allowed, isLoading } = usePermission({
resource: { type: "document", id: docId },
permission: "edit",
});Return type: { allowed: boolean; isLoading: boolean; error: Error | null }.
signIn() / signOut()
import { signIn, signOut, signInUrl, signOutUrl } from "@wocha/nextjs/client";
signIn("/dashboard"); // navigates to login with return_to
signOut("/"); // navigates to logout with return_toClient Components
Import from @wocha/nextjs/client. All components require WochaSessionProvider.
SignInButton / SignUpButton / SignOutButton
Link-styled controls that navigate to the BFF login, signup, or logout routes. Shorter aliases SignIn, SignUp, and SignOut are also exported.
import { SignInButton, SignUpButton, SignOutButton } from "@wocha/nextjs/client";
<SignInButton returnTo="/dashboard" />
<SignUpButton returnTo="/welcome" />
<SignOutButton returnTo="/" />SignUpButton appends signup=1 to the login URL; the route handler forwards screen_hint=signup to the authorisation server.
UserButton
Shows the signed-in user with a sign-out action. Renders nothing when unauthenticated.
import { UserButton } from "@wocha/nextjs/client";
// Default avatar menu
<UserButton />
// Custom render
<UserButton>
{({ user, signOut }) => (
<div>
<span>{user.email}</span>
<button type="button" onClick={() => signOut("/")}>Sign out</button>
</div>
)}
</UserButton>OrgSwitcher
Organisation dropdown when the user belongs to multiple orgs. Renders nothing for a single org or when unauthenticated.
import { OrgSwitcher } from "@wocha/nextjs/client";
<OrgSwitcher />
<OrgSwitcher>
{({ orgId, orgIds, switchOrg, isLoading }) => (
<select
value={orgId}
disabled={isLoading}
onChange={(e) => void switchOrg(e.target.value)}
>
{orgIds?.map((id) => (
<option key={id} value={id}>{id}</option>
))}
</select>
)}
</OrgSwitcher>Authenticated / Unauthenticated
Conditional rendering based on session status.
import { Authenticated, Unauthenticated, SignInButton } from "@wocha/nextjs/client";
<Authenticated fallback={<p>Loading…</p>}>
<Dashboard />
</Authenticated>
<Unauthenticated>
<SignInButton />
</Unauthenticated>Protect
Permission-gated wrapper. Renders children only when the user is authenticated and passes the permission check. Renders fallback (or null) while loading or when denied. Matches @wocha/react's Protect component.
import { Protect } from "@wocha/nextjs/client";
<Protect
permission={{ resource: { type: "document", id: "doc-123" }, permission: "edit" }}
fallback={<p>You don't have access.</p>}
>
<EditForm />
</Protect>Requires apiUrl in handler config — same as usePermission.
Middleware
When env vars are set, middleware requires no config:
import { withWochaAuth } from "@wocha/nextjs/middleware";
export default withWochaAuth(undefined, {
publicPaths: ["/", "/about", "/pricing"],
loginPath: "/api/auth/login",
});You can also pass config explicitly:
export default withWochaAuth(
{
clientId: process.env.WOCHA_CLIENT_ID!,
clientSecret: process.env.WOCHA_CLIENT_SECRET!,
issuer: process.env.WOCHA_ISSUER!,
},
{
publicPaths: ["/", "/about", "/pricing"],
loginPath: "/api/auth/login",
},
);| Option | Default | Description |
|--------|---------|-------------|
| publicPaths | [] | Paths that skip auth checks. Supports trailing * wildcards (e.g. /blog*). |
| loginPath | /api/auth/login | Redirect target for unauthenticated requests. Appends ?return_to= automatically. |
Access tokens are renewed silently 60 seconds before they expire (see Session renewal). When the session cannot be renewed, a page navigation is redirected to login and any other request (RSC fetch, prefetch, fetch()) gets 401 { "error": "session_expired" }, which makes the Next.js router load the page for real. When the identity provider is unavailable, the session is kept and the request gets 503 with Retry-After (for page navigations, a page that retries after a growing delay and links to sign-in), for up to 300 seconds after the token expired. Routes under /api/auth are always public. Configure a custom matcher in middleware.ts to limit which routes the middleware runs on:
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};Security model
- Confidential client: The client secret stays on the server. Token exchange and refresh happen in Route Handlers, never in the browser.
- Encrypted httpOnly cookies: Sessions are serialised and encrypted with AES-256-GCM. The encryption key is derived from the client secret via PBKDF2 (100,000 iterations, SHA-256).
- PKCE: The login flow uses PKCE with the verifier stored in a short-lived encrypted cookie (
greet_pkce). - ID token verification: Callback exchanges verify the
id_tokensignature against the issuer JWKS (RS256/ES256), and validateissandaud. - No refresh token in the browser: The
/sessionendpoint returns onlyuser,accessToken, andexpiresAt. Refresh tokens remain server-side. - No open redirects:
return_tois accepted only as a same-origin relative path, checked at every decoding layer (seereturn_to).
Structured errors
Server-side auth flows throw WochaAuthError with a typed code (e.g. state_mismatch, token_exchange_failed). Import from the main package:
import { WochaAuthError } from "@wocha/nextjs";
try {
// route handler logic
} catch (err) {
if (err instanceof WochaAuthError && err.code === "state_mismatch") {
// handle CSRF mismatch
}
}Self-hosted configuration
export const { GET, POST } = createWochaHandler({
clientId: process.env.WOCHA_CLIENT_ID!,
clientSecret: process.env.WOCHA_CLIENT_SECRET!,
issuer: "https://auth.internal.example.com",
apiUrl: "https://api.internal.example.com",
baseUrl: "https://app.internal.example.com",
});Or set the equivalent environment variables — see Environment variables above.
Related packages
| Package | Use when |
|---------|----------|
| @wocha/react | React SPA without a server-side BFF |
| @wocha/sdk | Server-side user/org management via the Management API |
| @wocha/cli | Scaffold auth integration with npx @wocha/cli init |
Migrating from @greet-auth/nextjs
If your project vendors the legacy @greet-auth/nextjs package, migrate to @wocha/nextjs:
- Replace the vendored package with
npm install @wocha/nextjs - Update imports:
@greet-auth/nextjs→@wocha/nextjs - Rename env vars:
GREET_CLIENT_ID→WOCHA_CLIENT_ID,GREET_ISSUER→WOCHA_ISSUER, etc.
The SDK reads GREET_* env vars as a backward-compatible fallback (with a deprecation warning), so the migration can be done incrementally.
Troubleshooting
redirect_uri_mismatch
The callback URL in your app must exactly match a redirect URI registered in the Wocha Console (including scheme, host, port, and path). For this SDK the default is https://your-app.com/api/auth/callback.
CSRF / state_mismatch
The authorisation flow stores a signed state value in a cookie. If login fails with state_mismatch, clear site cookies for your app origin and retry. Avoid opening multiple login tabs in parallel.
Session cookie not set
Cookies require secure: true when NODE_ENV=production. Use HTTPS in production. In local development, http://localhost is allowed. Confirm WOCHA_CLIENT_SECRET (or WOCHA_COOKIE_SECRET) is set and stable across deploys — changing it invalidates existing sessions.
Missing environment variables
The route handler and middleware read WOCHA_CLIENT_ID, WOCHA_CLIENT_SECRET, and WOCHA_ISSUER from the environment. Missing values throw at startup or on the first auth request. Check .env.local is loaded (Next.js does this automatically for dev).
Callback URL not registered
Every environment (local, staging, production) needs its own redirect URI in the Console. A common mistake is registering production only while testing on http://localhost:3000.
Separate API server
If your architecture includes a separate API backend (e.g. NestJS, Go, Python), that server must validate access tokens independently. The @wocha/nextjs BFF handles login and session management; your API validates the forwarded access token via JWKS.
See the Resource server guide for JWT validation patterns in Express, NestJS, Go, Python, and other frameworks.
