Chapter 1: your first sign-in
WIPBuild an engine over the memory adapter, create an account, sign in with a password, read the session back and sign out
DuckNotes starts with one question: who is making this request? This chapter builds the smallest engine that can answer it, signs Ada in, and takes the answer apart.
Learning goals
- Build an
AuthEnginewithcreateAuthfrom a store, a transport and a provider. - Create an identity and give it a password.
- Sign in with
auth.flows.signInand read the session back withresolveSession. - Catch an
AuthErrorand read its code.
The moving parts
The engine holds no data of its own. A store keeps identities, credentials and sessions; a transport decides how the session id travels; a provider is one way to prove who you are. Swapping any of them later leaves the rest of the code alone.
Step by step
Build the engine
import { MemoryAdapter } from '@gentleduck/auth/adapters/memory'
import { createAuth } from '@gentleduck/auth/core'
import { cookieTransport } from '@gentleduck/auth/core/transport'
import { passwords } from '@gentleduck/auth/providers/passwords'
export const auth = createAuth({
baseUrl: 'http://localhost:3000',
stores: new MemoryAdapter(),
transport: cookieTransport({ name: 'sid', secure: false }),
providers: [passwords()],
})
MemoryAdapterholds all three stores in maps, so a restart forgets everything. Chapter 8 replaces it with Postgres.cookieTransport({ name: 'sid', secure: false })puts the session id in a cookie that works over plain http. The default name,__Host-duck-sid, needs https.passwords()hashes with Argon2id through the@node-rs/argon2peer.baseUrlis where the app lives. Links the engine mails start from it.
Sign in
import { AuthError } from '@gentleduck/auth/core'
import { auth } from './auth'
const ada = await auth.identities.create({ profile: { username: 'ada', email: 'ada@example.com' } })
await auth.passwords.set(ada.id, 'correct-horse-battery', auth.cfg.stores.credentials)
const { sid, session } = await auth.flows.signIn({
providerId: 'password',
input: { email: 'ada@example.com', password: 'correct-horse-battery' },
})
console.log('signed in at AAL', session?.aal)
// signed in at AAL 1
const request = { headers: new Headers({ cookie: `sid=${sid}` }) }
const resolved = await auth.resolveSession(request).orNull()
console.log('resolved', resolved?.identity?.profile.username)
// resolved ada
try {
await auth.flows.signIn({ providerId: 'password', input: { email: 'ada@example.com', password: 'wrong-password' } })
} catch (err) {
if (err instanceof AuthError) console.log('refused', err.code)
// refused AUTH_INVALID_CREDENTIALS
}
await auth.flows.signOut(sid)
console.log('after sign-out', await auth.resolveSession(request).orNull())
// after sign-out null
Run it
npx tsx src/main.ts
npx tsx src/main.ts
What just happened
- An identity is the person.
identities.createstores a profile holding at leastusernameandemail. The password is not on it:passwords.setstores a hash as a separate credential. - The provider checks the password. It finds Ada by email, case-folded and trimmed, and verifies the hash. A wrong password and an unknown email both throw
AUTH_INVALID_CREDENTIALS, and both run the hasher, so neither the answer nor its timing says which. - The session is minted.
sidis a random id for the caller; the store keeps only its SHA-256 as the row id.session.aalis 1 because one factor was presented. intentsare for a server. They say "set thesidcookie and theduck-csrfcookie". With no server yet, this chapter drops them; chapter 4 applies them.resolveSessionreads it back. The transport findssidin thecookieheader, the engine hashes it, loads the row and its identity.signOutrevokes the row. The same cookie now reads as no session.
Answers
resolveSession answers an Answer: awaited directly, a missing or dead session throws. .orNull() turns only "not found" codes into null and still throws a store that is down, so an outage never reads as a signed-out user.
await auth.resolveSession({ headers: new Headers() }) // throws AUTH_SESSION_REVOKED
await auth.resolveSession({ headers: new Headers() }).orNull() // null
Every error the engine throws is an AuthError with a code, an HTTP status and meta. Branch on the code; the error reference lists them.
Try it
- Sign in as
ghost@example.com, which does not exist, and confirm the code is the same as for a wrong password. - Print
session.expiresAtandsession.absoluteExpiresAt: 7 and 30 days out. Chapter 3 changes them. - Replace
passwords()withpasswords({ hasher: scryptHasher() }), imported from the same module, and uninstall@node-rs/argon2. Sign-in still works on Node's built-in scrypt.
Where we are
ducknotes/
src/
auth.ts - the engine
main.ts - the script you run
See also
- Core: every
createAuthoption and facet. - Password: the strength policy and the hashers.
- Transports: cookie, bearer, JWT.
- Chapter 2: accounts