@radonsdk/auth
v0.1.0
Published
Provider-agnostic, database-agnostic authentication for Node. Email codes, magic links, password auth, Google OAuth, JWT sessions, and framework integrations — bring your own database.
Downloads
156
Readme
Radon
Authentication for Node that you actually own. Email codes, magic links, email + password (with reset), and Google OAuth — over any database, with any email provider, wired into your framework in a few lines. No hosted service, no per-MAU pricing, no vendor lock-in.
import { Radon } from "@radonsdk/auth";
import { postgresAdapter } from "@radonsdk/auth/adapters/postgres";
import { resendSender } from "@radonsdk/auth/senders/resend";
const auth = new Radon({
adapter: postgresAdapter(pool),
session: { secret: process.env.RADON_SECRET! },
providers: {
emailCode: { sender: resendSender({ apiKey, from: "[email protected]" }) },
magicLink: { sender: resendSender({ apiKey, from: "[email protected]" }), baseUrl: "https://app.com/verify" },
emailPassword: { sender: resendSender({ apiKey, from: "[email protected]" }), resetUrl: "https://app.com/reset" },
google: { clientId, clientSecret, redirectUri: "https://app.com/api/auth/google/callback" },
},
});- Bring your own database — MongoDB, PostgreSQL, MySQL, Prisma, Supabase, Firebase, SQLite. One small adapter contract; swap freely.
- Bring your own email — Resend (default), SendGrid, Postmark, AWS SES, or a failover chain across several.
- Four framework integrations — Next.js, Express, Fastify, Hono. Prebuilt routes + middleware, not hand-wired routing.
- Stateless JWT sessions you can verify in your own middleware.
- Secrets never stored in the clear — codes and session tokens are hashed before they touch your database; passwords are bcrypt-hashed.
5-minute setup
1. Install
npm install @radonsdk/auth
# your database driver (pick one):
npm install pg # or mongoose / mysql2 / better-sqlite3 / @supabase/supabase-js / firebase-adminEmail transports (Resend/SendGrid/Postmark) use fetch — no SDK to install.
(Only AWS SES needs @aws-sdk/client-sesv2.)
2. Set two env vars
RADON_SECRET=$(openssl rand -hex 32) # signs JWT sessions & link tokens
RESEND_API_KEY=re_... # or your chosen provider's key3. Create the Radon instance
// lib/auth.ts
import { Radon } from "@radonsdk/auth";
import { postgresAdapter } from "@radonsdk/auth/adapters/postgres";
import { resendSender } from "@radonsdk/auth/senders/resend";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const sender = resendSender({
apiKey: process.env.RESEND_API_KEY!,
from: "Acme <[email protected]>",
});
export const auth = new Radon({
adapter: postgresAdapter(pool),
session: { secret: process.env.RADON_SECRET! },
appName: "Acme",
providers: {
emailCode: { sender },
magicLink: { sender, baseUrl: "https://acme.com/verify" },
emailPassword: { sender, resetUrl: "https://acme.com/reset" },
// google: { clientId, clientSecret, redirectUri }, // see below
},
});
// Create tables once (SQL adapters); safe to call on boot.
await auth.init();4. Mount the auth routes
Pick your framework — each is a couple of lines. The integration exposes every flow under one mount point (routes listed below).
// app/api/auth/[...radon]/route.ts
import { radonNextHandler } from "@radonsdk/auth/integrations/next";
import { auth } from "@/lib/auth";
const handler = radonNextHandler(auth, { basePath: "/api/auth" });
export const GET = handler;
export const POST = handler;Protect a route / read the user:
import { getUserFromRequest } from "@radonsdk/auth/integrations/next";
import { auth } from "@/lib/auth";
export async function GET(req: Request) {
const user = await getUserFromRequest(auth, req);
if (!user) return new Response("Unauthorized", { status: 401 });
return Response.json({ user });
}import express from "express";
import { radonExpress, requireAuth } from "@radonsdk/auth/integrations/express";
import { auth } from "./lib/auth";
const app = express();
app.use(express.json());
app.use("/api/auth", radonExpress(auth));
app.get("/me", requireAuth(auth), (req, res) => res.json(req.radonUser));import Fastify from "fastify";
import { radonFastify, requireAuth } from "@radonsdk/auth/integrations/fastify";
import { auth } from "./lib/auth";
const app = Fastify();
await app.register(radonFastify(auth), { prefix: "/api/auth" });
app.get("/me", { preHandler: requireAuth(auth) }, (req) => req.radonUser);import { Hono } from "hono";
import { radonHono, requireAuth } from "@radonsdk/auth/integrations/hono";
import { auth } from "./lib/auth";
const app = new Hono();
app.all("/api/auth/*", radonHono(auth, { basePath: "/api/auth" }));
app.get("/me", requireAuth(auth), (c) => c.json(c.get("radonUser")));5. Call it from your frontend
// Email one-time code
await fetch("/api/auth/email-code/send", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email }),
});
// …user enters the 6-digit code…
await fetch("/api/auth/email-code/verify", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ email, code }),
}); // → sets an HttpOnly session cookieThat's it — you have working auth. 🎉
HTTP routes
All relative to your mount point (e.g. /api/auth). Successful sign-ins set an
HttpOnly, SameSite=Lax session cookie (radon_session).
| Method & path | Body / query | Effect |
| --- | --- | --- |
| POST /email-code/send | { email } | Emails a 6-digit code |
| POST /email-code/verify | { email, code } | Signs in → session cookie |
| POST /magic-link/send | { email } | Emails a sign-in link |
| GET /magic-link/verify | ?token=… | Signs in → cookie, redirects |
| POST /password/signup | { email, password } | Creates account → cookie |
| POST /password/login | { email, password } | Signs in → cookie |
| POST /password/request-reset | { email } | Emails a reset link (always 200) |
| POST /password/reset | { token, newPassword } | Sets a new password |
| GET /google/start | ?state=… | Redirects to Google |
| GET /google/callback | ?code=… | Signs in → cookie, redirects |
| POST /logout | — | Clears the session cookie |
| GET /session | — | { user } or 401 |
Errors come back as { error: "<code>", message } with a sensible status
(401 invalid credentials/codes, 429 rate-limited, 409 email exists, …).
Using the SDK directly
You don't have to use the HTTP layer — every provider is callable in code.
// Email code
await auth.emailCode.sendCode({ email });
const { user } = await auth.emailCode.verify({ email, code });
// Magic link
const { url } = await auth.magicLink.sendLink({ email }); // emailed for you
const { user } = await auth.magicLink.verify(tokenFromUrl);
// Password + reset
await auth.emailPassword.signup({ email, password });
const { user } = await auth.emailPassword.login({ email, password });
await auth.emailPassword.requestReset({ email }); // emails a link
await auth.emailPassword.setPassword({ token, newPassword });
// Google OAuth
const url = auth.google.getAuthUrl({ state }); // redirect the user here
const { user } = await auth.google.handleCallback(code); // on the callback
// Sessions (stateless JWT)
const { token, expiresAt } = auth.createSessionToken(user.id, { claims: { role: "admin" } });
const claims = auth.verifyToken(token); // throws if invalid/expired
const current = await auth.getSessionUser(token); // verify + load userVerify a token anywhere without an SDK instance — same secret:
import { verifyToken } from "@radonsdk/auth";
const claims = verifyToken(req.cookies.radon_session, process.env.RADON_SECRET!);Configuration
new Radon({
adapter, // required — from radon/adapters/*
session: {
secret: process.env.RADON_SECRET!, // required
expiresInSec: 7 * 24 * 60 * 60, // default 7 days
issuer, audience, // optional JWT claims
},
appName: "Acme", // shown in default email copy
providers: { /* see below */ },
code: { length: 6, ttlMs: 600_000, maxAttempts: 5 }, // one-time-code engine
rateLimit: { maxPerWindow: 3, windowMs: 600_000 }, // 3 sends / 10 min / email
});Providers (include only the ones you want):
providers: {
emailCode: { sender, template?, from? },
magicLink: { sender, baseUrl, ttlMs?, template?, from? },
emailPassword: { sender, resetUrl, hasher?, minLength?, resetTemplate?, from? },
google: { clientId?, clientSecret?, redirectUri?, scopes?, fetch? },
}Google credentials fall back to the RADON_GOOGLE_CLIENT_ID,
RADON_GOOGLE_CLIENT_SECRET, and RADON_GOOGLE_REDIRECT_URI environment
variables, so you can omit them from code entirely.
Custom email templates
emailCode: {
sender,
template: ({ code, expiresInMinutes, appName }) => ({
subject: `${code} is your ${appName} code`,
html: `<h1>${code}</h1><p>Expires in ${expiresInMinutes} min.</p>`,
text: `Your code: ${code}`,
}),
}Email senders
Each is a separate import so you only pull in what you use.
import { resendSender } from "@radonsdk/auth/senders/resend"; // default
import { sendgridSender } from "@radonsdk/auth/senders/sendgrid";
import { postmarkSender } from "@radonsdk/auth/senders/postmark";
import { sesSender } from "@radonsdk/auth/senders/ses"; // needs @aws-sdk/client-sesv2
resendSender({ apiKey, from: "Acme <[email protected]>" });
sendgridSender({ apiKey, from: "[email protected]" });
postmarkSender({ serverToken, from: "[email protected]" });
sesSender({ region: "us-east-1", from: "[email protected]" });Failover across providers:
import { multiSender } from "@radonsdk/auth";
const sender = multiSender(
[resendSender({ apiKey }), sendgridSender({ apiKey: sg })],
{ onError: (e, i) => console.warn(`sender ${i} failed`, e) },
);Writing your own is one method:
import type { EmailSender } from "@radonsdk/auth";
const mySender: EmailSender = {
async send({ to, subject, html, text }) {
await myTransport(to, subject, html ?? text);
return { provider: "custom" };
},
};Database adapters
Every adapter takes an already-instantiated client. SQL adapters create their
tables via auth.init(); Prisma/Supabase/Firebase use schemas you define (see
each adapter's docs in source, or the adapter reference).
import { mongoAdapter } from "@radonsdk/auth/adapters/mongo"; // Mongoose Connection
import { postgresAdapter } from "@radonsdk/auth/adapters/postgres"; // pg Pool
import { mysqlAdapter } from "@radonsdk/auth/adapters/mysql"; // mysql2 Pool
import { sqliteAdapter } from "@radonsdk/auth/adapters/sqlite"; // better-sqlite3 Database
import { prismaAdapter } from "@radonsdk/auth/adapters/prisma"; // PrismaClient
import { supabaseAdapter } from "@radonsdk/auth/adapters/supabase"; // SupabaseClient (service role)
import { firebaseAdapter } from "@radonsdk/auth/adapters/firebase"; // FirestoreGoogle OAuth setup
- In Google Cloud Console → APIs & Services → Credentials, create an OAuth 2.0 Client ID (Web application).
- Add your callback to Authorized redirect URIs, e.g.
https://acme.com/api/auth/google/callback. - Set
RADON_GOOGLE_CLIENT_ID,RADON_GOOGLE_CLIENT_SECRET, andRADON_GOOGLE_REDIRECT_URI(or pass them inproviders.google). - Send users to
GET /api/auth/google/start. Radon handles the exchange, fetches the profile, and merges by verified email into a single user.
How it fits together
┌─────────────────────────────────────────────┐
│ Radon (SDK) — the public API │
│ providers · senders · JWT sessions │
└───────────────┬─────────────────┬─────────────┘
integrations/* │ │ senders/*
(next·express· │ │ (resend·sendgrid·
fastify·hono) │ │ postmark·ses)
┌───────▼─────────┐ ▼
│ RadonEngine │ EmailSender
│ codes·sessions· │
│ merge-by-email │
└───────┬─────────┘
adapters/* (mongo·postgres·mysql·prisma·
supabase·firebase·sqlite)The low-level RadonEngine and every provider class are exported too, if you
want to compose things yourself.
Radon Pro
Pro unlocks 50 OAuth providers, phone/SMS OTP, passkeys/WebAuthn,
2FA/TOTP, and six more framework integrations. Pro code lives under
radon/pro/* so free-tier apps never bundle it.
Licensing
Pro features require a license key. Set it in config and call await auth.init()
— Radon verifies the key once against the license service and caches the
result for the process lifetime (never per request).
import { Radon } from "@radonsdk/auth";
const auth = new Radon({
adapter,
session: { secret: process.env.RADON_SECRET! },
licenseKey: process.env.RADON_LICENSE_KEY, // or falls back to that env var
license: { appUrl: "https://yourapp.com" }, // used for the anti-sharing domain lock
providers: {
oauth: {
github: { preset: "github", clientId, clientSecret, redirectUri },
},
phoneOtp: { sender: twilioSender({ accountSid, authToken, from }) },
totp: {},
webauthn: { rpID: "yourapp.com", origin: "https://yourapp.com" },
},
});
await auth.init(); // ← verifies the license; throws if missing/invalid- Configure a pro provider without a valid key →
auth.init()throwsLicenseRequiredErrorwith a link to buy one, and pro getters throw until a license is confirmed. - Keys are domain-locked: the first deployment to activate a key binds it
(via a hashed
instanceIdderived fromappUrl). A leaked key is useless on another domain. Keys are short and readable:RDN-XXXX-XXXX-XXXX. - The verify service is a tiny, self-hostable Express app — see
license-service/. It's the only always-on infrastructure in the whole product.
OAuth — 50 providers, one engine
Every provider runs on one generic OAuth2/OIDC engine; each is a one-liner.
providers: {
oauth: {
github: { preset: "github", clientId, clientSecret, redirectUri },
discord: { preset: "discord", clientId, clientSecret, redirectUri },
microsoft: { preset: "microsoft", clientId, clientSecret, redirectUri },
},
}
// then, after init():
const url = auth.oauth("github").getAuthUrl({ state }).url; // redirect the user here
const { user } = await auth.oauth("github").handleCallback({ code });Built-in presets (50): github, gitlab, bitbucket, discord, facebook, twitter, linkedin, slack, spotify, twitch, reddit, tiktok, snapchat, dropbox, box, zoom, notion, figma, salesforce, hubspot, paypal, amazon, yahoo, epicgames, battlenet, roblox, patreon, strava, fitbit, coinbase, line, wechat, kakao, naver, vk, mailru, instagram, pinterest, dribbble, behance, zoho, digitalocean, microsoft, azuread, apple, wordpress, okta, auth0, shopify, steam.
Some need per-instance params — use the builder presets:
import { microsoftPreset, shopifyPreset, oktaPreset, auth0Preset } from "@radonsdk/auth/pro/oauth";
oauth: {
work: { preset: microsoftPreset({ tenant: "your-tenant-id" }), clientId, clientSecret, redirectUri },
store: { preset: shopifyPreset({ shop: "acme" }), clientId, clientSecret, redirectUri },
sso: { preset: oktaPreset({ domain: "acme.okta.com" }), clientId, clientSecret, redirectUri },
}Any provider not in the 50 — pass your own preset (same shape the built-ins use). No forking required:
oauth: {
acme: {
preset: {
id: "acme", name: "Acme",
authorizationUrl: "https://acme.com/oauth/authorize",
tokenUrl: "https://acme.com/oauth/token",
userInfoUrl: "https://acme.com/api/me",
defaultScopes: ["email"],
// optional: pkce, tokenAuthStyle, mapProfile(raw) => { id, email, ... }
},
clientId, clientSecret, redirectUri,
},
}Credentials also fall back to RADON_<PROVIDER>_CLIENT_ID / _CLIENT_SECRET /
_REDIRECT_URI env vars.
Phone / SMS OTP
Same shape as email codes, over SMS. Twilio is the default transport (pluggable like email senders).
import { twilioSender } from "@radonsdk/auth/pro/sms";
providers: { phoneOtp: { sender: twilioSender({ accountSid, authToken, from: "+14155550123" }) } }
await auth.phoneOtp.sendCode({ phone: "+14155550123" });
const { user } = await auth.phoneOtp.verify({ phone: "+14155550123", code });2FA / TOTP
Google-Authenticator-compatible second factor, layered on any primary method. Passes the RFC 6238 test vectors.
providers: { totp: { issuer: "Acme" } }
// enrollment
const { secret, uri } = await auth.totp.beginEnrollment(userId); // render `uri` as a QR
const { recoveryCodes } = await auth.totp.confirmEnrollment(userId, codeFromApp); // show once
// at login, after the primary factor:
const ok = await auth.totp.verify(userId, codeFromApp);
// or: await auth.totp.verifyRecoveryCode(userId, recoveryCode)Passkeys / WebAuthn
Registration and authentication ceremonies, verified server-side with the
audited @simplewebauthn/server (install it to use passkeys).
Client-side required. WebAuthn needs browser APIs Radon can't call from the server. Radon produces the challenge options and verifies the responses; your frontend must call
navigator.credentials.create()/.get()(or@simplewebauthn/browser) and POST the results back. Persist thechallengereturned by eachstart*call and pass it to the matchingfinish*call.
providers: { webauthn: { rpID: "acme.com", origin: "https://acme.com" } }
// register
const { options, challenge } = await auth.webauthn.startRegistration(userId);
// → browser: startRegistration(options) → POST result back:
await auth.webauthn.finishRegistration({ userId, response, expectedChallenge: challenge });
// authenticate
const { options, challenge } = await auth.webauthn.startAuthentication(userId);
// → browser: startAuthentication(options) → POST result back:
const { user } = await auth.webauthn.finishAuthentication({ userId, response, expectedChallenge: challenge });Pro framework integrations
Completing the set of ten. Same mount-and-go pattern as the free four.
import { radonNestHandler, createRadonAuthGuard } from "@radonsdk/auth/pro/integrations/nestjs";
import { radonKoa } from "@radonsdk/auth/pro/integrations/koa";
import { radonSvelteKit } from "@radonsdk/auth/pro/integrations/sveltekit";
import { radonNuxt } from "@radonsdk/auth/pro/integrations/nuxt";
import { radonRemix } from "@radonsdk/auth/pro/integrations/remix";
import { radonAstro } from "@radonsdk/auth/pro/integrations/astro";Each mounts on a catch-all route (/api/auth/*) and exposes the same routes as
the free integrations, plus a requireAuth/guard for your own routes. See each
module's doc comment for the exact route-file placement.
Sessions, orgs & platform primitives
All configured under providers and reached via the SDK once licensed.
Refresh tokens (short access + long refresh, rotation)
providers: { refresh: { accessTtlSec: 900, refreshTtlMs: 30 * 864e5 } }
const pair = await auth.refresh.issue({ userId, device: { label: "CLI" } });
// pair.accessToken (short JWT) + pair.refreshToken (opaque, rotated on use)
const next = await auth.refresh.refresh(pair.refreshToken); // rotates; old token dies
await auth.refresh.revokeAllForUser(userId);Presenting an already-rotated refresh token (theft/replay) revokes the whole
token family and throws RefreshTokenInvalidError.
Multi-device sessions
providers: { sessions: {} }
const { token } = await auth.sessionsProvider.create({ userId, device: { label: "Chrome/macOS", ip } });
const devices = await auth.sessionsProvider.listDevices(userId, token); // current one is flagged
await auth.sessionsProvider.revokeDevice(devices[0].id); // one device
await auth.sessionsProvider.revokeAll(userId); // log out everywhereAnonymous / guest sessions + upgrade
const guest = await auth.sessionsProvider.createGuest({ metadata: { cart } });
// ...associate data with guest.user.id...
await auth.sessionsProvider.upgrade({
userId: guest.user.id, provider: "email", providerAccountId: email, email, emailVerified: true,
}); // same user id → data preserved, guest flag clearedAPI keys (service-to-service / CLI)
providers: { apiKeys: {} }
const { key, record } = await auth.apiKeys.create({ userId, name: "CI token", scopes: ["read"] });
// key = "rk_<prefix>_<secret>" — shown ONCE. Only its hash is stored.
const owner = await auth.apiKeys.verify(presentedKey); // throws if invalid/expired/revoked
await auth.apiKeys.revoke(record.id);Orgs / teams
providers: { orgs: { sender: resendSender({ apiKey }), inviteUrl: "https://app.com/invite" } }
const { org } = await auth.orgs.create({ name: "Acme", ownerUserId });
await auth.orgs.invite({ orgId: org.id, email: "[email protected]", role: "member", invitedByUserId });
const membership = await auth.orgs.acceptInvite(tokenFromEmail, acceptingUserId);
await auth.orgs.setRole(org.id, userId, "admin");
const members = await auth.orgs.listMembers(org.id);Roles rank owner > admin > member; the last owner can't be removed or
demoted. auth.orgs.assertRole(orgId, userId, "admin") gates your own actions.
Impersonation (support/debugging)
// ⚠️ Radon does NOT check who is an admin. Verify admin authorization YOURSELF first.
const { token } = await auth.sessionsProvider.impersonate(adminUserId, targetUserId, {
reason: "support ticket #42",
});
// token carries `imp: true` + `act: adminUserId` claims and emits an `impersonation` event.Account export & deletion (GDPR)
providers: { account: {} }
const data = await auth.account.exportData(userId); // all records, secrets stripped
await auth.account.deleteUser(userId); // cascade: sessions, keys, identities, membershipsEvents / webhooks
Hook side effects without forking Radon. Subscribing requires a license; handlers are isolated (a throwing handler never breaks auth).
const off = auth.on("user.created", ({ user }) => analytics.track(user));
auth.on("login", ({ user, method }) => {});
auth.on("session.revoked", ({ userId, all }) => {});
auth.on("apikey.created", ({ apiKey }) => {});
auth.on("org.member_added", ({ membership }) => {});
auth.on("refresh.reuse_detected", ({ userId }) => alertSecurity(userId));Security
Radon hashes every verifiable secret, encrypts the one reversible secret (TOTP)
with AES-256-GCM using your RADON_ENCRYPTION_KEY, compares all secrets in
constant time, ships secure-by-default cookies (HttpOnly + Secure +
SameSite=Lax), and provides CSRF helpers. See SECURITY.md for
the full model and the dependency-audit results (production deps: 0 known
vulnerabilities).
RADON_ENCRYPTION_KEY=$(openssl rand -hex 32) # required for TOTP; encrypts reversible secrets at restAdapter capabilities
The pro session/org/key/deletion features need extra adapter methods. All 7
built-in adapters (+ the in-memory reference) implement them. A custom
adapter missing a method throws a clear AdapterCapabilityError naming the
method and feature — nothing fails silently.
License
MIT
