Skip to content

Repository files navigation

@aswincloud/auth

npmCIlicense: MITzero deps

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/auth

New 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-db

Or 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.sql

schema.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)

constsession={secret: env.SESSION_SECRET,cookieName: "sess"};// after verifying the user (e.g. Google OAuth) and checking the allowlist:if(!isOwner(env.OWNER_EMAILS,email))returnnewResponse("forbidden",{status: 403});constsetCookie=awaitcreateSessionCookie(session,email);// gate a request:constwho=awaitreadSession(session,req);if(!who)returnResponse.redirect(newURL("/api/auth/login",req.url),302);

Multi-user site (shiptrack style)

consthash=awaithashPassword(plainPassword);// on signup -> store in your D1constok=awaitverifyPassword(plainPassword,hash);// on loginconstsetCookie=awaitcreateSessionCookie(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";constorigin=newURL(req.url).origin;constoauth={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/startreturnstartOAuth(oauth,provider);// 302 to provider + signed CSRF state cookie// GET /api/auth/oauth/:provider/callbackconstr=awaithandleOAuthCallback(oauth,provider,req);if(!r.ok)returnResponse.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))returnnewResponse("forbidden",{status: 403});constsetCookie=awaitcreateSessionCookie(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-separateddomains: env.ACCESS_DOMAINS,// comma-separated, e.g. "aswincloud.com"}))returnnewResponse("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:constrelay=awaitsignRelay(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:constclaims=awaitverifyRelay(env.RELAY_SECRET,relayToken);// {email,provider,nonce,providerUserId?} | nullif(!claims||claims.nonce!==expectedNonce)returnforbidden();

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<SsoButtonsproviders={["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";<LoginPageaction="/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,typeEmailSender,}from"@aswincloud/auth/d1";import{createSessionCookie}from"@aswincloud/auth";// You inject the email provider — the package never hardcodes one:constsendEmail: EmailSender=async({ to, subject, html, text })=>{awaitfetch("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/forgotawaitrequestPasswordReset(env.DB,{
email,secret: env.TOKEN_SECRET, sendEmail,appUrl: origin,});// always { ok: true } — no account enumeration// POST /api/auth/resetconstr=awaitresetPassword(env.DB,{ token, newPassword,secret: env.TOKEN_SECRET});if(!r.ok)returnjson({error: r.error},400);// POST /api/auth/verify → issue the session yourself on successconstv=awaitverifyOtp(env.DB,{ email, code,secret: env.TOKEN_SECRET});if(v.ok)setCookie(awaitcreateSessionCookie(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 }:

awaitsignup(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:

ALTERTABLE users ADD COLUMN name TEXT;
ALTERTABLE users ADD COLUMN is_admin INTEGERNOT NULL DEFAULT 0;

Sites using only the core primitives (no /d1) need no migration.

License

MIT

About

Shared, framework-agnostic auth primitives for Cloudflare Workers sites — signed sessions, HMAC tokens, PBKDF2 passwords, owner allowlists, OAuth — plus an optional React login UI. Published as @aswincloud/auth.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages