@jfinvesting/auth-client
v0.1.14
Published
TypeScript OAuth2 client SDK for the jfinvesting auth-core service.
Maintainers
Readme
@jfinvesting/auth-client — TypeScript OAuth2 client for auth-core
A typed OAuth2 + OIDC client SDK for the auth-core
service. Provides PKCE flow helpers, JWT verification (RS256 via JWKS), and
ready-made adapters for Next.js (App Router), Express, and Fastify. Mirrors
the Go client at github.com/jfinvesting/auth-client-go
in error codes and the verified User shape.
- Pure Web Crypto (works in Node 20+, Edge runtime, Cloudflare Workers).
- One stable error type (
AuthError) with stablecodes. - Cookie-based session for Next.js, Bearer-token middleware for Node servers.
Install
npm i @jfinvesting/auth-client josejose is a runtime dependency. Framework adapters declare optional peer deps
on next, react, express, fastify — only install what you use.
Quick start (Next.js App Router)
Four files. Copy-paste, set your env, done.
1. Mount the auth endpoints — app/auth/[...jfinvesting]/route.ts:
export { GET, POST } from "@jfinvesting/auth-client/next/route";This exposes /auth/login, /auth/callback, /auth/logout, /auth/refresh,
/auth/me.
2. Gate non-public routes — middleware.ts:
import { authMiddleware } from "@jfinvesting/auth-client/next/middleware";
export default authMiddleware({ publicRoutes: ["/", /^\/auth\/.*/] });
export const config = { matcher: ["/((?!_next|favicon).*)"] };3. Read the user from a server component — app/page.tsx:
import { getServerUser } from "@jfinvesting/auth-client/next/server";
export default async function Home() {
const user = await getServerUser();
return <main>{user ? `Cześć, ${user.name}` : <a href="/auth/login">Zaloguj</a>}</main>;
}4. .env.local:
AUTH_ISSUER=https://auth.jfinvesting.pl
AUTH_CLIENT_ID=cli_...
AUTH_CLIENT_SECRET=...
AUTH_REDIRECT_URI=http://localhost:3000/auth/callbackSee examples/next-app-router/ for a runnable
project.
Framework support
Next.js App Router — full support
Route mount + middleware + RSC helpers + client hook. The cookie session is managed for you, including PKCE state and refresh-token rotation.
// app/protected/page.tsx
import { requireUser } from "@jfinvesting/auth-client/next/server";
export default async function Protected() {
const user = await requireUser();
return <main>Hello {user.name}</main>;
}Client subtree:
"use client";
import { AuthProvider, useAuth } from "@jfinvesting/auth-client/next/client";Next.js Pages Router — manual integration
The mounted route handlers use the App Router NextRequest/NextResponse
APIs; they do not drop into pages/api/auth/*.ts. Hand-roll the
endpoints using the core helpers (authorizeUrl, exchangeCode,
verifyAccessToken, generateVerifier). The shape mirrors
examples/express/ — read it as a structural reference
and translate to your pages/api/... files.
Express — Bearer middleware
import express from "express";
import { verifyBearer, requireRoles } from "@jfinvesting/auth-client/node/express";
const app = express();
app.get("/api/me", verifyBearer(), (req, res) => res.json({ user: req.user }));
app.get("/api/admin", verifyBearer(), requireRoles("admin"), (req, res) => res.json({ ok: true }));verifyBearer reads Authorization: Bearer <jwt>. See
examples/express/ for the full PKCE login flow.
Fastify — plugin
import Fastify from "fastify";
import { authPlugin } from "@jfinvesting/auth-client/node/fastify";
const app = Fastify();
await app.register(authPlugin, { protectAll: false });
app.get("/api/me", { preHandler: app.verifyAuth }, async (req) => ({ user: req.user }));Pass { protectAll: true } to gate every request via an onRequest hook.
Configuration
Configuration comes from environment variables, parsed by readConfig().
| Variable | Required | Description |
| --- | --- | --- |
| AUTH_ISSUER | yes | URL of auth-core (e.g. https://auth.jfinvesting.pl). Trailing slashes are stripped. |
| AUTH_CLIENT_ID | yes | OAuth client ID registered in auth-core. |
| AUTH_CLIENT_SECRET | conditional | Required for confidential clients; omit for public PKCE-only clients. |
| AUTH_REDIRECT_URI | yes | Callback URL — must exactly match a redirect_uri registered in auth-core. |
| AUTH_COOKIE_DOMAIN | no | Optional cookie domain (e.g. .example.com) for cross-subdomain SSO. |
You can also build an AuthConfig programmatically and pass it to adapters
(verifyBearer(cfg), app.register(authPlugin, { cfg })).
API reference
@jfinvesting/auth-client (root)
readConfig(env?: NodeJS.ProcessEnv): AuthConfig
Reads AUTH_* vars. Throws AuthError("auth/invalid-token") listing the
missing variables.
generateVerifier(): string
Returns a fresh PKCE code_verifier (43-char base64url, RFC 7636).
challenge(verifier: string): Promise<string>
Computes the S256 PKCE code_challenge.
authorizeUrl(cfg, { state, verifier, scope? }): Promise<string>
Builds the GET /oauth/authorize URL. scope defaults to
"openid email profile".
exchangeCode(cfg, { code, verifier }): Promise<TokenResponse>
Exchanges an authorization code for tokens.
- Throws
AuthError("auth/network")on transport failure. - Throws
AuthError("auth/invalid-token")on a non-2xx token response.
refresh(cfg, refreshToken): Promise<TokenResponse>
Exchanges a refresh token for a new triple. Same throw contract as above. Refresh tokens rotate — the previous one is single-use.
verifyAccessToken(cfg, token): Promise<AuthUser>
Verifies signature (RS256), iss, aud, exp against the JWKS at
<issuer>/.well-known/jwks.json. Throws:
auth/missing-tokenif token is emptyauth/expired-tokenonexppastauth/unknown-issueronissmismatchauth/invalid-tokenon any other validation failure
getJwks(issuer): RemoteJWKSet
Memoized jose JWKS resolver. Same instance is reused per-issuer so jose
caches the keys.
AuthError, AuthErrorCode, AuthUser, AuthConfig, TokenResponse
See the Errors section and source for fields.
@jfinvesting/auth-client/next/route
GET(req: NextRequest) / POST(req: NextRequest)
App Router handlers. Mount under a catch-all segment, e.g.
app/auth/[...jfinvesting]/route.ts. Dispatches on the trailing path:
| Path | Method | Behavior |
| --- | --- | --- |
| /auth/login | GET | Sets PKCE cookie, redirects to authorize URL. Honors ?next=/some/path. |
| /auth/callback | GET | Validates state, exchanges code, sets access + refresh cookies, redirects to returnTo. |
| /auth/me | GET | Returns { user } from the access cookie or 401. |
| /auth/logout | GET, POST | Clears cookies, returns 204. |
| /auth/refresh | GET, POST | Rotates tokens via the refresh cookie. |
@jfinvesting/auth-client/next/middleware
authMiddleware(opts?: { publicRoutes?: (string | RegExp)[] })
Returns a Next middleware. Strings ending in / match as a prefix; RegExps
test against pathname. The mounted /auth/* endpoints are always public.
Behavior:
- No access cookie → 302 to
/auth/login?next=<original> - Invalid/expired cookie → 302 to
/auth/refresh?next=<original> - Valid cookie →
NextResponse.next()
@jfinvesting/auth-client/next/server
getServerUser(): Promise<AuthUser | null>
Reads cookies via next/headers, verifies, returns the user or null.
requireUser(): Promise<AuthUser>
Like getServerUser but redirect("/auth/login") if missing/invalid.
@jfinvesting/auth-client/next/client (client component)
<AuthProvider initialUser?={AuthUser | null}>
Wraps a client subtree, fetches /auth/me on mount.
useAuth(): { user, isLoading, logout }
Must be used under <AuthProvider>. logout() POSTs /auth/logout then
navigates to /.
@jfinvesting/auth-client/node/express
verifyBearer(cfg?: AuthConfig): RequestHandler
Verifies Authorization: Bearer <jwt> and sets req.user. On failure
responds 401 with { error: AuthErrorCode, message }.
requireRoles(...roles: string[]): RequestHandler
Run after verifyBearer. 403 with auth/insufficient-role if the user
lacks any of the listed roles. With no roles, just asserts authentication.
@jfinvesting/auth-client/node/fastify
authPlugin (default + named export)
Fastify plugin (wrapped with fastify-plugin).
await app.register(authPlugin, { cfg?, protectAll? });- Decorates
request.user(typedAuthUser | undefined). - Decorates
fastify.verifyAuth(apreHandler-shaped function) for per-route attachment. - With
protectAll: true, also installs anonRequesthook that 401s every unauthenticated request.
Errors
All operations throw a single typed AuthError with a stable code. Switch
on code, not on message.
| Code | When |
| --- | --- |
| auth/missing-token | No bearer header / empty access cookie / empty token passed to verifyAccessToken. |
| auth/invalid-token | JWT signature/claim check failed; token endpoint returned a non-2xx; required env vars missing. |
| auth/expired-token | JWT exp is in the past. |
| auth/insufficient-role | requireRoles(...) did not find a matching role on the user. |
| auth/unknown-issuer | JWT iss does not match cfg.issuer. |
| auth/network | Token endpoint unreachable (DNS, TCP, TLS, etc.). |
These codes are kept in lockstep with the Go client
(github.com/jfinvesting/auth-client-go) and the auth-core service spec.
Troubleshooting
JWKS fetch fails (network/firewall, wrong issuer). The verifier loads
keys from <AUTH_ISSUER>/.well-known/jwks.json. If you see auth/invalid-token
with a cause mentioning fetch, confirm the issuer URL is reachable from
the runtime (Edge functions in particular cannot reach private networks),
and that there is no trailing slash mismatch.
Refresh tokens are one-shot. Every successful refresh() call rotates
the refresh token — do not reuse the previous one. The Next.js
/auth/refresh endpoint already handles rotation; if you call refresh()
yourself, persist the new refresh_token from the response.
Clock skew. jose allows ~30s default tolerance. If you observe
sporadic auth/expired-token on freshly issued tokens, fix the clock on
either the auth-core host or the consumer host (NTP) rather than widening
the tolerance.
Edge runtime / secure cookie flag. The cookie helpers set secure
based on process.env.NODE_ENV === "production". In dev over plain HTTP
the cookie is non-secure (correct). In production, ensure NODE_ENV is
actually production so the browser keeps the cookie HTTPS-only.
Pages Router. Unsupported as drop-in. Use the core helpers
(authorizeUrl, exchangeCode, verifyAccessToken) inside your
pages/api/auth/*.ts files; the Express example shows the structure.
License
MIT — see LICENSE.
