@wocha/nextjs
v0.1.0
Published
Next.js App Router adapter for Wocha authentication
Downloads
360
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|api/auth).*)"],
};Or simply:
export default withWochaAuth();Note: Calling
withWochaAuth()withoutpublicPathslogs a console warning — every route except/api/authrequires authentication. SetpublicPathsexplicitly for landing pages and marketing routes.
Expired access tokens are refreshed silently using the refresh token before redirecting to login.
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. Sets an encrypted PKCE cookie. |
| /api/auth/callback | GET | Exchanges the authorisation code, writes the session cookie, redirects to return_to. |
| /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). |
| /api/auth/refresh | POST | Refreshes tokens server-side and updates the session cookie. |
| /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 }.
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. Refreshes expired tokens automatically. |
| 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=.... |
| 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.
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. |
Expired access tokens are refreshed silently before redirecting to login. 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.
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.
