Skip to content

Repository files navigation

@buddy-works/identity

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 in

Perfect 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.

Installation

npm install @buddy-works/identity

Requires Node.js 18+ (built-in fetch). Works in every modern browser.

How it works

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.

Usage

Client-side (browser)

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}`;
}

Server-side (Express / Connect / plain Node)

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);
});

Next.js / Remix / anything with Fetch API requests

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());

Explicit options

When no request object is available, pass the pieces yourself:

const user = await getCurrentUser({
  baseUrl: "https://myapp.buddytunnels.site",
  cookie: rawCookieHeader,
});

Local development (no tunnel)

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.

Examples

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 in next.config.mjs.
pnpm install
pnpm example          # Express example on http://localhost:3000
pnpm example:next     # Next.js example on http://localhost:3000

Both understand MOCK=logged-out (simulate a logged-out user) and MOCK=0 (disable the mock — use when running behind a real Buddy tunnel).

API

getCurrentUser(input?)

Returns Promise<BuddyUser | null>.

  • input — optional. One of:
    • (nothing) — browser only; requests /.buddy/auth/me on the current origin.
    • Node IncomingMessage / Express request — cookie and host are read from it.
    • Fetch API Request or Headers — same, for edge/serverless frameworks.
    • { baseUrl?, cookie?, fetch? } — explicit options.
  • Returns null when 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).

buddyIdentity(options?)

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;
    }
  }
}

@buddy-works/identity/dev

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 — the owner / admin / member presets.
  • resolveMockUser(input) — resolves any mock input to a full BuddyUser | null.

Types

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.

Client-side vs server-side

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), check owner/admin on 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.

Development

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 library

CI lives in .buddy/ as Buddy pipelines, one per file:

  • tests.yml — build + typecheck on every push.
  • publish-npm.yml — manual pnpm publish from main; requires an encrypted NPM_TOKEN variable on the pipeline.

License

MIT © Buddy

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages