@jezzwtf/auth-client (0.3.0)
Installation
@jezzwtf:registry=https://git.jezz.wtf/api/packages/JezzWTF/npm/npm install @jezzwtf/auth-client@0.3.0"@jezzwtf/auth-client": "0.3.0"About this package
@jezzwtf/auth-client
The client half of the JezzWTF unified account: the PKCE redirect handoff, session cookies, rotation, and the three auth routes — as one package instead of a file set copied into each app.
api.jezz.wtf is the identity authority. An app here holds no credentials: it
sends the user to the account, exchanges a one-time code for that account's own
session tokens, and keeps them in HTTP-only cookies on its own origin.
Why it exists
pastes and hanzi-path each carried the same six files. Two consumers in,
they had already drifted: lib/account.ts differed by 331 diff lines, and
hanzi-path had lost parseCallbackUrl, getAccount's error handling, and the
bearer-token path entirely. This package is those files, reconciled — taking the
better version wherever the two disagreed.
Install
Published to the Gitea npm registry on git.jezz.wtf:
{
"dependencies": {
"@jezzwtf/auth-client": "^0.3.0"
}
}
Every machine that runs pnpm install — your workstation and the VM — needs two
lines in its user ~/.npmrc, never a committed one, so the token stays out
of every repo:
@jezzwtf:registry=https://git.jezz.wtf/api/packages/JezzWTF/npm/
//git.jezz.wtf/api/packages/JezzWTF/npm/:_authToken=<token>
The second line is the credential; npm auth keys are the registry URL with the
scheme stripped, which is why it begins //. The trailing slash must match on
both lines — drop it from one and npm sends no credential and returns a 401
that reads exactly like a bad token.
The token is a Gitea PAT. Publishing needs write:package; the VM only needs
read:package, so give it its own. Nothing is needed in CI — no consuming app
runs pnpm install on a GitHub runner; they all deploy over SSH and install on
the VM.
Installing from a git tag no longer works
Up to and including v0.2.0 this was consumed as
git+ssh://git@git.jezz.wtf/JezzWTF/auth-client.git#v0.2.0, with dist/
committed so that no build had to run on install. From 0.2.1 the build is
packed at publish time and dist/ is out of git, so a git-tag install would
fetch source with nothing to compile it.
That tradeoff is deliberate. The committed build could go stale silently — a
release that forgot pnpm build shipped source changes consumers never saw, and
nothing failed. prepack makes that impossible.
v0.2.0's tag still has its dist/ and remains installable, which is the
escape hatch if the registry is ever unreachable.
Entry points
Four, and the first three splits are load-bearing:
| Import | Contains | Safe in middleware |
|---|---|---|
@jezzwtf/auth-client |
parseCallbackUrl, originOfCallback, safeReturnTo, fetchAccountProfile, createPkce, createSessionRotator, types |
yes — imports nothing |
@jezzwtf/auth-client/server |
createAccountClient, createAuthRoutes |
no — next/headers, react |
@jezzwtf/auth-client/middleware |
createSessionMiddleware |
yes — next/server only |
@jezzwtf/auth-client/mock |
createMockAccountServer |
n/a — dev/CI only |
createSessionMiddleware is deliberately not re-exported from /server. A
barrel offering both would pull next/headers and the app's Prisma client into
the edge runtime the moment middleware imported the factory.
Usage
1. The client
// lib/account.ts
import { createAccountClient } from "@jezzwtf/auth-client/server";
import type { AccountProfile } from "@jezzwtf/auth-client";
import { db } from "./db";
export interface AccountUser {
id: string;
email: string;
name: string | null;
}
export const account = createAccountClient<AccountUser>({
appId: "yourapp",
syncProfile: async (profile: AccountProfile) => {
// Upsert rather than create. Two server components on one page each resolve
// the session, so on a first sign-in both can find no row and both try to
// write it — one then fails the primary key and takes down the render.
return db.user.upsert({
where: { id: profile.id },
update: { email: profile.email, ...(profile.displayName ? { name: profile.displayName } : {}) },
create: { id: profile.id, email: profile.email, name: profile.displayName },
});
},
});
export const getAccount = account.getAccount;
syncProfile is the one part that cannot be shared, and the package does not try
to. Every app's local row means something different: pastes preserves a role and
repairs ownership of rows created before the account existed; HanziPath assigns a
starting course and a time zone. The client resolves who the visitor is; what
that implies locally stays with the app.
Defaults derived from appId — override any of them if an app already uses
different names:
- cookies
yourapp_access,yourapp_refresh,yourapp_pkce YOURAPP_AUTH_CALLBACK_URL, plusJWTF_API_BASEand optionalJWTF_ACCOUNT_BASE
Every environment read happens on call, never at module scope. Reading at module
scope evaluates during next build, where runtime variables are absent, turning
a missing value into a failed build rather than a failed request.
2. The routes
// lib/auth-routes.ts
import { createAuthRoutes } from "@jezzwtf/auth-client/server";
import { account } from "./account";
export const authRoutes = createAuthRoutes(account);
// app/api/auth/start/route.ts
export { start as GET } from "@/lib/auth-routes"; // or: export const GET = authRoutes.start
callback is GET; signout is POST only — a GET sign-out can be triggered
by any image tag or prefetch, which turns "log the user out" into something a
third-party page can do.
3. Carrying something across the redirect
Pass a type parameter and an extras reader. It runs on the start request, where
a browser is still present to have supplied the value; on the way back there is
nobody to ask.
export const account = createAccountClient<AccountUser, string | null>({
appId: "hanzipath",
syncProfile: async (profile, timeZone) => { /* timeZone is set only on sign-in */ },
});
export const authRoutes = createAuthRoutes(account, {
extras: (request) => {
const zone = request.nextUrl.searchParams.get("tz");
return zone && isValidTimeZone(zone) ? zone : null; // untrusted input
},
});
extra reaches syncProfile on the request that completes sign-in and is null
on every request after it.
4. Middleware
// middleware.ts
import { createSessionMiddleware } from "@jezzwtf/auth-client/middleware";
export const middleware = createSessionMiddleware({
appId: "yourapp",
protectedPaths: ["/dashboard", "/admin"],
// Answered with a 401 instead of a redirect. A fetch from a client component
// parses the body as JSON, so a 307 to a sign-in page arrives at the call
// site as an HTML parse error — considerably worse to debug than a status.
protectedApiPaths: ["/api/session", "/api/user"],
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
Next 16 renames middleware.ts to proxy.ts (exporting proxy rather than
middleware). The factory does not care which — mount its return value from
whichever file the app uses. Note that a matcher excluding api cannot serve
protectedApiPaths.
The mock account server
For development, CI, and coding agents that cannot reach api.jezz.wtf. Any app
can use it — nothing in it is app-specific:
// package.json
"scripts": { "mock:auth": "jwtf-mock-auth" }
MOCK_USER_EMAIL=me@jezz.wtf pnpm mock:auth # then JWTF_API_BASE=http://localhost:4000
MOCK_AUTH_PORT, MOCK_USER_ID, MOCK_USER_EMAIL, MOCK_USER_NAME,
MOCK_REDIRECT_URIS (comma-separated allowlist, unset means allow any) and
MOCK_VERIFY_PKCE=0 configure it. Import createMockAccountServer from
@jezzwtf/auth-client/mock to drive it from a test.
It verifies PKCE, and codes are single use. That is the one bug a mock is best placed to catch, and a server that accepted any verifier would hide it.
It is not an authorisation server. No registered apps, grants, tiers, or
MFA — so it cannot catch an unregistered app, an entitlement failure, a redirect
URI missing from AUTH_REDIRECT_URIS_* (unless you configure the allowlist), or
a second factor that should have been demanded. A flow that works here is not
yet a flow that works in production.
jwtf — signing CLIs and agents in
Scripts and coding agents have no browser session of their own. jwtf signs
them in with a device code (RFC 8628) and hands them fresh access tokens:
jwtf login --app cal --env local --name "claude-code on desktop"
curl -H "Authorization: Bearer $(jwtf token --app cal --env local)" http://localhost:3010/api/v1/me
jwtf whoami --app cal --env local
jwtf logout --app cal --env local
jwtf list
login prints a code and opens the account's /device page, where a signed-in
person names the login, picks scopes (never more than requested with --scope)
and approves. The result is an ordinary account session with platform agent,
revocable under Active sessions in the portal.
--env picks the account API: local (http://localhost:3003), dev
(https://api-dev.jezz.wtf) or prod (https://api.jezz.wtf, the default).
--api or JWTF_API_BASE overrides it; JWTF_ENV and JWTF_APP set defaults.
Progress goes to stderr, so $(jwtf token …) captures only the token.
Credentials live in %APPDATA%\jezzwtf\credentials.json on Windows and
~/.config/jezzwtf/credentials.json elsewhere (JWTF_CONFIG_DIR overrides),
one entry per environment and app, written with 0600 permissions.
Refreshes are serialised. Refresh tokens rotate and are single use, and the
account revokes the whole session when one is presented twice. Two agents
refreshing the same login at once would do exactly that, so jwtf token refreshes
under a lock file and re-reads the store inside it; everyone else gets the token
the first refresh produced.
Two things worth understanding before changing any of this
Validity is resolved by asking the API, not by verifying the token locally. Local verification would mean each app holding the account's signing secret, which would let a compromise of any one of them mint tokens for every other — the audience separation would be decorative. The cost is one request per signed-in page load. The benefit is that revoking a session in the portal takes effect immediately rather than whenever a token happens to expire.
Rotation belongs in middleware and nowhere else. /auth/refresh revokes the
token it replaces. A server component that refreshed could not persist the
replacement — Next forbids writing cookies during render — so it would destroy
the session it was trying to preserve. getAccount therefore only ever reads.
Migrating an app that already has the copied files
- Add the dependency and delete
lib/account.ts,lib/account-profile.ts,lib/session-rotation.ts,lib/callback-url.ts,lib/app-origin.ts,lib/safe-return-to.ts, and the threeapp/api/auth/*/route.tsbodies. - Move the app's
syncProfileintocreateAccountClient. It is the only part worth reading closely — everything else was identical. - Cookie names are unchanged if
appIdmatches the old prefix, so existing sessions survive the deploy. - One caveat: the PKCE stash cookie changed shape — app-specific fields now
live under
extrarather than at the top level. A sign-in already in flight across the deploy lands on?authError=expiredand has to be retried. The cookie lives 600 seconds, so the window is small, but it is not zero.
Development
pnpm install
pnpm test # node:test via tsx
pnpm typecheck
pnpm build
Releasing
pnpm typecheck && pnpm test
pnpm publish --access restricted # prepack builds; publishConfig picks the registry
git tag -a vX.Y.Z -m "..." && git push origin main vX.Y.Z
prepack rebuilds dist/ as part of packing, so a published version can never
carry a stale build — the failure mode that committed dist/ had.
Gitea versions are immutable: a published version cannot be overwritten, only deleted. Bump rather than republish.
The tag is now a marker for humans, not the distribution mechanism. Consumers
resolve from the registry, so a version is live the moment it publishes, and
^0.2.1 picks up patches without touching any app's package.json.
Dependencies
Development Dependencies
| ID | Version |
|---|---|
| @types/node | ^22.10.2 |
| @types/react | ^19.2.18 |
| next | 16.1.6 |
| react | 19.2.3 |
| tsx | ^4.19.2 |
| typescript | ^5.9.3 |
Peer Dependencies
| ID | Version |
|---|---|
| next | >=15 |
| react | >=18 |