JezzWTF

@jezzwtf/auth-client (0.3.1)

Published 2026-09-16 22:45:54 +00:00 by LyAhn

Installation

@jezzwtf:registry=https://git.jezz.wtf/api/packages/JezzWTF/npm/
npm install @jezzwtf/auth-client@0.3.1
"@jezzwtf/auth-client": "0.3.1"

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, plus JWTF_API_BASE and optional JWTF_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.

Opening an app on a phone

YOURAPP_AUTH_CALLBACK_URL must be https unless it is loopback, and a phone on the same Wi-Fi cannot reach loopback. Outside production the callback may therefore name a private network address over http — 10/8, 172.16/12 or 192.168/16, nothing wider:

JWTF_API_BASE="http://192.168.0.23:4000"                              # the mock, which binds 0.0.0.0
CAL_AUTH_CALLBACK_URL="http://192.168.0.23:3010/api/auth/callback"

NODE_ENV=production refuses it again, the same gate that decides whether the session cookie is Secure. Against the real account rather than the mock, the address also has to be in AUTH_REDIRECT_URIS_<APP> on the API.

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

  1. 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 three app/api/auth/*/route.ts bodies.
  2. Move the app's syncProfile into createAccountClient. It is the only part worth reading closely — everything else was identical.
  3. Cookie names are unchanged if appId matches the old prefix, so existing sessions survive the deploy.
  4. One caveat: the PKCE stash cookie changed shape — app-specific fields now live under extra rather than at the top level. A sign-in already in flight across the deploy lands on ?authError=expired and 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
Details
npm
2026-09-16 22:45:54 +00:00
10
UNLICENSED
37 KiB
Assets (1)
Versions (5) View all
0.5.0 2026-09-26
0.4.0 2026-09-20
0.3.1 2026-09-16
0.3.0 2026-09-14
0.2.1 2026-09-06