@1moby/just-auth
v0.4.3
Published
Lightweight zero-dependency edge-native auth library for React
Downloads
261
Maintainers
Readme
@1moby/just-auth
Lightweight, zero-dependency, edge-native auth library for React.
OAuth 2.0 + PKCE, email/password, session management, RBAC, route-level middleware, and (with the ClickHouse adapter) a multi-org permission graph + approval-flow primitives — all built on Web Crypto API and raw SQL. Works with Cloudflare Workers, Bun, Deno, Next.js, and any runtime that supports standard Request/Response.
Features
- Zero runtime dependencies — only React as a peer dep (
@clickhouse/clientis an optional peer for the CH adapter) - OAuth 2.0 + PKCE — built-in providers for Google, GitHub, LINE
- Email/password auth — PBKDF2-SHA256 (600k iterations), timing-safe comparison
- Account linking — multiple providers share one user account via email matching (
allowEmailAccountLinking) - Lifecycle callbacks —
signInto gate OAuth sign-in + inject extra user columns;sessionto customize the/api/auth/sessionresponse body - Session management — sliding window (30-day sessions, auto-extend at 15 days), SHA-256 hashed tokens
- RBAC — optional role-based access control with code-defined permissions; multi-role per user, role inheritance, deny rules
- Email/domain restriction —
allowedEmailsconfig to restrict by domain or custom function - Route permission middleware —
createAuthMiddlewarefor path-based permission gating - Database adapters — D1, bun:sqlite, pg, mysql2, Bun.sql, ClickHouse — bring your own driver
- ClickHouse extras (experimental) — multi-org / department / supervisor permission graph (
adapter.rbac) + approval-flow state machine (adapter.approvals); integration-tested against CH 24.8 / 25.3 / 25.10 - Table prefix —
tablePrefix: "myapp_"for shared databases - Non-destructive migrations — validates existing schema, never ALTER or DROP
- Security hardened — open redirect protection, password length limits, POST-only logout
- Edge-native — standard
Request/Response, no Node.js-specific APIs
Install
bun add @1moby/just-auth
# or
npm install @1moby/just-authQuick Start
1. Choose a Database Adapter
// Cloudflare D1
import { createD1Adapter } from "@1moby/just-auth/adapters/d1";
const db = createD1Adapter(env.DB);
// bun:sqlite
import { createBunSQLiteAdapter } from "@1moby/just-auth/adapters/bun-sqlite";
const db = createBunSQLiteAdapter(new Database("auth.db"));
// PostgreSQL (pg)
import { createPgAdapter } from "@1moby/just-auth/adapters/pg";
const db = createPgAdapter(new Pool({ connectionString: env.DATABASE_URL }));
// MySQL (mysql2)
import { createMySQLAdapter } from "@1moby/just-auth/adapters/mysql";
const db = createMySQLAdapter(pool);
// Bun.sql (Postgres or MySQL)
import { createBunSQLAdapter } from "@1moby/just-auth/adapters/bun-sql";
const db = createBunSQLAdapter(Bun.sql);
const db = createBunSQLAdapter(Bun.sql, { dialect: "mysql" });Only the adapter you import gets bundled. Drivers are peer dependencies — install what you need.
2. Run Migrations
import { migrate } from "@1moby/just-auth";
await migrate(db);
// With table prefix:
await migrate(db, { tablePrefix: "myapp_" });Migrations are non-destructive: creates tables if missing, validates existing schema, never alters or drops existing tables.
3. Server Setup
import {
createReactAuth,
createGoogleProvider,
createGitHubProvider,
} from "@1moby/just-auth";
const auth = createReactAuth({
providers: [
createGoogleProvider({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
redirectURI: "https://example.com/api/auth/callback/google",
}),
createGitHubProvider({
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
redirectURI: "https://example.com/api/auth/callback/github",
}),
],
database: db,
credentials: true,
oauthAutoCreateAccount: true,
allowEmailAccountLinking: true,
});
// Handle auth routes
const response = await auth.handleRequest(request);
if (response) return response;4. Protect Server Routes
const session = await auth.auth(request);
if (!session) {
return new Response("Unauthorized", { status: 401 });
}
// session.user = { id, email, name, avatarUrl, role }5. React Client
import { SessionProvider, useSession, signIn, signOut } from "@1moby/just-auth/client";
function App() {
return (
<SessionProvider>
<Profile />
</SessionProvider>
);
}
function Profile() {
const { data, status } = useSession();
if (status === "loading") return <p>Loading...</p>;
if (status === "unauthenticated") return <button onClick={() => signIn("google")}>Sign In</button>;
return (
<div>
<p>Hello, {data.user.name}</p>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}6. Email/Password
import { signIn, signUp } from "@1moby/just-auth/client";
// Register
const res = await signUp({ email: "[email protected]", password: "secret123" });
// Login
const res = await signIn("credentials", { email: "[email protected]", password: "secret123" });Configuration
createReactAuth({
providers: [...],
database: db,
// Auth options
basePath: "/api/auth", // default: "/api/auth"
credentials: true, // enable email/password auth
allowRegistration: true, // allow self-registration (default: true when credentials enabled)
oauthAutoCreateAccount: true, // auto-create users on OAuth login (default: false)
allowEmailAccountLinking: true, // link accounts by verified email match (default: false)
passwordMinLength: 8, // default: 8, max: 128
// Email restriction
allowedEmails: ["@1moby.com"], // domain allowlist
// or: allowedEmails: (email) => email.endsWith("@1moby.com"),
// Table prefix for shared databases
tablePrefix: "myapp_", // → myapp_users, myapp_accounts, myapp_sessions
// Cookie options
cookie: {
name: "auth_session", // default: "auth_session"
secure: true, // default: true
sameSite: "lax", // default: "lax"
domain: ".example.com", // for subdomain sharing
path: "/", // default: "/"
},
// Session options
session: {
maxAge: 30 * 86400, // 30 days (seconds)
refreshThreshold: 15 * 86400, // extend when < 15 days remaining
},
// RBAC (see full RBAC section below)
rbac: {
statements: {
post: ["create", "read", "update", "delete"],
user: ["list", "ban", "set-role"],
},
roles: {
user: { post: ["read"] },
admin: "*", // wildcard = all permissions
},
defaultRole: "user",
},
// Custom error redirect target (used by signIn callback rejections)
pages: { error: "/auth/error" },
// Lifecycle callbacks (see "Hooks" section)
callbacks: {
signIn: async (ctx) => ({ allow: true }),
session: async ({ user, session }) => ({ user, session }),
},
});Email-based account linking
When a user signs in via OAuth and their (provider_id, provider_user_id) has no matching row in accounts, but their profile email matches an existing user, the default behavior is to reject with OAuthAccountNotLinked (HTTP 403). Set allowEmailAccountLinking: true to instead link the incoming OAuth account to the existing user.
createReactAuth({
// ...
allowEmailAccountLinking: true,
});Trust implication. Linking by email is safe only when you trust the identity provider to verify the email (e.g. Google Workspace with a hosted-domain restriction, or a corporate IdP). If a provider lets users sign up with unverified emails, a malicious user could claim ownership of another user's email and get their account linked.
When a link occurs, the signIn callback (if set) is invoked with ctx.emailLinked === true and ctx.existingUserId set to the linked user's id — useful for audit logs:
callbacks: {
signIn: async (ctx) => {
if (ctx.emailLinked) {
await auditLog.record({ event: "oauth_account_linked", userId: ctx.existingUserId, provider: ctx.provider });
}
return { allow: true };
},
}The older allowDangerousEmailAccountLinking flag still works as an alias for backward compatibility but is deprecated; prefer allowEmailAccountLinking in new code.
Hooks
Two optional lifecycle callbacks let consumers intercept sign-in and customize the session response without forking the library. Both are plain async functions on AuthConfig.callbacks.
signIn — gate OAuth sign-in, inject extra columns
Fires inside the OAuth callback handler, after token exchange and user lookup but before any user or account row is written. Return { allow: false, reason } to abort (the user is redirected to pages.error ?? "/" with ?error=REASON). Return { allow: true, userOverrides } to continue; userOverrides is merged into the users INSERT as extra columns — only applied when a new user is being created. The base identity columns (id, email, name, avatar_url) cannot be overridden. The role column IS overridable — you can assign an initial role to the new user via userOverrides: { role: "admin" }. Use with care; the library does not validate role names.
import type { AuthConfig } from "@1moby/just-auth";
export const authConfig: AuthConfig = {
// ... providers, database, etc.
pages: { error: "/auth/error" },
callbacks: {
signIn: async (ctx) => {
if (!ctx.profile.email?.endsWith("@1moby.com")) {
return { allow: false, reason: "DOMAIN_BLOCKED" };
}
// Optional: look up invitation, attach org_id
return {
allow: true,
userOverrides: { org_id: "the-org-uuid" },
};
},
},
};Extra columns must already exist on the users table — the library never ALTERs existing tables. Column names are validated against /^[a-zA-Z_][a-zA-Z0-9_]*$/ before being interpolated into the INSERT; values use parameter binding.
session — customize the /api/auth/session response
Fires on every GET /api/auth/session call, after the session + user are loaded. Whatever you return becomes the response body verbatim (the default { user, session, accounts, permissions } shape is bypassed entirely — include what you need).
callbacks: {
session: async ({ user, session }) => {
const roles = await fetchRolesFor(user.id);
return { user, roles, sessionExpiresAt: session.expiresAt };
},
}With no callbacks.session set, the default response shape is unchanged from 0.1.x.
RBAC
Supports multi-role per user, role inheritance, and deny rules — all backward-compatible with single-role setups.
Basic (single role)
rbac: {
statements: {
post: ["create", "read", "update", "delete"],
user: ["list", "ban", "set-role"],
},
roles: {
viewer: { post: ["read"] },
admin: "*", // all permissions
},
defaultRole: "viewer",
}Multi-role
Multiple roles stored as comma-separated string in the same role column ("user,editor"):
// Assign multiple roles
POST /api/auth/role
{ userId: "u1", roles: ["user", "editor"] }
// Add a role incrementally
{ userId: "u1", addRole: "editor" }
// Remove a role
{ userId: "u1", removeRole: "admin" }Role Inheritance
roles: {
viewer: { post: ["read"] },
editor: {
allow: { post: ["create", "read", "update"] },
inherits: ["viewer"], // gets all viewer permissions
},
moderator: {
allow: { user: ["list", "ban"] },
inherits: ["editor"], // editor → viewer chain
},
}Deny Rules (deny always wins)
roles: {
moderator: {
allow: { user: ["list", "ban", "set-role"] },
deny: { user: ["set-role"] }, // can ban but can't change roles
},
admin: {
allow: "*",
deny: { billing: ["manage"] }, // can view but not manage billing
},
superadmin: "*", // unrestricted
}Server API
const canEdit = await auth.hasPermission(request, "post:update");
const isAdmin = await auth.hasRole(request, "admin"); // multi-role aware
const roles = await auth.getRoles(request); // ["user", "editor"]Client API
import { usePermission, useRole } from "@1moby/just-auth/client";
const canEdit = usePermission("post:update");
const isAdmin = useRole("admin"); // works with "user,admin" multi-roleRoute Permission Middleware
import { createAuthMiddleware } from "@1moby/just-auth/middleware";
const { handle } = createAuthMiddleware(auth, {
publicPaths: ["/login", "/public/*"],
loginRedirect: "/login",
routePermissions: {
"/admin/*": "admin:access",
"/api/admin/*": "admin:access",
},
onForbidden: (req) => new Response("Forbidden", { status: 403 }),
});
// In your server:
const blocked = await handle(request);
if (blocked) return blocked;
// ...proceed with normal routingAuto-skips static files (.js, .css, .png, etc.). Supports exact paths and glob patterns.
Auth Routes
Default basePath: "/api/auth":
| Route | Method | Description |
|-------|--------|-------------|
| /api/auth/login/:provider | GET | Redirect to OAuth provider |
| /api/auth/callback/:provider | GET | Handle OAuth callback, create session |
| /api/auth/register | POST | Register with email/password |
| /api/auth/callback/credentials | POST | Login with email/password |
| /api/auth/session | GET | Return session JSON + linked accounts + permissions |
| /api/auth/role | POST | Set user role (requires user:set-role permission) |
| /api/auth/logout | POST | Invalidate session, return { ok: true } |
Database Adapters
Bring your own driver — only the adapter you import gets bundled:
import { createD1Adapter } from "@1moby/just-auth/adapters/d1";
import { createBunSQLiteAdapter } from "@1moby/just-auth/adapters/bun-sqlite";
import { createPgAdapter } from "@1moby/just-auth/adapters/pg";
import { createMySQLAdapter } from "@1moby/just-auth/adapters/mysql";
import { createBunSQLAdapter } from "@1moby/just-auth/adapters/bun-sql";The Pg and Bun.sql adapters auto-translate ? placeholders to $1, $2, .... Schema uses portable types (VARCHAR(255), BIGINT, TEXT) that work across SQLite, Postgres, and MySQL.
You can also implement the DatabaseAdapter interface directly for any custom driver.
ClickHouse adapter (experimental)
@1moby/just-auth/adapters/clickhouse is a drop-in replacement for the SQL adapters that backs auth state on ClickHouse ReplacingMergeTree tables, plus exposes two extra APIs on the same adapter object:
adapter.rbac— a multi-org / department / supervisor permission graph.adapter.approvals— an approval-flow state machine (open / decide / delegate / expire).adapter.migrate()— idempotent DDL setup, cluster-aware.
import { createClient } from "@clickhouse/client";
import { createClickhouseAdapter } from "@1moby/just-auth/adapters/clickhouse";
import { createReactAuth } from "@1moby/just-auth";
const ch = createClient({ url: env.CH_URL, username: env.CH_USER, password: env.CH_PASS });
const adapter = createClickhouseAdapter({ client: ch });
await adapter.migrate();
const auth = createReactAuth({
database: adapter,
providers: [/* ... */],
});
// RBAC
await adapter.rbac.createOrganization({ id: "org1", name: "Acme" });
await adapter.rbac.defineRole({ id: "editor", scope: "org", permissions: ["dashboard.edit"] });
await adapter.rbac.grantUserRole({ userId: "u1", roleId: "editor", orgId: "org1", grantedBy: "system" });
const decision = await adapter.rbac.resolvePermission({
userId: "u1",
permission: "dashboard.edit",
resource: { orgId: "org1" },
});
// decision.allowed === true; decision.via === "role"
// Approvals
const req = await adapter.approvals.open({
requesterUserId: "u1",
action: "dashboard.publish",
resource: { orgId: "org1" },
payload: { title: "Q4" },
chainStrategy: "supervisor_chain",
});
await adapter.approvals.decide({
requestId: req.id,
approverUserId: "boss",
decision: "approved",
});Verified versions
The adapter is integration-tested against three live ClickHouse servers. All 13 spec scenarios pass on each:
| Server | Image tag | Status |
| --- | --- | --- |
| 24.x LTS | clickhouse/clickhouse-server:24.8 | ✓ 13/13 |
| 25.x LTS | clickhouse/clickhouse-server:25.3 | ✓ 13/13 |
| 25.10+ | clickhouse/clickhouse-server:25.10 | ✓ 13/13 |
To reproduce locally:
docker compose -f examples/clickhouse/docker-compose.yml up -d
bun tests/integration/clickhouse/run.ts # all three
bun tests/integration/clickhouse/run.ts 24 # one version
docker compose -f examples/clickhouse/docker-compose.yml down -vOLAP-on-OLTP trade-offs (read these before adopting)
- Hot reads use
FINALonReplacingMergeTreeto collapse to the latest version per key. This is more expensive than a typical SQL row read; budget for it. sessions_dictDictionary fronts the session lookup hot path. DefaultLIFETIME(MIN 5 MAX 15)— up to 15s of staleness on revoked sessions. Either accept the window, forceSYSTEM RELOAD DICTIONARY sessions_dicton revoke, or disable the dict viauseSessionDict: false.- Email uniqueness is best-effort. ClickHouse has no transactional unique constraint. Two parallel
createUsercalls with the same email both succeed; the consumer must check viaFINAL ... LIMIT 1before insert. Even then, a residual race window remains. - Cascade deletes are app-level. Deleting a user emits tombstone rows across
users,accounts,sessions, anduser_role_grantssequentially. There is a small window where the user is gone but related rows are not. - No multi-table transactions. The approval state machine writes a tombstone row + a fresh row for each transition. A crash between the audit-log append and the request-row update can leave the audit slightly ahead of state.
- Cluster mode. Pass
cluster: 'name'to wrap every DDL withON CLUSTER '<name>'and every engine withReplicated*. Keeper paths follow/clickhouse/tables/{installation}/{shard}/<table>(uses CH macros).
The pre-existing RbacConfig-style RBAC continues to work on every adapter (including ClickHouse) — the graph RBAC API is additive, you can use either or both.
See examples/clickhouse/ for a runnable docker-compose setup.
Exports
// Main
import { createReactAuth, migrate } from "@1moby/just-auth";
import { createGoogleProvider, createGitHubProvider, createLineProvider } from "@1moby/just-auth";
import { hashPassword, verifyPassword, resolvePermissions, parseRoles } from "@1moby/just-auth";
import { createQueries, resolveTableNames } from "@1moby/just-auth";
// Client
import { SessionProvider, useSession, signIn, signUp, signOut } from "@1moby/just-auth/client";
import { usePermission, useRole } from "@1moby/just-auth/client";
// Middleware
import { createAuthMiddleware } from "@1moby/just-auth/middleware";
// Database Adapters
import { createD1Adapter } from "@1moby/just-auth/adapters/d1";
import { createBunSQLiteAdapter } from "@1moby/just-auth/adapters/bun-sqlite";
import { createPgAdapter } from "@1moby/just-auth/adapters/pg";
import { createMySQLAdapter } from "@1moby/just-auth/adapters/mysql";
import { createBunSQLAdapter } from "@1moby/just-auth/adapters/bun-sql";
import { createClickhouseAdapter } from "@1moby/just-auth/adapters/clickhouse";
// Types
import type {
AuthConfig, AuthInstance, User, Session, Account,
DatabaseAdapter, OAuthProvider, SessionManager,
RbacConfig, RoleDefinition, SessionContextValue, SessionStatus,
Queries, TableNames, MigrateOptions,
// 0.2.x callbacks
AuthCallbacks, SignInCallbackContext, SignInCallbackResult,
SessionCallbackContext, PagesConfig,
} from "@1moby/just-auth";
// ClickHouse adapter types (when using @1moby/just-auth/adapters/clickhouse)
import type {
Organization, Department, EffectiveRole, PermissionDecision,
PermissionVia, RoleScope, RoleDefinition as CHRoleDefinition,
RoleGrant, ApprovalRequest, ApprovalStatus, ApprovalDecision,
RbacApi, ApprovalsApi, CHClient, CHTableNames,
} from "@1moby/just-auth/adapters/clickhouse";Testing
bun test # unit suite (331 tests across 20 files)
# ClickHouse integration matrix (requires Docker)
docker compose -f examples/clickhouse/docker-compose.yml up -d
bun tests/integration/clickhouse/run.ts # all 13 scenarios x CH 24.8 / 25.3 / 25.10
docker compose -f examples/clickhouse/docker-compose.yml down -vSecurity
What the library protects against, and what your application still has to handle.
What's enforced by the library
- Session tokens — 256-bit random, SHA-256-hashed in storage, sliding window with 30-day TTL. Raw tokens never touch the database.
- OAuth state — 256-bit random per flow, timing-safe equality, per-provider cookie names (
oauth_state_<id>,code_verifier_<id>) so concurrent flows don't cross-contaminate. Cleared on every callback exit path (success and every error). - PKCE — S256 code verifier on Google and LINE; GitHub passes the challenge but its OAuth-App tokens endpoint may not enforce it.
- Cookie prefixes — default cookie name is
__Host-auth_session. If you setcookie.domainor a non-/cookie.path, the library auto-downgrades to__Secure-…(a__Host-cookie with Domain set is silently rejected by browsers). - Email-based account linking — gated on
profile.emailVerified === trueandallowEmailAccountLinking: true. SetallowUnverifiedEmailLinking: trueto opt out (don't, unless you trust the IdP). ReturnsEmailNotVerifiedwhen the provider didn't verify the email. signIncallbackuserOverrides—id,email,name,avatar_url,password_hash, androleare reserved and cannot be injected. Other column names pass through but are validated against/^[a-zA-Z_][a-zA-Z0-9_]*$/before being interpolated.POST /role— requires theuser:set-rolepermission. Cannot be used to change your own role. Cannot grant a role whose permissions aren't a subset of yours (no privilege escalation in a single call).- Email enumeration on registration —
hashPasswordruns unconditionally before checking if the email exists, so taken/available paths take comparable wall time. - Email enumeration on credentials login —
verifyPasswordalways runs against either the user's hash or a dummy hash of the same shape. - CSRF (defense in depth) —
Origin/Referercheck on every POST, alongsideSameSite=Laxcookies. - Open redirect —
onAuthSuccessandpages.errorare validated againstisSafeRedirect(same-origin only). - HTML attribute escaping —
&,",',`,<,>are all escaped in the OAuth-redirect HTML. - Email case — normalized to lowercase + trimmed at every ingestion point (registration, OAuth callback,
allowedEmailscheck) so[email protected]and[email protected]collapse to one identity. - ClickHouse table names — every consumer-provided table name is validated against the SQL identifier regex at adapter construction time.
createReactAuthprovider IDs — must match[a-zA-Z0-9_-]+(cookie-name safety).
What you still need to do
- Rate limit
POST /registerandPOST /callback/credentials. The library enforces apasswordMinLength(default 8 — bump to 12+ for new apps) and a 128-char max to prevent PBKDF2 DoS, but it does not rate-limit. Add Cloudflare Rate Limiting rules, an upstream WAF, or a middleware in front of the auth routes. - Force-sign-out all sessions on password change. The library exposes
queries.deleteUserSessions(userId)if you wire it; the framework doesn't auto-revoke other sessions when a single password is changed. - Audit your
signIncallback. Anything you put inuserOverridesis written to theusersrow. Reserved keys (id,email,name,avatar_url,password_hash,role) are rejected, but if your callback echoes user-controlled data intouserOverridesfor any other column, the user controls that column. - Trust your
X-Forwarded-Host/X-Forwarded-Protosource. The library uses these headers to build absolute redirect URLs in the OAuth flow. Only trust them when the auth server is firewalled behind a known reverse proxy. - Provide a password-reset / magic-link flow. The library doesn't ship one. The
verification_tokenstable is reserved for that purpose (in the CH adapter); SQL adapters don't migrate it by default. - Consider GitHub PKCE limitations. GitHub's OAuth Apps don't enforce the
code_verifieryou send. The library still sends one for forward compatibility but don't rely on it for security against authorization-code interception.
Reporting
Open a GitHub security advisory (preferred) or email the maintainer if you find a vulnerability.
License
MIT
