@tula-tech/nextjs
v0.4.0
Published
Tula's Next.js SDK: a same-origin proxy route handler, middleware that verifies sessions offline, server helpers and the React components for the App Router.
Readme
@tula-tech/nextjs
Tula Auth for the Next.js App Router (15 and 16): the prebuilt components, a server that knows
who is signed in, and session cookies that belong to your own origin. See
ADR 0029 for the design and
examples/nextjs-app-router for a complete app.
bun add @tula-tech/nextjs@latest @tula-tech/react@latest # or: npm install …Early release (0.x). The API may change from one release to the next. Install
@tula-tech/reactat the same version. It talks to a Tula API, which you host yourself (docs/self-host.md); everything else is in the repository.
Set up
TULA_API_URL=https://auth.example.com # where the Next.js SERVER reaches the API
NEXT_PUBLIC_TULA_PUBLISHABLE_KEY=tula_pk_live_…
TULA_ENVIRONMENT_ID=<environment id>
# TULA_ISSUER=… only if the API's public URL differs from TULA_API_URL
# TULA_APP_URL=https://app.example.com recommended in production: the app's public origin
# (required behind a proxy that rewrites Host)
# TULA_TRUSTED_PROXY_HOPS=1 proxies in front of this server that append to X-Forwarded-For
# (default 0: none is trusted; see "What you must configure")
# TULA_SECRET_KEY=… only for `stateful` session profiles; never NEXT_PUBLIC_
# With it, TULA_API_URL must be https (or localhost); on a private network
# you trust, TULA_ALLOW_INSECURE_HTTP=true allows plain http1. The route handler. The browser talks to this route, never to the API's host.
// app/api/tula/[...tula]/route.ts
import { createTulaHandlers } from '@tula-tech/nextjs/handlers'
export const { GET, POST, PUT, PATCH, DELETE } = createTulaHandlers()2. The request interceptor. proxy.ts in Next.js 16; in Next.js 15 name the file
middleware.ts and the export middleware.
// proxy.ts
import { tulaMiddleware } from '@tula-tech/nextjs/middleware'
export const proxy = tulaMiddleware({ publicRoutes: ['/', '/pricing'] })
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }3. The provider, with what the server knows, so the first paint is right.
// app/layout.tsx
import { TulaProvider } from '@tula-tech/nextjs'
import { auth } from '@tula-tech/nextjs/server'
import '@tula-tech/react/styles.css'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const { sessionId } = await auth()
return (
<html lang='en'>
<body>
<TulaProvider
publishableKey={process.env.NEXT_PUBLIC_TULA_PUBLISHABLE_KEY ?? ''}
initialState={sessionId ? { sessionId } : null}
signInUrl='/sign-in'
afterSignOutUrl='/'
>
{children}
</TulaProvider>
</body>
</html>
)
}4. Pages. Components come from @tula-tech/nextjs (they are Client Components); the server
helpers from @tula-tech/nextjs/server.
// app/sign-in/page.tsx
import { SignIn } from '@tula-tech/nextjs'
import { safeRedirectPath } from '@tula-tech/nextjs/server'
export default async function Page(props: { searchParams: Promise<{ redirect_url?: string }> }) {
const { redirect_url } = await props.searchParams
return <SignIn afterSignInUrl={safeRedirectPath(redirect_url, '/dashboard')} />
}// app/dashboard/page.tsx
import { auth, currentUser } from '@tula-tech/nextjs/server'
export default async function Dashboard() {
const { userId } = await auth()
const user = await currentUser()
return <p>{user?.email} ({userId})</p>
}Organizations and has()
Where the environment has organizations (ADR 0065), the access token of a session with an active organization carries the organization, the user's role in it and the role's permissions. On the server:
const { orgId, orgRole, orgPermissions, has } = await auth()
if (!has({ permission: 'org:billing:read' })) {
redirect('/no-access')
}
has({ role: 'admin' }) // a role instead; one check at a timehas() is an exact match on the keys (no wildcard) and false when no organization is
active, for a signed-out visitor and for a check that names neither or both. The three claims
are used only as a set that passed the contract's checks (the grammar of the keys, the bounds):
a malformed set means no organization, never a partial one. This holds for a hybrid
session's token, a stateful session's answer and the header the middleware seals.
Protect a group of routes in the middleware, before anything renders:
// proxy.ts
export const proxy = tulaMiddleware({
publicRoutes: ['/', '/pricing'],
access: [
{ routes: '/billing(.*)', permission: 'org:billing:read' },
{ routes: '/admin(.*)', role: 'admin' },
],
forbiddenUrl: '/no-access', // pages go here; API routes and writes get a 403 JSON error
})A visitor with no session is sent to sign in; one who is signed in and fails the rule is
refused. A rule with a wildcard, a key that is not in the grammar, or both or neither of
permission and role throws when the middleware is created. Check again in the page or route
that serves the data: a matcher is a convenience, not the only guard.
In the browser, useOrganization() and useOrganizationList() (re-exported from
@tula-tech/react) read and switch the active organization; a switch refreshes the router so
the server components render with the new token.
A large role makes the access token outgrow one cookie. The largest role and custom claims
the API accepts give an access token of about 5.3 kB, more than the roughly 4 kB a browser keeps
in one cookie. The SDK stores the token in up to two cookies, tula_at and tula_at_1, and
joins them before it verifies the whole as one token. Nothing for you to do; do not read or
write these cookies yourself. A token too large even for two is not stored, one warning says so,
and the session continues on its refresh cookie.
What you must configure on the API
Allowed origins. Add the app's origin to the environment's
urls.allowedOrigins. The handler forwards the browser'sOriginunchanged, so the API's own origin rules decide.The visitor's address: two settings, both needed. The API limits sign-in attempts per IP address, and behind the handler it sees the Next.js server's address unless it is told the visitor's.
- On the Next.js server, say how the address is known. By default no forwarding header is
believed and no address is sent: anyone who can reach the server directly can write an
X-Forwarded-Forof their own. SetTULA_TRUSTED_PROXY_HOPS(or thetrustedProxyHopsoption) to the number of proxies in front of the Next.js server that append toX-Forwarded-For:1behind one load balancer or one platform router,2behind a CDN and a load balancer. The address is then the entry that many places from the right; what is left of it came with the request and is ignored. On a platform that states the address in its own header, passclientIp: (request) => …instead. Too high a number lets a visitor choose their address, and per-IP limits, lockout and the audit log's addresses then mean nothing; when unsure, count low. - On the API, set
TRUST_PROXY=true, and have the Next.js server reach it directly.
With either one missing every visitor shares the Next.js server's address, and therefore one per-IP rate limit: a few failed sign-ins by one person can lock everyone out. That is safe but coarse, and in production the server says so once in its log.
- On the Next.js server, say how the address is known. By default no forwarding header is
believed and no address is sent: anyone who can reach the server directly can write an
Callback pages, if you use emailed links or OAuth. Give the provider
emailLinkUrlandoauthCallbackUrl(paths of your app), render<EmailLinkCallback />and<OAuthCallback />on those pages, leave both routes public in the interceptor (a visitor arrives there signed out), and list their full URLs in the environment'surls.allowedRedirectUrls. Both finish through the handler; an OAuth provider returns the visitor to the API's host first, so the provider's redirect URI stays the API's.
How it behaves
- Cookies (
tula_rt,tula_at,tula_session;__Host--prefixed over https) areHttpOnly,SameSite=Lax, host-only. No script can read a token and nothing is in storage. - Access tokens are verified offline against the environment's JWKS: no call per request.
A session revoked on the API keeps working here until its token expires (60 seconds by
default), then is signed out at the refresh.
currentUser()asks the API and notices at once. - Clocks must agree. Expiry is judged by the Next.js server's clock against the API's
exp(five seconds of tolerance): keep both on NTP. - One session per browser. A sign-in replaces whatever the browser held: when the API
issues a
statefulsession's cookie the handler removes the token cookies, and the other way round, so the server never answers for an earlier user. - A refresh the API refuses for the session (
session.*,auth.user_banned) clears the cookies. A refusal of the request itself (the app's origin is not among the allowed origins, the publishable key is wrong) does not: the request is treated as signed out, the cookies stay, and the server logs once what is likely misconfigured. Warnings go toconsole.warn, or toonWarningwhen you pass one; they never contain a token, a key or a cookie. - Sizes. The handler passes on request bodies up to 1 MiB (
413beyond, also for a streamed body) and reads JSON answers up to 1 MiB (502beyond). - Refresh happens in the interceptor when the token is missing or about to expire. Requests
racing with one refresh token are covered by the API's reuse grace window (10 seconds by
default); do not use a profile with
refresh.reuseGracePeriod: nullbehind it. A refresh waits at most 8 seconds whatevertimeoutSecondssays, and one that got no answer at all is sent once more at once: both stay inside that window. The single flight is per process: with several Next.js instances, two of them can present the same refresh token, and only the API's reuse grace window keeps the second from being treated as reuse. Flights are keyed by API URL, environment and token, and the one that starts a flight sends its own client address andOriginfor everyone who joins it, so the API's per-IP rate-limit counts that first caller. - Over plain http, a sibling origin can plant cookies. Without
httpsthe cookie names have no__Host-prefix, so a page on another port or subdomain of the same host can writetula_atortula_at_1with garbage. That can force a refresh or a sign-out (a denial of service); it cannot forge a session, because every token is verified. Use https in production. x-tula-authis stripped only where the interceptor runs. The header is sealed with an HMAC bound to the session cookie, but a route outside the interceptor'smatcherreceives whatever the browser sent. Such a route must not readx-tula-authas input: callauth().- Set
TULA_APP_URLto the app's public origin. It decides whether the__Host-cookie names are used and whichOriginthe handler accepts, the same way in the interceptor, the handler andauth(). Set the variable, not only theappUrloption of the interceptor and the handler:auth()andcurrentUser()read the environment. Without it the app's origin is worked out from each request, and behind a proxy that ends TLS that needs the proxy to sendX-Forwarded-Proto: https: Next.js fills the header in withhttpwhen the proxy sent none. In that case (no app URL, no forwardedhttps) the handler refuses every write withrequest.origin_not_allowed, so nobody can sign in, and the server logs once why. Cookie names alone have one more rule, the same in all three places: a request that carries one of this package's__Host-cookies is read under the__Host-names even when the forwarded scheme sayshttp, because a browser stores and sends such a cookie over https only. It keepsauth()and the interceptor in agreement; it does not replace the setting. Where that rule chose the names, an answer that sets or clears a cookie also expires the plain-named ones, so a sign-in made over http on the same host (localhost, another port) does not come back after a sign-out. - A failed sign-out is not a sign-out. The handler clears the cookies only when the API
answered the sign-out;
<UserButton>and<UserProfile>then stay on the page and say the session may still be active, with "Try again". statefulsession profiles have no token. WithTULA_SECRET_KEYthe server asks the API on every matched request (one network call each); without it the server treats such a session as signed out.redirect_urlis written by the interceptor as a path and must be read throughsafeRedirectPath, which refuses anything that leaves the origin, including a path that only becomes a host once it is normalised (/.//host).auth()in the root layout makes every route dynamic. For static pages leaveinitialStateout; the state is thenloadinguntil the browser has asked.
