@aswincloud/auth
v0.5.0
Published
Shared, framework-agnostic auth primitives for Cloudflare Workers sites (Web Crypto sessions, signed tokens, password hashing, OAuth) plus an optional React login UI. Each consuming site brings its own D1 database and secrets; nothing links sites together
Maintainers
Readme
@aswincloud/auth
Drop-in auth for Cloudflare Workers sites. Write the login plumbing once, reuse it across every site — each keeping its own database, fully isolated.
Shared, framework-agnostic auth primitives for Cloudflare Workers sites — signed sessions, HMAC tokens, PBKDF2 password hashing, owner allowlists, and OAuth (Google / GitHub / Microsoft) — plus an optional React login UI.
Each consuming site brings its own D1 database and its own secrets. Nothing in
this package links sites together. Security rests on your per-site
SESSION_SECRET, not on this code being private (it's public on purpose).
Install
npm install @aswincloud/authNew site? Each gets its OWN database
This package never shares a database between sites — every site you build gets its own isolated D1, so nothing is linked. Setting one up is one command:
# creates the D1, prints the binding to paste into wrangler.jsonc, applies the schema
npx aswincloud-auth-setup-db mysite-dbOr do it by hand:
npx wrangler d1 create mysite-db # → copy the database_id it prints
# paste into wrangler.jsonc:
# "d1_databases": [{ "binding": "DB", "database_name": "mysite-db",
# "database_id": "<id>" }]
npx wrangler d1 execute mysite-db --remote \
--file=node_modules/@aswincloud/auth/schema.sqlschema.sql creates users, oauth_identities, and otp_codes — all
IF NOT EXISTS, so re-running is safe, and you add your own app tables
alongside them in your own migrations. Then set per-site secrets
(SESSION_SECRET, OAuth client ids).
Two entry points
// Core — pure Web Crypto, zero deps, runs on any Worker.
import {
hashPassword, verifyPassword,
signToken, verifyToken,
createSessionCookie, readSession, clearSessionCookie,
isOwner,
} from "@aswincloud/auth";
// Optional React login UI — only multi-user sites need this.
import { LoginPage } from "@aswincloud/auth/react";Owner-only Worker sites import only the core and never pull React in.
Owner-only site (status / console style)
const session = { secret: env.SESSION_SECRET, cookieName: "sess" };
// after verifying the user (e.g. Google OAuth) and checking the allowlist:
if (!isOwner(env.OWNER_EMAILS, email)) return new Response("forbidden", { status: 403 });
const setCookie = await createSessionCookie(session, email);
// gate a request:
const who = await readSession(session, req);
if (!who) return Response.redirect(new URL("/api/auth/login", req.url), 302);Multi-user site (shiptrack style)
const hash = await hashPassword(plainPassword); // on signup -> store in your D1
const ok = await verifyPassword(plainPassword, hash); // on login
const setCookie = await createSessionCookie(session, userId);OAuth — Google, GitHub, Microsoft
One config drives all three providers. Configure only the ones you have
credentials for; configuredProviders() tells the UI which buttons to show.
import {
startOAuth, handleOAuthCallback, configuredProviders, isOwner,
createSessionCookie,
} from "@aswincloud/auth";
const origin = new URL(req.url).origin;
const oauth = {
clients: {
google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET },
github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET },
microsoft: { clientId: env.MICROSOFT_CLIENT_ID, clientSecret: env.MICROSOFT_CLIENT_SECRET,
tenantId: env.MICROSOFT_TENANT_ID }, // optional, defaults to "common"
},
stateSecret: env.STATE_SECRET,
redirectUri: (p) => `${origin}/api/auth/oauth/${p}/callback`,
};
// GET /api/auth/oauth/:provider/start
return startOAuth(oauth, provider); // 302 to provider + signed CSRF state cookie
// GET /api/auth/oauth/:provider/callback
const r = await handleOAuthCallback(oauth, provider, req);
if (!r.ok) return Response.redirect(`${origin}/login?oauth_error=${r.error}`, 302);
// r.user = { providerUserId, email, emailVerified } — verified by the provider.
// Owner-only site: gate on the allowlist.
if (!isOwner(env.OWNER_EMAILS, r.user.email)) return new Response("forbidden", { status: 403 });
const setCookie = await createSessionCookie(session, r.user.email);
// Multi-user site: look up / create the user in YOUR D1, then issue the session.
// The package never touches a database — each site keeps its own, fully isolated.Who can sign in — access policy
emailAllowed decides whether a provider-verified email may sign in, in one of
three modes a site picks (e.g. from an ACCESS_MODE env):
import { emailAllowed } from "@aswincloud/auth";
// "public" → any authenticated email
// "domain" → only emails in ACCESS_DOMAINS (owners, if any, also allowed)
// "owners" → only emails in the OWNER_EMAILS allowlist (strict "only me")
if (!emailAllowed({
mode: env.ACCESS_MODE, // "public" | "domain" | "owners"
email: r.user.email,
owners: env.OWNER_EMAILS, // comma-separated
domains: env.ACCESS_DOMAINS, // comma-separated, e.g. "aswincloud.com"
})) return new Response("forbidden", { status: 403 });parseAccessMode(raw) normalizes the env string (unknown ⇒ "owners", the safe
default). Pure and zero-dep.
Central OAuth broker (relay)
For many sites that shouldn't each register their own OAuth client, a central
broker authenticates with the provider once and relays the verified email back
to each site, signed with a per-site shared secret (RELAY_SECRET):
import { signRelay, verifyRelay } from "@aswincloud/auth";
// On the broker, after handleOAuthCallback succeeds:
const relay = await signRelay(site.relaySecret, { email, provider, nonce, providerUserId });
// → 302 back to the site with ?relay=<relay>
// providerUserId is optional: owner-only sites can omit it (token shape is then
// unchanged); multi-user sites link accounts on (provider, providerUserId).
// On the site's callback:
const claims = await verifyRelay(env.RELAY_SECRET, relayToken); // {email,provider,nonce,providerUserId?} | null
if (!claims || claims.nonce !== expectedNonce) return forbidden();Short-lived (2 min) and purpose-bound; the nonce (echoed from the site's start
request) defends against replay. The broker Worker itself lives outside this
package; these are just the shared sign/verify helpers.
Provider notes: Google & Microsoft return verified emails; GitHub falls back to
/user/emails for the primary verified address. Microsoft uses your tenant in
the URL. CSRF is the HMAC-signed state cookie (checked against the returned
state param). Zero-dependency, pure fetch + Web Crypto.
SSO buttons (React)
import { SsoButtons } from "@aswincloud/auth/react";
// providers usually comes from configuredProviders(oauth) on the server
<SsoButtons providers={["google", "github", "microsoft"]} />Renders <a href="/api/auth/oauth/{provider}/start"> buttons with real
provider logos. Drop it into <LoginPage ssoSlot={...} /> to get SSO + password
on one screen.
React login UI
import { LoginPage } from "@aswincloud/auth/react";
<LoginPage
action="/api/auth/login"
onSuccess={() => location.assign("/dashboard")}
signupHref="/signup"
forgotHref="/forgot"
/>;Framework-agnostic — no next/* imports, so it runs on Next, Vite, or plain
React. Self-contained default styling; every element overridable via styles.
Navigation is yours via onSuccess. Drop SSO buttons in through ssoSlot.
User-management flows — @aswincloud/auth/d1
A separate, opt-in entry point with DB-backed flows for multi-user sites:
signup + OTP verify, password reset, change password/username, self-service
email change, and account removal. The core stays zero-dep — only import /d1
where you need it.
Flows are functions, not HTTP handlers: each takes your D1 binding + plain
values and returns { ok: true, … } | { ok: false, error: "<code>" } — never
throws. You own the routing, the session cookie, and the email provider.
import {
signup, verifyOtp, requestPasswordReset, resetPassword,
changePassword, requestEmailChange, confirmEmailChange, removeUser,
type EmailSender,
} from "@aswincloud/auth/d1";
import { createSessionCookie } from "@aswincloud/auth";
// You inject the email provider — the package never hardcodes one:
const sendEmail: EmailSender = async ({ to, subject, html, text }) => {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ from: env.MAIL_FROM, to, subject, html, text }),
});
};
// POST /api/auth/forgot
await requestPasswordReset(env.DB, {
email, secret: env.TOKEN_SECRET, sendEmail, appUrl: origin,
}); // always { ok: true } — no account enumeration
// POST /api/auth/reset
const r = await resetPassword(env.DB, { token, newPassword, secret: env.TOKEN_SECRET });
if (!r.ok) return json({ error: r.error }, 400);
// POST /api/auth/verify → issue the session yourself on success
const v = await verifyOtp(env.DB, { email, code, secret: env.TOKEN_SECRET });
if (v.ok) setCookie(await createSessionCookie(session, v.userId));Pure email templates ship too (passwordResetEmail, verifyEmail, otpEmail,
emailChangeEmail, accountDeletedEmail) returning { subject, html, text } —
use them or write your own. Matching React pages: ForgotPasswordPage,
ResetPasswordPage, VerifyEmailPage from @aswincloud/auth/react.
Branded emails — to keep your own look instead of the built-in template,
pass a render* override to the sending flows; it gets the dynamic bits and
returns { subject, html, text }:
await signup(env.DB, { email, password, secret, sendEmail,
renderOtp: ({ code, ttlMinutes }) => myOtpEmail({ code, ttlMinutes }) });
// likewise: requestPasswordReset → renderReset({ resetUrl, ttlHours }),
// requestEmailChange → renderEmailChange({ confirmUrl, newEmail, ttlHours }).When omitted, the built-in template is used (app name via appName).
Schema (0.2.0): the users table gained name + is_admin. New sites get
them from schema.sql. Existing DBs (created before 0.2.0) — run once:
ALTER TABLE users ADD COLUMN name TEXT;
ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0;Sites using only the core primitives (no /d1) need no migration.
License
MIT
