@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
Maintainers
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_TOKENonly in server secrets (.dev.vars.secrets/.dev.varslocally, Worker secrets in production). Never put it in frontend environment variables, page props, logs, analytics, storage or query strings. - A host-owned
/admin/loginpage may use onetype="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().adminorGET /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 callauth.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 unsupportedstorage-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 — noDomainattribute, so sibling subdomains never receive them.Secureis 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
Originheader are rejected. - OAuth
stateround-trips in a short-lived cookie and is compared on return. nextis 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,audandexpare checked, and anonceround-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_verifiedmust betrue, not merely "notfalse". 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
subbefore 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-authRuns 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 aUNIQUEviolation surfacing as a 500 on D1. Accounts are now found by subject first, and the address on the row follows the provider. email_verifiedmust betrue. An absent claim used to pass.iss,aud,expand anonceare 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.
