AuthBuild your own

Build your own adapter

The three built-in adapters (jwtAdapter, clerkAdapter, supabaseAdapter) cover the auth providers Junjo ships first-class wrappers for. Any other provider, including session-token stores you already maintain, can plug in by implementing the one-method AuthAdapter interface directly.

The contract, again

import type { AuthAdapter } from "@junjo.io/sdk";
 
export interface AuthAdapter {
  verifyToken(token: string): Promise<{ userId: UserId } | null>;
}

Three rules:

  1. Return { userId } on a verified token. The string is whatever your provider returns; Junjo persists it as the external user id.
  2. Return null for every legitimate failure (missing or malformed token, expired session, signature mismatch, upstream network error, unknown user). The calling code treats null as “session not authorized” and never has to wrap your adapter in a try/catch.
  3. Throw JunjoError({ code: "invalid_config" }) only on setup-time misconfiguration (missing secret, missing required option). Configuration errors should fail loud at startup; runtime verification failures should not.

The UserId brand is a TypeScript-only marker. At the boundary cast a raw string with as UserId.

Recipe 1: opaque session token stored in your own database

The most common BYO case: you issue session tokens (via Lucia, NextAuth’s database adapter, Iron Session, or a hand-rolled session table), store them server-side, and look them up on every request.

import type { AuthAdapter, UserId } from "@junjo.io/sdk";
import { prisma } from "./db";
 
export function sessionStoreAdapter(): AuthAdapter {
  return {
    async verifyToken(token) {
      if (typeof token !== "string" || token.length === 0) return null;
      const session = await prisma.session.findUnique({
        where: { token },
        select: { userId: true, expiresAt: true },
      });
      if (!session) return null;
      if (session.expiresAt.getTime() < Date.now()) return null;
      return { userId: session.userId as UserId };
    },
  };
}

Wire it into the SDK like any other adapter:

const junjo = new Junjo({
  apiKey: process.env.JUNJO_API_KEY!,
  authAdapter: sessionStoreAdapter(),
});

Every verifyToken call hits your database. If that becomes a hot path, layer a short-lived in-memory cache (keyed by token, evicted on logout) inside the adapter; the AuthAdapter interface does not enforce a cache shape, so you have full latitude.

Recipe 2: Auth0 (or any “verify against an issuer” provider)

Auth0 is JWT-based, so jwtAdapter is usually the right fit if you have your tenant’s signing key. If you would rather call Auth0’s /userinfo endpoint or use the node-jwks-rsa JWKS client to pick a key by kid:

import type { AuthAdapter, UserId } from "@junjo.io/sdk";
import jwksClient from "jwks-rsa";
import { jwtVerify, createRemoteJWKSet } from "jose";
 
const JWKS = createRemoteJWKSet(
  new URL(`https://${process.env.AUTH0_DOMAIN}/.well-known/jwks.json`),
);
 
export function auth0Adapter(opts: { audience: string; issuer: string }): AuthAdapter {
  return {
    async verifyToken(token) {
      if (typeof token !== "string" || token.length === 0) return null;
      try {
        const { payload } = await jwtVerify(token, JWKS, {
          audience: opts.audience,
          issuer: opts.issuer,
          algorithms: ["RS256"],
        });
        const sub = payload.sub;
        if (typeof sub !== "string" || sub.length === 0) return null;
        return { userId: sub as UserId };
      } catch {
        return null;
      }
    },
  };
}

This pattern (use createRemoteJWKSet + jwtVerify from jose) generalizes to any provider that publishes JWKS: Cognito, Keycloak, Okta, your own JWKS-publishing issuer. The reason to BYO instead of using jwtAdapter: jwtAdapter takes a single PEM key (no JWKS / no kid selection / no rotation), so issuers that rotate keys via JWKS need this wrapper.

jose is already a runtime dep of @junjo.io/sdk, so importing from it costs nothing additional.

Recipe 3: Roblox Player.UserId behind a game-server secret

Roblox does not give the dev’s backend a session token for a player; the trusted identity source is Player.UserId on the Roblox game server (server scripts have no LocalPlayer; that property is nil outside clients). So the thing your backend must authenticate is the ORIGIN of the request: a numeric user id is only trustworthy after you have proven the request came from your game server, not from anyone on the internet who found the endpoint.

Authenticate the game server first, then trust the id. Store a shared secret in the Roblox secrets store (Creator Dashboard > your experience > Secrets), send it in a header from the game server, and have the backend reject any request lacking it BEFORE reading a user id out of the body. Without that gate, the adapter below is an impersonation oracle: it accepts any numeric string as a verified identity, so anyone who can reach the URL can act as any user in your game.

On the Roblox side (a server Script):

local HttpService = game:GetService("HttpService")
 
local function callBackend(player, payload)
  HttpService:RequestAsync({
    Url = "https://your-backend.example/junjo/act",
    Method = "POST",
    Headers = {
      -- Secret userdata stays opaque to Lua; Roblox interpolates the
      -- value at request time.
      ["X-Game-Server-Key"] = HttpService:GetSecret("BACKEND_SHARED_SECRET"),
      ["Content-Type"] = "application/json",
    },
    Body = HttpService:JSONEncode({
      -- From the Player object the server already holds; clients
      -- never supply this value.
      userId = tostring(player.UserId),
      payload = payload,
    }),
  })
end

On the backend, gate the route before the adapter ever sees the id, then brand the id:

import { timingSafeEqual } from "node:crypto";
import type { AuthAdapter, UserId } from "@junjo.io/sdk";
 
// Route-level guard: runs BEFORE any user id in the body is trusted.
export function requireGameServer(req, res, next) {
  const presented = Buffer.from(req.header("x-game-server-key") ?? "");
  const expected = Buffer.from(process.env.ROBLOX_SHARED_SECRET ?? "");
  if (
    expected.length === 0 ||
    presented.length !== expected.length ||
    !timingSafeEqual(presented, expected)
  ) {
    return res.status(401).end();
  }
  next();
}
 
// Only after requireGameServer has passed is the numeric id meaningful.
export function robloxUserIdAdapter(): AuthAdapter {
  return {
    async verifyToken(token) {
      if (typeof token !== "string" || token.length === 0) return null;
      if (!/^[0-9]+$/.test(token)) return null;
      return { userId: token as UserId };
    },
  };
}

The adapter itself stays a passthrough because the real verification happened at the transport layer; its input guards are sanity checks, not authentication. The RobloxUserIdAdapter shipped by the Junjo.io SDK for Roblox (the Luau SDK) is the in-game counterpart for code running on the Roblox server itself; the recipe above is for the non-Roblox half of the integration (a Node backend the Roblox game server forwards requests to).

Recipe 4: passthrough adapter for tests

When testing your own integration you often want to skip token verification entirely:

import type { AuthAdapter, UserId } from "@junjo.io/sdk";
 
export function staticUserAdapter(userId: string): AuthAdapter {
  return {
    async verifyToken() {
      return { userId: userId as UserId };
    },
  };
}

Use only in tests. A production deployment with this adapter accepts any string as a valid session for the configured user, which is exactly what you do not want at runtime.

Recipe 5: layered adapter (try one provider, then another)

Multi-tenant apps sometimes need to accept tokens from more than one issuer. Compose adapters by trying each in turn:

import type { AuthAdapter } from "@junjo.io/sdk";
 
export function firstMatch(adapters: AuthAdapter[]): AuthAdapter {
  return {
    async verifyToken(token) {
      for (const a of adapters) {
        const result = await a.verifyToken(token);
        if (result) return result;
      }
      return null;
    },
  };
}
 
const junjo = new Junjo({
  apiKey: process.env.JUNJO_API_KEY!,
  authAdapter: firstMatch([clerkAdapter({ /* ... */ }), jwtAdapter({ /* ... */ })]),
});

Each child adapter still owns its failure-mode contract; firstMatch just sequences them. Order matters: put the cheapest verifier (in-process JWT) before the network-bound one (Clerk’s JWKS endpoint) when both can succeed.

Caching guidance

Junjo does not cache adapter results. The right cache shape (TTL, key, invalidation on logout) depends on your session-lifetime tolerance and security model:

  • Stateless JWT adapters rarely need a cache; signature verification is already fast.
  • Network-bound adapters (Supabase’s getUser, a Clerk verifier that hits JWKS) benefit from a short-lived (10-60 second) in-memory cache keyed by token hash. Evict on logout if your provider tells you when that happens.
  • Database-backed session adapters can use the same cache pattern, plus an explicit invalidation hook on session deletion.

Either layer the cache inside your adapter (so the contract stays one method that returns a verified id) or build a higher-level wrapper that also exposes invalidation methods to your application.

Failure-mode checklist

Before shipping a BYO adapter, confirm:

  • Empty / non-string token returns null without invoking your verifier (defensive guard).
  • Network errors against an upstream verifier collapse to null, not to a thrown exception.
  • The configured user-id field is checked for string type and non-empty length before being returned.
  • Setup-time misconfiguration (missing secret, missing client) throws JunjoError({ code: "invalid_config" }) from the factory, not from verifyToken.

The four built-in adapters (and the recipes on this page) all follow this checklist; lifting it into your own adapter keeps the failure-mode story consistent with the rest of the SDK.