npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ptner/worker-auth

v0.3.0

Published

Google OAuth, email magic-link and administrator token/password sign-in for Cloudflare Workers, Node and Deno. No UI, no framework.

Downloads

932

Readme

@ptner/worker-auth

Google OAuth, email magic-link/code and administrator token/password sign-in for Cloudflare Workers, Node and Deno.

No UI, no framework, no rendering. The library owns sessions, user/admin roles and sign-in flows; storage and email delivery are adapters you supply. Web Crypto and the Fetch API only, so the same code runs on Workers, Node 18+, Deno and Bun.

Quick start

import { createAuth, cloudflareEmail } from '@ptner/worker-auth';
import { d1Store } from '@ptner/worker-auth/stores/d1';

export default {
  async fetch(request, env, ctx) {
    const auth = createAuth({
      store: d1Store(env.DB),
      appOrigin: env.APP_ORIGIN,            // https://example.com
      appName: 'Example',
      admin: { token: env.ADMIN_TOKEN },
      google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
      email: { send: cloudflareEmail(env.EMAIL, { from: '[email protected]', fromName: 'Example' }) },
    });

    // Handles /api/auth/* and returns null for everything else.
    const handled = await auth.handle(request);
    if (handled) return handled;

    const user = await auth.currentUser(request);
    if (!user) return new Response('Sign in first', { status: 401 });
    return Response.json({ hello: user.username || user.email });
  },
};

Apply schema.sql to your D1 database first.

Email links and codes

With a bundled store, each login email contains both a clickable link and a six-digit code. They share one login record and expire together (15 minutes by default). Successfully using either consumes both, including concurrent submissions. Both methods create the same kind of user session.

The existing link callback needs no frontend changes. To accept codes, retain the challengeId returned when requesting the email and submit it with the code from that email:

const started = await fetch('/api/auth/email/start', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: '[email protected]' }),
});
const pending = await started.json();
if (!started.ok) throw new Error(pending.error);
// Keep pending.challengeId with this login form (sessionStorage if needed
// across reloads). A null challengeId means the server offers links only.

// When the user submits the code; use a string to preserve leading zeros.
const verified = await fetch('/api/auth/email/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ challengeId: pending.challengeId, code: '012345' }),
});
const result = await verified.json();
if (!verified.ok) throw new Error(result.error);
// { ok: true, user }; the response also sets the session cookie.
// The application can now navigate to its chosen local page.

The challenge is independent of the link token and cannot authenticate on its own. Enter the code on the page that requested it; use the link to sign in on another device. Normal API responses never include the code or usable link. For development only, email.devEcho: true returns devCode and devLink, and onDevLink receives { to, link, code, challengeId }.

Each challenge allows at most emailCodeMaxAttempts well-formed guesses (default 5, configurable from 1 to 20), enforced atomically. After exhausting it, request another email or use the existing link. Resending creates an independent challenge; it does not reset earlier attempt counts or revoke earlier emails. The existing per-address email sending limit applies.

Set email: { send, code: false } to keep a link-only flow. Custom email renderers receive code as well as link; include both to offer both methods. An older custom store without consumeLoginCode remains link-only by default; explicitly requesting email.code: true requires that method.

Existing D1 databases must apply migrations/0002_email_codes.sql once before enabling codes. Outstanding links remain valid. Rename the table in the migration if your adapter uses a custom login-token table. Fresh databases use the updated schema.sql only. Memory and storage-lib need no schema migration; call store.ensureIndexes() at startup for storage-lib's new challenge index.

Administrator account and host integration contract

worker-auth owns authentication, roles, sessions and JSON interfaces. It never renders frontend pages or components. Each host application provides its own entry page/form; /admin/login is the recommended host route, not a library route. The same API remains usable even when the host has no administrator UI yet.

Use a separate ADMIN_TOKEN secret for each application. Pass it explicitly; the library does not read process, Worker or Deno environment variables itself:

const auth = createAuth({
  store,
  appOrigin,
  admin: { token: env.ADMIN_TOKEN }, // Workers
  // Node: admin: { token: process.env.ADMIN_TOKEN }
  // Deno: admin: { token: Deno.env.get('ADMIN_TOKEN') }
});

Generate at least 32 random bytes, encoded as hex or base64url. Nonblank admin.token values must be 32–1024 characters; there is no default credential. Unset, null, empty and whitespace-only values disable administrator login and invalidate authentication through existing initial-admin sessions. Nonblank credentials are compared exactly, without trimming or lowercasing.

JSON interface

POST /api/auth/admin/login (basePath is configurable):

const response = await fetch('/api/auth/admin/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token }),
});
// 200: { ok: true, user: { id, username: 'admin', role: 'admin', email: null, ... } }
// Session is set with the normal HttpOnly cookie. No credential is returned.

Typed contracts are available without a UI dependency:

import type { AdminOptions, AdminLoginRequest, AdminLoginResponse } from '@ptner/worker-auth/contracts';

The initial administrator has stable ID worker-auth:admin, username admin, and role admin. First login creates this row atomically and invokes onUserCreated once. An existing revoked role is never promoted by logging in. Neither the credential nor its hash is written to user, session or attempt rows. Sessions store only hashes of independent random session tokens.

The endpoint accepts JSON bodies up to 8 KiB. It does not accept tokens in URLs, email addresses, ordinary usernames, form submissions or Authorization headers. GET requests do not render a page. Wrong credentials return 401, revoked roles or cross-origin browser requests return 403, oversized bodies 413, non-JSON requests 415, attempt limits 429 and disabled configuration 503. All errors are JSON { error }; credentials never enter these messages.

Responsibilities of host applications and future integrations

  • Keep ADMIN_TOKEN only in server secrets (.dev.vars.secrets/.dev.vars locally, Worker secrets in production). Never put it in frontend environment variables, page props, logs, analytics, storage or query strings.
  • A host-owned /admin/login page may use one type="password" input and POST { token }. Clear the input after login, then refresh the session or navigate to a fixed same-origin destination. No library-supplied HTML/React is required.
  • Exempt only the host's login page from admin guards. Protect every admin page and endpoint using auth.requireAdmin(request), never a frontend flag, user label, email pattern or the presence of an API Key.
  • Use HTTPS in production, keep browser Origin/CSRF checks, and do not disable the shared attempt limiter. Hosts may require Origin even for CLI clients.
  • Discovery uses auth.providers().admin or GET /api/auth/me. This reports a boolean only, never the token or a credential-derived identifier.
  • Rotating the secret changes accepted logins after recreating createAuth. Existing sessions keep their normal lifetime; explicitly call auth.signOutEverywhere('worker-auth:admin') to revoke them during rotation.

adminLoginAttemptsPerHour defaults to 10, shared across token and password attempts, including successes and unknown usernames. It is persisted across instances. Counting and recording are separate storage operations, so the existing limit is best-effort under concurrency; an edge limiter can supplement it.

Compatibility

Existing admin: { password: env.ADMIN_PASSWORD } configurations continue to work. Configure either admin.token or admin.password, not both. The old JSON { username: 'admin', password } and the new { token } both authenticate against the single configured credential and use the same identity/session machinery. Mixed payload shapes are rejected. There is no [email protected] special case: email and Google identities always remain separate from the reserved admin.

Existing D1 databases

Apply migrations/0001_admin_role.sql once before using administrator login on an existing database. It adds username and role, defaults existing users to user, and preserves sessions. Adjust the table name if you configured a custom users table. Fresh databases should apply the updated schema.sql only. Memory and storage-lib adapters need no schema migration.

Routes

Mounted under basePath (default /api/auth):

| Route | Method | Result | |---|---|---| | /me | GET | { user, providers: { google, email, admin } } | | /admin/login | POST | { ok: true, user } + session cookie — body { token } (legacy { username, password } supported) | | /email/start | POST | { ok: true, challengeId } — body { email, next? } | | /email/verify | POST | { ok: true, user } + session cookie — body { challengeId, code } | | /email/callback?token= | GET | 302 + session cookie | | /google/start?next= | GET | 302 to Google + state cookie | | /google/callback | GET | 302 + session cookie | | /logout | POST | { ok: true } + cleared cookie |

Browser-facing routes redirect to loginPath?error=… on failure; JSON routes return { error } with a status.

Options

| Option | Default | | |---|---|---| | store | — | required, see below | | appOrigin | — | required, e.g. https://example.com | | basePath | /api/auth | where the routes are mounted | | loginPath | / | where failed callbacks land | | appName | App | used in the default email copy | | google | — | { clientId, clientSecret, scope?, prompt? } | | admin.token | — | explicit ADMIN_TOKEN server secret; 32–1024 characters; disabled when unset/blank | | admin.password | — | legacy alternative to admin.token; configure only one | | email.send | — | async ({ to, subject, html, text }) => {} | | email.render | built-in | ({ link, code, email, appName, ttlMinutes }) => ({ subject, html, text }) | | email.code | enabled for stores with consumeLoginCode | include a six-digit code; false for links only | | email.devEcho | false | return the link and code instead of sending them | | sessionCookieName | wa_session | | | sessionTtlMs | 30 days | | | loginTokenTtlMs | 15 min | | | loginTokensPerHour | 5 | per email address | | emailCodeMaxAttempts | 5 | attempts per challenge, integer 1–20; does not disable its link | | adminLoginAttemptsPerHour | 10 | total administrator attempts per hour, integer 1–500 | | onUserCreated | — | async user => {}, seed your own per-user rows | | onError | — | called for 5xx |

API

handle is optional — every step is available directly:

auth.currentUser(request)       // public user shape, or null
auth.currentUserRow(request)    // the raw storage row, or null
auth.requireUser(request)       // throws AuthError(401)
auth.requireAdmin(request)      // throws AuthError(401) or AuthError(403)
auth.startEmailLogin(email)     // { email, link, challengeId, sent }; code also returned in devEcho
auth.completeEmailLogin(request, token)
auth.completeEmailCodeLogin(request, { challengeId, code }) // { user: raw row, cookie }
auth.completeGoogleLogin(request)
auth.completeAdminLogin(request, { token }) // { user: raw row, cookie }; legacy credentials also supported
auth.issueSession(request, userId)   // e.g. after your own signup flow
auth.signOut(request)                // returns a clearing Set-Cookie value
auth.signOutEverywhere(userId)
auth.purgeExpired()                  // from ctx.waitUntil or a cron
auth.providers()

Storage adapters

Three ship with the package:

| Adapter | Import | | |---|---|---| | d1Store(db, { users, sessions, loginTokens }) | ./stores/d1 | Cloudflare D1 | | storageLibStore(store, { … }) | ./stores/storage-lib | anything @ptner/storage-lib supports — SQLite, MongoDB, D1, Bunny | | memoryStore() | ./stores/memory | tests |

Any object implementing the same store interface works — Postgres via Hyperdrive, Durable Object storage, KV, whatever you already run. findUserByGoogleSub is optional: a store written against 0.1.0 keeps working, it just falls back to matching on the address alone.

Custom stores need ensureAdminUser({ now }) only when administrator login is enabled. It atomically creates the reserved administrator row if absent and returns { user, created }; it must preserve any existing row and its role. See the shipped adapters for the row shape. createUser should assign role user by default, and touchUser must preserve role and username.

For email codes, createLoginToken also accepts nullable codeChallengeHash and codeHash. Store them as code_challenge_hash and code_hash, and initialize code_attempts to zero on the same record as the link's token_hash and consumed_at. Never persist the raw challenge or code. Implement:

store.consumeLoginCode({ challengeHash, codeHash, now, maxAttempts })
// Resolves to the email on success, otherwise null.

Find the record by code_challenge_hash. It must be unconsumed, unexpired, have a code verifier, and have fewer than maxAttempts recorded guesses. Atomically increment code_attempts and, if code_hash matches, set consumed_at and return the email. A wrong guess returns null and leaves the link usable. The eligibility check, increment and optional consumption must be one atomic operation (conditional update, transaction, or revision-based CAS), sharing the consumption state with consumeLoginToken. Independent read-then-write operations are unsafe.

import { createStorage } from '@ptner/storage-lib';
import { storageLibStore } from '@ptner/worker-auth/stores/storage-lib';

const storage = await createStorage({ storeBackend: 'sqlite', databasePath: './auth.db' });
const store = storageLibStore(storage.store);
await store.ensureIndexes();   // once at start-up; a no-op where unsupported

storage-lib is a key-document store, so the two uniqueness rules the SQL schema states as UNIQUE columns — one account per address, one per Google subject — are enforced by the adapter instead, each as a create-only compare-and-set against its own tiny collection. A duplicate therefore loses a write rather than producing a second account. User lookups remain point reads; email-code challenges use an indexed query followed by a revision-based CAS on the shared login record.

Security properties

  • Session and magic-link tokens are random 256-bit values; only their SHA-256 is stored, so a database dump yields nothing usable.
  • Cookies are HttpOnly (an XSS cannot read them), SameSite=Lax (cross-site POSTs carry no session), and host-only — no Domain attribute, so sibling subdomains never receive them. Secure is added on https and omitted on http so local development still works.
  • Links and codes share a single-use record. Consumption and code-attempt counting use conditional updates or CAS, so concurrent requests cannot reuse either credential or bypass a challenge's attempt limit.
  • Six-digit codes use Web Crypto randomness. Code verifiers bind the code to a separate random 256-bit challenge held by the browser; only their hashes are stored. A database dump alone does not reveal a reusable challenge or permit guessing its short code. Code submissions with a cross-origin Origin header are rejected.
  • OAuth state round-trips in a short-lived cookie and is compared on return.
  • next is restricted to same-site paths, so neither flow becomes an open redirect.
  • Sign-in emails are rate limited per address.
  • The initial administrator's token/password stays in server configuration and is compared using Web Crypto HMAC verification. Only its random session token's hash is persisted. Administrator login cannot be substituted with email/OAuth.
  • The Google ID token's iss, aud and exp are checked, and a nonce round-trips in the state cookie, so a token minted for another application or another attempt is refused. Google delivers the token directly to the token endpoint over TLS, which is why OIDC permits skipping the signature — the claims are checked anyway, because "it is fine because of how we received it" stops being true the moment a token reaches that code from elsewhere.
  • email_verified must be true, not merely "not false". Accounts are matched to people by address, so an unconfirmed one would let anybody able to create a Google account claiming [email protected] sign in as that owner.
  • A Google account is matched by sub before address. The subject is the stable identifier: an address can be renamed, or reassigned to a different person. When an address already belongs to a different subject the sign-in is refused rather than merged.

SameSite=Lax still allows cross-site GET navigations, so it is not CSRF-proof on its own. Keep state-changing routes on POST/PATCH/DELETE and check the Origin header on them.

Testing

npm test --workspace @ptner/worker-auth

Runs the flows against memoryStore(), storage-lib with temporary SQLite databases, and the D1 adapter through a SQLite-backed D1 test interface. No Workers account or network is required.

Upgrading to 0.2.0

Behaviour changes, all of them refusals of things that previously succeeded:

  • A renamed Google account is now one account. 0.1.0 looked users up by address only, so a provider-side rename missed and inserted a second row with the same google_sub — a silent duplicate on stores without the constraint, and a UNIQUE violation surfacing as a 500 on D1. Accounts are now found by subject first, and the address on the row follows the provider.
  • email_verified must be true. An absent claim used to pass.
  • iss, aud, exp and a nonce are checked on the ID token.
  • A second Google account claiming a linked address is refused with a 409 instead of being signed in as the existing user.

Custom stores keep working. findUserByGoogleSub is optional and touchUser gains an email field it may ignore; both shipped stores implement them.