@codefusion-cc/sign-in
v0.1.1
Published
Signing people in to a Cloudflare Worker app by several methods and keeping their sessions on D1: accounts with several identities and a rule for joining them by confirmed address, a random token in a __Host- cookie with only its hash stored, sliding and
Maintainers
Readme
@codefusion-cc/sign-in
Sign-in for a Worker app with a D1 database: accounts, the identities that prove them, sessions in a __Host-
cookie, the routes a page calls, and the methods people sign in with. Which methods an app offers is data: each one
is switched on and off in settings, without a deploy. Google is here (./google); a password, a code by mail,
passkeys, Apple and Facebook are later entries on the same core, which imports no provider.
WebCrypto, fetch and D1 only. It builds on @codefusion-cc/workers-http (cookies, the same-origin check, JSON
bodies, errors), @codefusion-cc/workers-crypto (random tokens) and, for ./google,
@codefusion-cc/google-sign-in.
npm install @codefusion-cc/sign-in| Entry | Runs in | What it holds |
| --- | --- | --- |
| . | Worker | createSignIn (the routes), createSessions (accounts and sessions), the method contract, safeReturnPath |
| ./google | Worker | google(), the Google method |
| ./browser | page | createSessionStore: who is signed in, which methods are offered, the actions |
| ./google/browser | page | googleSignIn(store): the trip to Google and back |
| ./react | page | useSession, useSignInMethods, GoogleButton |
| ./migrations | build, tests | the tables as SQL (signInMigrations), and the codefusion-sign-in-migrations command |
| ./testing | tests | a browser with a cookie jar, the Google method against a fake Google (testGoogle) and a sign-in with it through the real routes, a session without a provider |
In the Worker
import { createSignIn } from '@codefusion-cc/sign-in'
import { google } from '@codefusion-cc/sign-in/google'
const signIn = createSignIn({
db: env.DB,
methods: [google({ clientId: env.GOOGLE_CLIENT_ID })],
rateLimiter: env.AUTH_LIMITER,
onSignIn: ({ accountId, email, emailVerified }) => linkGuestOrders(env, accountId, emailVerified ? email : null),
me: session => ({ role: roleOf(session.account) }),
})
// In the router: the sign-in routes, or null for any other path.
const answer = await signIn.handle(request)
if (answer) return answer
// In the app's own routes.
const session = await signIn.sessions.read(request) // null when nobody is signed in
const staff = await signIn.sessions.require(request, { signedInWithinSeconds: 900, methods: ['google', 'passkey'] })createSignIn makes no request by itself, so making one per request costs nothing. Everything is a parameter: the
cookie names (cookieName, challengeCookieName), the lifetimes (slidingSeconds, absoluteSeconds,
renewAfterSeconds), the routes' place (basePath, default /api/auth), the tables' prefix (tablePrefix).
Routes
| Route | What it does |
| --- | --- |
| GET <base>/methods | { methods: ['google', …] }: the ids offered now, in the order the app registered them. No secrets; cache-control: public, max-age=30. While no registered method is configured it is { methods: [] } without a read of D1, so an app with no client id or no tables yet answers; with one configured, a D1 failure is 503 sessions_unavailable, never an empty list. |
| GET <base>/me | Who is signed in: { account, method, signedInAt, expiresAt } plus the fields of the me hook, or { account: null }. Never clears a cookie. |
| POST <base>/logout | Ends this browser's session. |
| POST <base>/account/delete | Runs onDeleteAccount, then deletes the account, its identities and every session of it. |
| POST <base>/<method>/start | Starts a sign-in: sets the challenge cookie and answers what the method's begin returns. |
| POST <base>/<method> | Completes it: the method verifies, the challenge is used up, the session starts, the answer is the me body. |
| <base>/<method>/… | The method's own routes (Google's callback page). |
Every POST must come from the app's own origin (requireSameOrigin, plus allowedOrigins) with a JSON body, which
a form on another site cannot send; a GET on a write route is a 405 and writes nothing. That holds for a method's
own routes too, by construction: the core checks the request before the method sees it and hands it the parsed body,
unless the method opened that one path (openPosts, below). Every answer but the list of methods is no-store. A refusal is JSON with a code, never a sentence to show: not_signed_in (401),
recent_sign_in_required (403), method_disabled (403), method_not_configured (503), sign_in_refused (401, with
problem), email_unverified (401), sign_in_unavailable (503, try again), sessions_unavailable (503, D1 failed),
and those of workers-http (cross_origin, unsupported_media_type, rate_limited). The app words them.
Sessions
- The token is 32 random bytes, in the cookie
__Host-session(HttpOnly,Secure,SameSite=Lax,Path=/, noDomain). D1 holds only its SHA-256, so a copy of the database signs nobody in. - A sign-in always makes a new token and ends the session the browser came with; a cookie the browser brought is never adopted.
- A session ends
slidingSecondsafter its last use (default 30 days) andabsoluteSecondsafter it started at the latest (default 180 days, the cookie'sMax-Age). Reading is one statement; the new expiry is written at most once perrenewAfterSeconds(default a day). - A session records the method that proved it and when:
session.method,session.authenticatedAt.signedInWithin(session, seconds, { methods })andsessions.require(request, { signedInWithinSeconds, methods })ask for a recent sign-in, by some methods only when the app says so, before something sensitive. - Expired sessions and spent challenges are removed a few at a time by each sign-in. No cron.
- What it costs: a sign-in is one D1 round trip of ten statements (one transaction: all of it or nothing), after
one statement that spends the challenge; a read is one statement, and a second one once per
renewAfterSecondsto write the new expiry. The switches are read once per 30 seconds per isolate. No statement runs per identity. - Errors: what a visitor or D1 can cause is a result or an
HttpErrorof workers-http (D1 out of reach is a 503); what only a programming mistake causes (a cookie name without__Host-, a lifetime of 500 days, an identity without a subject) throws aTypeErrororRangeError. Each doc comment says which.
Accounts and identities
An account has one or more identities, each a kind and a subject: google and Google's sub, email and the
normalized address, passkey and the credential id. An identity may carry an address and says whether it is
confirmed. Addresses are compared normalized (normalizeEmail): NFC, trimmed, lower case, an international domain
in its ASCII form. Dots and +tags are not folded (only some providers ignore them) and look-alike letters of
another script stay different, so two people are never one.
Whose account a sign-in is, decided in one D1 batch, so two sign-ins at the same moment make one account:
- The identity signed in before: its account, also when its address changed at the provider (nothing is merged).
- Its address is confirmed and an account holds the same address confirmed: that account, with this identity added (Google joins the account made with a password, and the other way round; a second Google account with the same verified address joins too). When two accounts hold it, the one that held it first.
- Otherwise a new account. An identity with no address, or with one that is not confirmed, joins nothing.
A confirmed sign-in removes every unconfirmed claim on its address, in other accounts too, and ends the sessions
those claims started. It never joins the account such a claim made: whoever registered an address they do not own
chose what is in that account, and would hand it to the owner. So the owner gets an account of their own, the
claimant keeps no identity and no session, and claiming an address again and again blocks nobody. An account left
with no identity keeps its row (for what the app stores under its id) and loses every session; nothing leads to it
again. SELECT id FROM accounts WHERE id NOT IN (SELECT account_id FROM identities) lists them for a cleanup.
Joining by address means that whoever controls a confirmed address can enter the account, as with a password reset
by mail. What must not follow an address (a staff role) is pinned to the identity instead: onSignIn gets
identity (google and the sub), so another Google account with the same address is told apart.
Hooks
onSignIn(event)runs after each sign-in, before the answer:accountId,emailandemailVerified(link nothing to an address that is not verified),method,identity,created,session,request. What it throws fails the sign-in: the new session ends and an account that sign-in made is removed, so the next attempt iscreatedagain. The session the browser came with has ended by then.me(session, request)adds fields to whatmeanswers; it cannot replace the core's. The core keeps nothing between requests, so one account's fields never reach another.onDeleteAccount(session, request)runs before the account goes; throwing keeps it. Ask for a recent sign-in there if the app wants one (signedInWithin), and remove what the app keeps for the account.rateLimitercounts the attempts to complete a sign-in per network, before anything is verified. Starting a sign-in is not counted: it writes nothing (the challenge lives in the cookie), and a refused attempt writes nothing either, so a flood grows no table. A method with routes of its own limits them itself (a password: per address).
Methods
A method turns a completed sign-in into one verified identity, or a refusal code:
interface SignInMethod {
id: string // the session's `method`, and the path its routes are under
configured: boolean // false while its client id or secret is missing
begin?(input): Record<string, unknown> // what the page needs to leave for the provider
complete?(input): Promise<MethodOutcome> // { ok: true, identity } | { ok: false, code, status?, retry?, data? }
routes?(request, context): Promise<Response | null> // anything else under <base>/<id>/
openPosts?: string[] // paths under <base>/<id>/ whose POSTs the core lets through unchecked
}The core does for every method what must not differ between them:
- The challenge.
startputs a random secret into the cookie__Host-sign-in(ten minutes) and hands the method its SHA-256 aschallenge: the OpenID Connectnonce, the OAuthstate, or both. The page never sees the secret, so what travels to the provider and back is useless in another browser.completegets the challenge of the cookie this browser presents; it is bound to the method that started it, expires by the server's clock, and works once (a table of spent challenges), also for two requests at the same moment. Every answer but "try again" takes the cookie away; a refusal writes nothing, so a method whose proof can be guessed counts attempts itself. - The checks on a method's own routes. A GET or HEAD passes and must change nothing. Anything else must be a
POST from this origin with a JSON body, which the core reads into
context.body; a method cannot forget it. A path a method names inopenPostsis let through as it is, for an answer that is no JSON or that another site must be able to post (Google's callback page hands the token on as a form; Apple posts its answer). - Who may start a session.
context.signInworks only on a request bound to this browser: a write the core checked, or one whose challengecontext.takeChallenge()just took. On a plain GET or an open POST it answers 403, so a link or a form another site makes the browser follow signs nobody in as someone else. - The return path.
begingetsreturnPathonly when it is a path on this origin (safeReturnPath, which also refuses what names another site once a router has percent-decoded it:/%2F%2Fevil,/%5Cevil). - The join rule above, and the one place a session starts (
context.signInfor a method with routes of its own): no method signs anyone in while it is switched off.
./google is the first: begin answers { clientId, nonce }, the page leaves for Google
(@codefusion-cc/sign-in/google/browser), Google sends the browser to <base>/google/callback (register it as the
redirect URI), and complete verifies the ID token with verifyGoogleIdToken. An address Google did not verify is
refused (email_unverified); Google's keys out of reach is a 503 that ends nobody's session and keeps the challenge,
so the page sends the same token again.
The login page the callback returns a sign-in to is callback.loginPath: one path (default /login), several, or
a function of the callback's request that answers either.
google({ clientId, callback: { loginPath: ['/konto', '/en/konto', '/de/konto'] } }) // a login page per language
google({ clientId, callback: { loginPath: request => new URL(request.url).hostname === 'b2b.example' ? '/partner/login' : '/login' } })A sign-in returns only to the page it left from (with that page's query, so ?next= survives), only when that page
is one of these, and only on the origin it left from. The first is the page the callback links back to when the
hand-off fails; a function that puts the visitor's language first gives that link the right page. Every path of a
list or a function must be a path on this origin (safeReturnPath): a list that names anything else is a TypeError
when google() is called, and what a function answers that is none is dropped, with /login when nothing is left.
Google's token arrives in the fragment of the page named, and a link someone else made can send a person's token
to any page named here, with a query of their choosing (never to another page or origin), so:
- every page named is a login page that calls
withGoogle.finish()as it loads, which takes the token out of the address at once and accepts it only for a sign-in this tab started; on a page that does not, the token stays in the address for every script of that page to read; - no page named shows what other people wrote or runs a script the app does not trust;
- a function picks among the app's own login pages (by the request's hostname or language) and never answers a path it took from the request: its query, a header, a cookie.
Google's addresses are fixed: the method verifies with Google's keys and for Google's issuer, and nothing a request
carries, and no variable, changes that. keys is for another cache or fetch in front of Google's own keys; whoever
holds a key of the set it names signs anyone in, so it is never made from a request or a variable. The fake Google
of the tests is in ./testing (below).
How the methods to come sit on it
None of these is built. Each is a later entry of this package and touches nothing in the core.
- Apple (
./apple): OpenID Connect like Google, withresponse_mode=form_post. Apple's POST to<base>/apple/callbackcomes from another site, so the method names it inopenPosts, and the browser sends noSameSite=Laxcookie with it: the method'sroutesanswers a page that handsid_tokenandstateto the app's login page, which posts them toPOST <base>/applewith the challenge cookie, as Google's callback does.completeverifies the token withnonce = challenge. Apple sends the name only with the first sign-in: the identity carries it when present, and the account keeps the name it has when a later sign-in has none. A relay address is an address like any other: it is confirmed and joins only an account that holds the same one. - Facebook (
./facebook): an authorization code and no ID token.beginanswers the address to go to withstate = challenge;completecomparesbody.statewith the challenge, exchangesbody.codewith the app's secret and asks for the profile. The address may be missing, and Facebook does not say it is verified:{ kind: 'facebook', subject: id, email, emailConfirmed: false }gets an account of its own and joins nothing. Facebook out of reach is{ ok: false, code: 'sign_in_unavailable', retry: true }. - A password (
./password): nobeginorcomplete, onlyroutes(register, sign in, confirm the address, reset), which readcontext.body(the core has checked the origin and the JSON) and end incontext.signIn. The link in the confirmation mail is a GET, which signs nobody in: it answers a page whose button posts the token. The identity is{ kind: 'email', subject: address, email: address, emailConfirmed }: the address is the identity, the password is one credential on it, kept in the entry's own table (the hash, never here). The session'smethodispassword. Registering signs in withemailConfirmed: false(an account of its own until the address is confirmed); confirming signs in withtrue. The rule for that slice: when another account already holds the address confirmed (the person signed in with Google meanwhile), the password credential attaches to that account, never to a second one: theemailidentity moves to the holder in the same batch (account_idin the upsert of statement 2), the account it registered with is left without an identity, and no two accounts hold one confirmed address through this path. Today's core does not move it yet: nothing confirms anemailidentity before that slice. - A code or link by mail (
./email-code): another proof of the sameemailidentity, so someone who registered with a password and later signs in by code is the same account without a join. A code proves the address:emailConfirmed: true. Its routes usecontext.issueChallenge()andcontext.takeChallenge(), so a code works only in the browser that asked for it, once; a link opened in that browser may sign in on its GET for the same reason, and one opened elsewhere gets a page that posts. It counts wrong codes per address itself. The session'smethodisemail-code. - Passkeys (
./passkey, on@codefusion-cc/passkeys): the identity is{ kind: 'passkey', subject: credential id, emailConfirmed: false }. It carries no address and never joins by one. Its steps (options, verify) arerouteswith the WebAuthn challenge fromcontext.issueChallenge(), kept by the server side of the cookie, single use and bound to the browser. A passkey is added only by someone signed in: the add step readscontext.session()and writes the identity for that account (the one statement this needs,link, comes with that entry). An app that wants a strong sign-in askssessions.require(request, { signedInWithinSeconds, methods: ['passkey', 'google'] }).
dieselpaul moves onto this with the password entry: its users become accounts (ids are text here, so 42
stays '42', and newAccountId numbers new ones the same way), each user an email identity with
confirmed = confirmed_at IS NOT NULL, its PBKDF2 hashes go to the password entry's table with their parameters and
are verified as they are, and staff passkeys become passkey identities. Staff are not given shorter sessions:
the panel asks sessions.require(request, { signedInWithinSeconds: 7 * 86_400, methods: [...] }) on every request,
which holds for whoever is staff today without a lifetime per account. That slice also adds an onSignOut hook
(dieselpaul and Magnetar end other things at sign-out) and decides whether a valid dp_session cookie (no __Host-
prefix) is adopted once into a new session or people sign in once after the move.
Switching methods on and off
The table sign_in_methods holds one row per method someone switched (id, enabled, changed_at,
changed_by); a method nobody switched is on. A method is offered only when the app registers it, it is
configured, and it is enabled.
await signIn.methods.list()
// [{ id: 'google', configured: true, enabled: true, offered: true, changedAt: null, changedBy: null }, …]
await signIn.methods.setEnabled('password', false, staff.account.email)
// { ok: true, enabled: false } | { ok: false, problem: 'unknown_method' | 'last_method' | 'unavailable' }- The last offered method cannot be switched off (
last_method), also when two switches arrive at the same moment: the check and the write are one statement. - Off deletes no identity and ends no session. It stops new sign-ins:
startandcompleteanswer 403method_disabled, also for a sign-in that started before the switch. - An id the app does not register is refused (
unknown_method), and a row for one (a later version's, or written by hand) offers nothing: a setting never turns on a method the app does not carry. - The routes read the table once per
methodsCacheSecondsper isolate (default 30); the isolate that made the switch follows at once, the others within that time.list()always reads.
To put the switch into an app: a staff route that answers signIn.methods.list() and one that calls
setEnabled(id, on, <who>) after sessions.require(request, { signedInWithinSeconds }) and the app's own role
check; the same two calls behind a console admin action. The package ships no screen and no route for it, since who
may switch is the app's to say.
On the page
import { createSessionStore } from '@codefusion-cc/sign-in/browser'
import { googleSignIn } from '@codefusion-cc/sign-in/google/browser'
import { GoogleButton, useSession, useSignInMethods } from '@codefusion-cc/sign-in/react'
export const session = createSessionStore<{ role: string }>() // one per page
export const withGoogle = googleSignIn(session)
function Account() {
const { status, me } = useSession(session) // loading | signed-in | signed-out | expired | unavailable
const offered = useSignInMethods(session) // { status: 'ready', methods: ['google', …] }: a button for each the app knows
if (offered.status === 'unavailable') … // offered.problem ('network', 'unavailable', …) and the Worker's offered.code
return offered.methods.includes('google') && (
<GoogleButton onClick={() => withGoogle.start({ returnPath: location.pathname })}>{t('Continue with Google')}</GoogleButton>
)
}
// The Google button, and once on the login page when the browser is back:
await withGoogle.start({ returnPath: location.pathname })
const result = await withGoogle.finish() // null, { ok: true } or { ok: false, problem }- One
merequest however many components ask:load()joins the question in flight.refresh()asks again now, so its answer is never older than the call: after a request of the app's own that changed whatmesays,refresh()shows the change, also while an earlier question is still out (that one's late answer is dropped).refresh()calls made in the same turn, next to the page's firstload()too, share one request; a failure is not kept. A page that only wants the state callsload(), which never asks twice. - Which methods are offered is
loading,ready(the list, which may be empty) orunavailable, andunavailablesays why:problemas for any action (networkfor no answer,unavailablefor a 503,rate-limited,serverfor an answer that is no list) andcode, the Worker's own (sessions_unavailable), when it sent one. session.fetchisfetchthat reports a 401;reportUnauthorized()does the same for the app's own client. Many 401s are one question. A session that ended showsexpiredand keepsme, so the page keeps what the person typed and asks them to sign in again; it never throws them out by itself.- Answers that arrive out of order cannot undo a newer one, a sign-in or a sign-out.
- A sign-out in one tab signs the others out (BroadcastChannel); a sign-in makes them ask.
- A double click on any action is one request. Failures are results (
problem:network,unavailable,disabled,rate-limited, …), never exceptions; the app words them. - A tab that comes back into view asks again when its answer is older than five minutes.
The Google button
GoogleButton is a real <button type="button"> drawn to Google's branding rules for a custom button: Google's "G"
in its own colours (inline SVG, nothing is fetched), the fill, line and text colour of Google's light, dark or
neutral theme, 40 pixels high at least, the label in Roboto Medium 14/20 where the page has that font.
<GoogleButton onClick={() => withGoogle.start({ returnPath })} theme={appTheme} className="w-full">
{t('Continue with Google')}
</GoogleButton>- The label is the app's, in its language; Google allows "Sign in with Google", "Sign up with Google" and "Continue with Google". It is the button's accessible name; the logo and the spinner are hidden from a screen reader.
- Pending by itself. While the promise
onClickreturned is not settled the button shows a spinner, says it is busy (aria-busy,aria-disabled), stays focusable and takes no second press by mouse, Enter or Space.pendingholds it there for longer (the browser is leaving for Google);disabledis the native attribute. A rejection frees the button and stays the app's to handle. themeisautoby default: light or dark by the system's colour scheme. An app with a theme switch of its own passes the theme it shows.shape="pill"is Google's other shape.- Styling. Every rule has no specificity, so a class of the app's sets the width, the height or the corners
wherever its stylesheet stands; the colours and the logo are Google's. The rules are added to the document's head
once (
noncefor a Content-Security-Policy that asks for one);GOOGLE_BUTTON_CSSis the same text for a page rendered on a server or a policy that admits styles by hash. - Every other attribute of a
<button>passes through (type="submit",aria-describedby,style).
Migrations
The tables come as one migration, generated for the prefix the app uses:
npx codefusion-sign-in-migrations migrations/0000_sign_in # writes; --check only reports
npx codefusion-sign-in-migrations migrations/0000_sign_in --prefix=auth_accounts, identities, sessions, sign_in_challenges and sign_in_methods. Every query of the package is by a
primary key or an index: a session by its hash, an account's sessions and identities by account_id, the join by
identities(email), pruning by expires_at.
Testing
import { resetSignInTables, testBrowser, testGoogle, testGoogleIssuer, testGoogleSignIn, testSession } from '@codefusion-cc/sign-in/testing'
await resetSignInTables(env.DB)
const issuer = await testGoogleIssuer()
const signIn = createSignIn({ db: env.DB, methods: [testGoogle(issuer)] }) // google() against the fake Google
const browser = testBrowser(request => signIn.handle(request))
await testGoogleSignIn(browser, issuer, { email: '[email protected]' }) // through the real routes
await testSession(signIn.sessions, { browser, identity: { email: '[email protected]' } }) // no provider at alltestGoogle(issuer, { callback, hosts }) is the one way to point the Google method at another Google, and it is in
this entry on purpose: whoever holds the fake's key signs in as anyone, so no production entry imports it, and it
takes the issuer as an object a test made, not a name a variable or a request could carry. An app whose end-to-end
tests run its whole Worker gives them an entry of their own that the deployment never builds, and registers
testGoogle there in place of google():
// worker/test-entry.ts, named by the test's wrangler configuration only
const issuer = await testGoogleIssuer({ clientId: 'test-client' })
export default app({ google: testGoogle(issuer, { callback: { loginPath: ['/konto', '/en/konto'] } }) })Should it reach a deployment all the same, it signs nobody in there: it works only on requests whose own address
is this machine's (localhost, 127.0.0.1, [::1], *.localhost), a .test name, or a hostname the test lists in
hosts, and answers 503 method_not_configured on any other. GET <base>/methods still lists google there (what
is configured is not a matter of the request), so the page shows a button that answers not-configured: a test
entry that was deployed is seen at the first press.
The host rule is the second guard, and it holds only where the request's address cannot be chosen by whoever
sends it. On Workers it is the address the request was routed by, which no header, cookie or body changes. A
server that builds the address from the Host header (Node behind a proxy), or a Worker called by another Worker
with an address that one makes up, has only the first guard: the testing entry is not in what it deploys. Never
list a deployment's hostname in hosts, and keep a fixed signing key (testGoogleIssuer({ key })) out of
anything deployed: whoever has it signs in as anyone wherever testGoogle answers.
The page's side still leaves for accounts.google.com: a browser test answers that address itself (Playwright's
route) with issuer.authorize(url).
What is next
Not here: mail, passwords, codes, WebAuthn, Apple, Facebook, a settings screen and a console action for the
switches, merging two accounts that both exist, and roles (the app adds a role to me with the me hook and checks
it in its own routes). Decided for the slices that follow:
- The fake Google's account chooser:
startGoogleSignInof@codefusion-cc/google-sign-in/browserhas Google's address fixed, so a browser test intercepts it. A chooser address a test can name there (in code, astestGoogleis), andhandleGoogleSignIntaking several login pages itself in place of this package asking it once per page, come with that package's next release. - Password (
./password): a confirmed password address that another account already holds confirmed attaches to that account (see above); attempts are limited per address and per network by the entry itself. - Staff: no session lifetime per account. A panel asks for a sign-in within 7 days by the methods it accepts
(
signedInWithinSecondsandmethodsofsessions.require), and pins a role toonSignIn'sidentity. - dieselpaul's move: an
onSignOuthook, and the decision on adopting itsdp_sessioncookie. sessions.link(accountId, identity)for adding a passkey or a password to a signed-in account comes with the first entry that needs it.- Shared D1 types:
DatabaseandStatementare declared here, in commerce and in mail, and the migrations command repeats commerce's. One package for both (@codefusion-cc/d1: the structural types, named parameters, the migration file writer) once no open pull request edits the three.
