Skip to content

arraypress/admin-auth

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@arraypress/admin-auth

Layered admin authentication middleware for Hono on edge runtimes (Cloudflare Workers, Deno, Bun, Node.js).

One factory call gives you three middlewares:

  • adminAuth() — resolves "who is this request from?" via four methods, first match wins.
  • requirePermission(perm) — RBAC gate with API-token scope clamping.
  • csrfProtection()X-Requested-With sentinel for cookie-auth mutations.

Zero frontend lock-in. Works with any Kysely-compatible DB (D1, SQLite, Postgres, libSQL) — you supply the query closures, the library owns the auth logic.


Why

Rolling your own admin auth is a minefield: replayable sessions from DB leaks, CSRF on cookie endpoints, dev bypass accidentally active in production, PATs granting more than the user's role allows. This package encodes the pattern that's been battle-tested across multiple @arraypress apps — hardened dev bypass, token hash-at-rest, CSRF sentinel, scope clamping — with a single factory and no hidden global state.

Features

  • Layered auth — session cookie → API token → Cloudflare Access → hardened dev bypass, in that priority.
  • Hash-at-rest tokens — raw values go in the cookie / Authorization header, SHA-256 hashes go in the DB. DB dumps never yield replayable credentials.
  • Three-gate dev bypass — environment flag AND loopback hostname AND explicit opt-in env var. All three must be true; any one false blocks the bypass. Cannot be accidentally activated in production even when a proxy forwards Host: localhost.
  • CSRF protection — session-auth mutations require X-Requested-With; bearer tokens and Cloudflare Access are exempt (no ambient-credential vector).
  • RBAC with scope clamping — tokens can never grant more than the user's role AND more than the token's scope set. A "Manager + read-only token" resolves to read-only.
  • First-class disabled-user handling — treated as unauthenticated rather than 403, avoiding existence leaks.
  • Zero ambient state — everything is injected via the factory. No module-level config, no globals.

Install

npm install @arraypress/admin-auth hono

Peer dependency: hono ^4.0.0.

Runtime dependencies (auto-installed): @arraypress/crypto, @arraypress/api-tokens, @arraypress/rbac.

Quick start

import { createAdminAuth } from '@arraypress/admin-auth';
import type { AdminAuthVariables } from '@arraypress/admin-auth';
import { Hono } from 'hono';

// Your app's permission matrix — see @arraypress/rbac docs for shape.
const PERMISSIONS = {
  'users:read':  10,
  'users:write': 40,
  'settings:write': 50,
};

// Your app's query closures — wire them to whatever DB you use.
const queries = {
  async getAdminSession(db, hash) {
    return db.selectFrom('admin_sessions').selectAll().where('id', '=', hash).executeTakeFirst();
  },
  async getAdminUserById(db, id) {
    return db.selectFrom('admin_users').selectAll().where('id', '=', id).executeTakeFirst();
  },
  async getAdminUserByEmail(db, email) {
    return db.selectFrom('admin_users').selectAll().where('email', '=', email).executeTakeFirst();
  },
  async getApiTokenByHash(db, hash) {
    return db.selectFrom('api_tokens').selectAll().where('token_hash', '=', hash).executeTakeFirst();
  },
  async updateApiTokenLastUsed(db, id) {
    return db.updateTable('api_tokens').set({ last_used_at: new Date().toISOString() }).where('id', '=', id).execute();
  },
};

export const { adminAuth, requirePermission, csrfProtection } = createAdminAuth({
  cookieName: 'sv_admin',
  permissions: PERMISSIONS,
  queries,
  devBypass: {}, // enable with defaults — all three gates required
});

// Mount into your Hono app.
type Env = { Bindings: { DB: D1Database }; Variables: AdminAuthVariables };
const app = new Hono<Env>();
const admin = new Hono<Env>();

admin.use('*', adminAuth(), csrfProtection());
admin.get('/users', requirePermission('users:read'), (c) => {
  const user = c.get('adminUser')!;
  return c.json({ me: user.email });
});

app.route('/admin/api', admin);

Auth methods (priority order)

1. Session cookie

Browser admin UI flow. Reads cookieName, hashes the value with SHA-256, looks up the session via queries.getAdminSession, then resolves the user via queries.getAdminUserById.

Stored value in the DB is the hash, NOT the raw token — so a DB dump never yields replayable sessions. The cookie holds the raw token.

Disabled users fall through (treated as unauthenticated, not 403) to avoid leaking that a disabled account exists.

An invalid / expired / disabled session clears the cookie via deleteCookie before falling through, so the browser stops re-sending dead credentials.

2. API token

Reads Authorization: Bearer <token>. If the value starts with apiTokenPrefix (default 'sc_pat_'), hashes it via @arraypress/api-tokens' hashToken and resolves via queries.getApiTokenByHash.

On success:

  • Fires queries.updateApiTokenLastUsed(db, id) non-blockingly (errors swallowed).
  • Sets c.var.tokenScopes to the parsed scope list — consumed by requirePermission for clamping.

On failure: returns 401 Invalid API token (instead of falling through) — bearers that start with the prefix are unambiguously PAT attempts.

Bearers that don't start with the prefix fall through, so you can layer other bearer-token schemes (e.g. third-party OAuth) before or after.

3. Cloudflare Access

Reads cf-access-authenticated-user-email. Email must already exist in admin_users; the middleware never auto-provisions — that's a deliberate choice so CF Access can't bootstrap admin accounts without explicit consent from an Owner.

Unknown email → 403 Not authorized as admin.

4. Hardened dev bypass

Three compounding gates — all must be true:

  1. c.env[environmentKey] === environmentValue (default: ENVIRONMENT === 'development')
  2. Request hostname is in loopbackHosts (default: localhost / 127.0.0.1 / 0.0.0.0)
  3. c.env[enabledKey] === enabledValue (default: DEV_AUTH_BYPASS === '1')

If any gate fails, the bypass is skipped and the middleware returns 401 Unauthorized.

The three-gate design handles a real attack: a production Worker sitting behind a proxy that passes Host: localhost through would otherwise trigger a loopback-only bypass. The DEV_AUTH_BYPASS=1 opt-in and the ENVIRONMENT === 'development' check ensure production CAN'T accidentally allow this.

The synthetic user attached to the context defaults to an Owner-role (role: 50) user with id: 0. Override via devBypass.syntheticUser if you need different defaults for local testing.

Pass devBypass: undefined (or omit entirely) to disable the bypass feature completely.

requirePermission(permission)

Two-stage gate, chained after adminAuth():

  1. Scope clamp (API tokens only) — if c.var.tokenScopes is set, the permission must either be in the scope list OR the token must hold the wildcard admin scope. Tokens can never grant more than their scope set, even if the user has higher role.
  2. RBAC check — delegates to @arraypress/rbac's hasPermission({ role }, permission, permissions).

Both must pass. Returns 401 Unauthorized if no user is set, 403 Insufficient scope if the scope clamp fails, 403 Forbidden if the role check fails.

csrfProtection()

Only applies to mutating methods (POST, PUT, DELETE, PATCH). Requires X-Requested-With header for session-authed requests.

Exempts API tokens and Cloudflare Access — these don't ride on cookies, so there's no ambient-credential CSRF vector.

The React admin UI should set X-Requested-With: XMLHttpRequest on every mutating fetch. An HTML <form> post can't set custom headers, so cross-origin CSRF via a hidden form is blocked.

Configuration reference

AdminAuthConfig<Db>

Field Default Description
cookieName — (required) Session cookie name. Each app should pick a distinct name to avoid cross-app collisions.
apiTokenPrefix 'sc_pat_' PAT bearer-prefix. Tokens not starting with this are treated as "not a PAT" and fall through to other auth.
permissions — (required) Permission matrix. Passed straight to @arraypress/rbac's hasPermission.
queries — (required) Query closures — see AdminAuthQueries.
getDb (c) => c.get('db') Extract the DB handle from the context. Override if you attach it elsewhere.
devBypass undefined Dev-bypass config. Omit to disable.
loopbackHosts new Set(['localhost', '127.0.0.1', '0.0.0.0']) Hostnames considered "loopback" for the dev bypass.

AdminAuthQueries<Db>

All queries receive db from config.getDb(c). Each should resolve to undefined (not throw) on miss — the middleware handles the miss as a fall-through.

Query Signature Returns
getAdminSession (db, hash: string) Session row where id === hash, or undefined.
getAdminUserById (db, id: number) User row, or undefined.
getAdminUserByEmail (db, email: string) User row (for CF Access flow), or undefined.
getApiTokenByHash (db, hash: string) Token row where token_hash === hash, or undefined.
updateApiTokenLastUsed (db, id: number) Ignored result — called fire-and-forget.

DevBypassConfig

Field Default Description
environmentKey 'ENVIRONMENT' Env key checked for the environment gate.
environmentValue 'development' Env value required to match.
enabledKey 'DEV_AUTH_BYPASS' Env key checked for the explicit opt-in.
enabledValue '1' Env value required to match.
syntheticUser { id: 0, email: 'dev', name: 'Developer', role: 50, disabled: 0 } User attached to context when bypass fires.

Context variables

Extend your Hono Env['Variables'] with AdminAuthVariables:

import type { AdminAuthVariables } from '@arraypress/admin-auth';

interface MyVariables extends AdminAuthVariables {
  db: Kysely<Database>;
}

type Env = { Bindings: { DB: D1Database }; Variables: MyVariables };

The middleware sets:

  • c.var.adminUser — always on success, shape AdminUser.
  • c.var.adminSession — only on cookie auth, shape AdminSession.
  • c.var.tokenScopes — only on API token auth, shape string[].

Patterns

Admin UI fetches

Always set the CSRF header. Easiest via a client wrapper:

// Using @arraypress/api-client:
import { createClient } from '@arraypress/api-client';

const client = createClient('/admin/api', {
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest',
  },
});

Per-resource permission gates

Chain requirePermission after adminAuth per sub-router or per handler:

admin.route('/api/users', userRoutes);

userRoutes.get('/', requirePermission('users:read'), listHandler);
userRoutes.post('/', requirePermission('users:write'), createHandler);

Extending the auth chain

adminAuth() is one middleware — add your own before or after:

admin.use('*',
  myRateLimit(),          // before: rate-limit all admin routes
  adminAuth(),
  myAuditLog(),           // after: log authed requests
  csrfProtection(),
);

Security notes

  • Never log raw session tokens or PATs — only their hashes. console.log(rawToken) is a full-takeover bug waiting to happen via wrangler tail / Logpush.
  • The dev bypass attaches id: 0 — make sure your write paths don't take adminUser.id as a trusted FK without a role check too. In practice, audit log entries etc. will log the synthetic user, which is fine for local dev but a signal if it ever appears in production logs.
  • Disabled users fall through to other auth methods — they can't complete a session login, but a PAT they created before being disabled will also be rejected because the PAT → user lookup fails the !user.disabled check.
  • API tokens bypass CSRF — intentionally, because they don't ride on cookies. Make sure your PAT-issuance flow treats token minting as a sensitive write (it is).
  • CF Access emails are trusted verbatim — if your CF Access policy is misconfigured and allows arbitrary emails, this middleware will happily resolve them to admin users. Check your Access policy before enabling header-based auth.

Testing

The factory pattern makes the middleware easy to unit-test without a real DB — pass stub query closures:

import { createAdminAuth } from '@arraypress/admin-auth';
import { Hono } from 'hono';

const stubQueries = {
  getAdminSession: async () => ({ id: 'stub-hash', user_id: 1 }),
  getAdminUserById: async () => ({ id: 1, email: 'a@b', name: 'A', role: 50, disabled: 0 }),
  getAdminUserByEmail: async () => undefined,
  getApiTokenByHash: async () => undefined,
  updateApiTokenLastUsed: async () => {},
};

const { adminAuth } = createAdminAuth({
  cookieName: 'test_session',
  permissions: {},
  queries: stubQueries,
  getDb: () => ({}), // no-op DB — queries ignore it
});

const app = new Hono();
app.use('*', adminAuth());
app.get('/', (c) => c.json({ user: c.get('adminUser') }));

See tests/ for the full round-trip (session cookie → user resolution, PAT → scope attach, dev bypass gate combinations, CSRF enforcement).

License

MIT

About

Layered Hono middleware for admin auth — session cookie + API token + CF Access + hardened dev bypass.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors