Zero-config authentication for apps running behind Buddy tunnels.
When a tunnel has Buddy authentication enabled, Buddy handles login and access control and issues a session cookie. This library gives your application the identity of the current user in a single call — no logins, no OAuth, no sessions, no user database:
import { getCurrentUser } from "@buddy-works/identity";
const user = await getCurrentUser(req);
// => { name, email, owner, admin, avatar, sso_id } — or null when not logged inPerfect for internal tools, dashboards and AI-built apps running in Buddy sandboxes: access is managed centrally in the tunnel settings, and your code just receives trusted user information.
npm install @buddy-works/identityRequires Node.js 18+ (built-in fetch). Works in every modern browser.
Buddy tunnels expose a user-info endpoint on your app's domain:
GET /.buddy/auth/me
The tunnel intercepts this path before the request reaches your app, verifies the
Buddy session (a signed JWT stored in an HttpOnly cookie) and responds with:
{
"id": 42,
"name": "John Doe",
"email": "john@example.com",
"owner": false,
"admin": true,
"avatar": "https://…",
"sso_id": "john.doe@idp.example.com",
"groups": [
{ "id": 1, "name": "everyone" },
{ "id": 2, "name": "finance" }
]
}This library is a thin, typed client for that endpoint. All security logic — JWT signature verification, JWKS key management, claim validation — stays on Buddy's side, so your app never touches a token.
Call it with no arguments. The request goes to the current origin and the browser attaches the session cookie automatically:
import { getCurrentUser } from "@buddy-works/identity";
const user = await getCurrentUser();
if (user) {
document.querySelector("#hello").textContent = `Welcome ${user.name}`;
}Pass the incoming request so its cookie can be forwarded:
import express from "express";
import { getCurrentUser } from "@buddy-works/identity";
const app = express();
app.get("/api/whoami", async (req, res) => {
const user = await getCurrentUser(req);
if (!user) return res.status(401).json({ error: "Not logged in" });
res.json({ message: `Welcome ${user.name}` });
});Or use the middleware to get req.buddyUser everywhere:
import { buddyIdentity } from "@buddy-works/identity";
app.use(buddyIdentity());
// app.use(buddyIdentity({ required: true })); // reject anonymous requests with 401
app.get("/api/admin", (req, res) => {
if (!req.buddyUser?.admin) return res.status(403).json({ error: "Admins only" });
res.json({ secret: "…" });
});Groups managed centrally in Buddy make simple role systems free:
app.get("/api/reports", (req, res) => {
const inFinance = req.buddyUser?.groups.some((g) => g.name === "finance");
if (!inFinance) return res.status(403).json({ error: "Finance only" });
res.json(reports);
});getCurrentUser also accepts a Fetch API Request or a Headers instance:
// Next.js route handler
export async function GET(request: Request) {
const user = await getCurrentUser(request);
return Response.json({ user });
}
// Next.js server component
import { headers } from "next/headers";
const user = await getCurrentUser(await headers());When no request object is available, pass the pieces yourself:
const user = await getCurrentUser({
baseUrl: "https://myapp.buddytunnels.site",
cookie: rawCookieHeader,
});Locally there is no tunnel to answer /.buddy/auth/me. The /dev entry point
ships everything needed to develop against realistic user states anyway.
Every mocking API accepts the same input: a preset name ("owner",
"admin", "member"), a partial user (merged onto the admin preset), or
null for the logged-out state.
Server / dev-server — mount the fake endpoint as middleware. Anything that
talks to /.buddy/auth/me over HTTP (your browser code, getCurrentUser(req)
on the server) now works locally:
import { mockAuthEndpoint } from "@buddy-works/identity/dev";
if (process.env.NODE_ENV !== "production") {
app.use(mockAuthEndpoint(process.env.MOCK_USER ?? "admin"));
// mockAuthEndpoint("member") — preset
// mockAuthEndpoint({ name: "Jane", owner: true }) — custom user
// mockAuthEndpoint(null) — logged-out
}SPA / tests / no middleware — install an in-process mock; every
getCurrentUser() call in this runtime returns it without any HTTP request:
if (import.meta.env.DEV) {
const { mockUser } = await import("@buddy-works/identity/dev");
mockUser("member");
}clearMockUser() removes it again (handy in test teardown), and the raw presets
are exported as MOCK_USERS if you want to build your own states from them.
In Next.js, mount the endpoint with a dev-only rewrite instead of middleware —
see examples/nextjs. Behind a real tunnel the endpoint is
intercepted before requests reach your app, so a leftover mock endpoint is never
hit in production — but only enable mocks in development anyway.
Two runnable examples live in examples/:
examples/express— Express + a vanilla-JS page: client-side, server-side and admin-gating in one screen.examples/nextjs— Next.js App Router: Server Component (getCurrentUser(await headers())), Client Component, Route Handler and an admin-gated page. The local mock is wired up via a rewrite innext.config.mjs.
pnpm install
pnpm example # Express example on http://localhost:3000
pnpm example:next # Next.js example on http://localhost:3000Both understand MOCK=logged-out (simulate a logged-out user) and MOCK=0
(disable the mock — use when running behind a real Buddy tunnel).
Returns Promise<BuddyUser | null>.
input— optional. One of:- (nothing) — browser only; requests
/.buddy/auth/meon the current origin. - Node
IncomingMessage/ Express request — cookie and host are read from it. - Fetch API
RequestorHeaders— same, for edge/serverless frameworks. { baseUrl?, cookie?, fetch? }— explicit options.
- (nothing) — browser only; requests
- Returns
nullwhen the user is not logged in (the endpoint responds with a non-2xx status or a login redirect). - Throws when the endpoint cannot be reached at all (e.g. running locally without the dev mock).
Connect/Express-style middleware. Resolves the user once per request and sets
req.buddyUser: BuddyUser | null. With { required: true } it responds 401
to unauthenticated requests.
In TypeScript, teach Express about the new property once, e.g. in a .d.ts file:
import type { BuddyUser } from "@buddy-works/identity";
declare global {
namespace Express {
interface Request {
buddyUser?: BuddyUser | null;
}
}
}Development helpers — see Local development:
mockAuthEndpoint(user?)— middleware serving a fake/.buddy/auth/me.mockUser(user?)/clearMockUser()— in-process mock for SPAs and tests.MOCK_USERS— theowner/admin/memberpresets.resolveMockUser(input)— resolves any mock input to a fullBuddyUser | null.
interface BuddyUser {
id: number; // Buddy user id
name: string;
email: string;
owner: boolean; // workspace owner
admin: boolean; // workspace administrator
avatar: string | null;
sso_id: string | null; // SSO identifier of the user in this workspace
groups: BuddyGroup[]; // workspace groups the user belongs to
}
interface BuddyGroup {
id: number;
name: string;
}sso_id is the user's identifier in the workspace's SSO provider — it is a
string only when the workspace has SSO enabled and the user is bound to an
SSO identity; otherwise it is null.
groups is defensively normalized to [] (and sso_id to null) when
missing from the response, so you can always rely on both fields being present.
The package is isomorphic — one import works in both environments, because in both cases it is just an HTTP call to the same endpoint with the same cookie:
| Client-side | Server-side | |
|---|---|---|
| How the cookie travels | attached automatically by the browser (same-origin, HttpOnly) |
forwarded from the incoming request's Cookie header |
| Where the call goes | relative /.buddy/auth/me, intercepted by the tunnel |
app's public origin (derived from X-Forwarded-Host/Host), intercepted by the tunnel |
| Best for | showing who's logged in, personalizing UI | authorization decisions, SSR, API routes |
Things to keep in mind:
- Authorization must happen server-side. Client-side checks like
user.admin && renderAdminPanel()are UI conveniences — anyone can flip them in DevTools. The tunnel already guarantees that only allowed users reach the app at all; for anything finer-grained (admin-only endpoints, per-user data), checkowner/adminon the server. - Server-side calls need outbound network access to the app's own public
hostname, since the endpoint lives on the tunnel edge, not inside your app. This
costs one extra HTTP round-trip per call — cache the result per request (the
buddyIdentity()middleware does this for you). - Client-side calls must be same-origin. The session cookie belongs to the
tunnel domain and is
HttpOnly, so a different origin can neither read nor send it.
An alternative design would verify the JWT locally in the SDK (reading the cookie,
fetching Buddy's JWKS, checking signature and claims). It would save the extra HTTP
call on the server, but it drags token internals, key caching/rotation and claim
validation into every app — and it wouldn't work in the browser at all, since the
cookie is HttpOnly. Delegating verification to the tunnel keeps the SDK tiny and
lets the endpoint evolve (e.g. richer user data) without shipping new SDK versions.
This repo is a pnpm workspace (the library at the root, examples
in examples/*) with pinned dependencies:
pnpm install # install everything
pnpm build # build the library (tsup → ESM + CJS + d.ts)
pnpm typecheck # typecheck the libraryCI lives in .buddy/ as Buddy pipelines, one per file:
tests.yml— build + typecheck on every push.publish-npm.yml— manualpnpm publishfrommain; requires an encryptedNPM_TOKENvariable on the pipeline.
MIT © Buddy