JezzWTF

@jezzwtf/auth-client (0.5.0)

Published 2026-09-26 21:52:28 +00:00 by LyAhn

Installation

@jezzwtf:registry=https://git.jezz.wtf/api/packages/JezzWTF/npm/
npm install @jezzwtf/auth-client@0.5.0
"@jezzwtf/auth-client": "0.5.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.5.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;

Routes that authorize operations can resolve the same local user together with the account authority's verified session metadata. For example, Pastes can keep read and write separate without decoding a JWT or holding the signing secret:

const context = await account.accountContextFromRequest(request);
if (!context) return Response.json({ error: "Unauthorized" }, { status: 401 });

if (!context.session.scopes.includes("pastes:content:write")) {
  return Response.json({ error: "Insufficient scope" }, { status: 403 });
}

const ownerId = context.user.id;

resolveAccountContext(accessToken) provides the same result for a known token, and getAccountContext() reads it from the app's session cookie. accountFromRequest, resolveAccount, and getAccount remain identity-only and source-compatible.

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). It can also make authenticated requests without ever placing the bearer token in shell source or command output:

jwtf login --app cal --env local --name "claude-code on desktop"
jwtf request --app cal --env local --base http://localhost:3010 GET /api/v1/me
jwtf whoami --app cal --env local
jwtf scopes --app cal --env local
jwtf logout --app cal --env local
jwtf list

More than one agent can share a machine, each with its own named login for the same app and environment — login upserts by --name, so re-running it with the same name refreshes that agent's login rather than creating another. token, whoami, request, scopes, and logout all pick the one stored login automatically; once a second is stored for the same app and environment, they require --login <name> (or JWTF_LOGIN) to say which one, rather than guessing:

jwtf login --app cal --env prod --name "Claude Code on this machine"
jwtf login --app cal --env prod --name "Codex on this machine"
jwtf request --app cal --env prod --base https://cal.jezz.wtf \
  --login "Codex on this machine" GET /api/v1/me
jwtf list

jwtf request refreshes the explicitly selected app/environment login, adds the Authorization header internally, does not follow redirects, and accepts only an origin-relative path so the credential cannot be redirected or encoded into another origin:

jwtf request --app cal --env prod --base https://cal.jezz.wtf GET /api/v1/summary
jwtf request --app pastes --env prod --base https://p.jezz.wtf GET /api/v1/pastes/mine
jwtf request --app cal --env prod --base https://cal.jezz.wtf \
  --json @note.json POST /api/v1/sessions/SESSION_ID/log

JSON may be supplied inline, from @file, or from stdin with --json -. POST requests receive a generated Idempotency-Key; pass --idempotency-key <key> to preserve a key across retries. The response body is written to stdout without JSON-only parsing, while HTTP <status> and the idempotency key go to stderr. HTTP redirects, client errors, and server errors exit with codes 3, 4, and 5 respectively. If an upstream ever reflects a stored access or refresh token, the CLI replaces it with [REDACTED].

jwtf scopes reads the account API's live app registration before login and reports requested, available, unavailable, and the selected login's granted scopes. An unavailable requested scope exits with code 4, making a registration gap visible before a device code is created:

jwtf scopes --app cal --env prod \
  --scope cal:sessions:read,cal:issues:write

The same command extends a stored login when --scope asks for access it does not already hold. The approval page names that existing login and shows only the delta; approval keeps its session id and Active Sessions history:

jwtf login --app pastes --env prod --scope pastes:content:read
jwtf login --app pastes --env prod --scope pastes:content:read,pastes:content:write

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. jwtf token remains available for legacy integrations, but jwtf request is the safer default. jwtf --version prints the installed package version, and every command has focused examples under jwtf <command> --help.

Credentials live in %APPDATA%\jezzwtf\credentials.json on Windows and ~/.config/jezzwtf/credentials.json elsewhere (JWTF_CONFIG_DIR overrides), one or more named entries per environment and app — one per agent sharing the machine — written with 0600 permissions. An older single-login store reads and upgrades in place the first time something writes to it.

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-26 21:52:28 +00:00
5
UNLICENSED
latest
32 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