Chapter 5: in the browser
WIPSign in from a page with plain fetch calls to your own routes, read the error envelope, draw the page from the session, and send the CSRF token on every write
The server from chapter 4 is complete; now a page talks to it. The routes are yours, so the page calls them with fetch, the same way it calls /api/notes.
Learning goals
- Call your auth routes with
fetch. - Read the error envelope a refused request answers.
- Draw the page from
GET /auth/session. - Send the CSRF token on every write.
Where the page sits
Step by step
Serve a page
Add one line to src/server.ts, under express.json():
src/server.ts
app.use(express.static('public'))
public/index.html
<!doctype html>
<meta charset="utf-8" />
<title>DuckNotes</title>
<p id="status">Loading</p>
<form id="sign-in" hidden>
<input name="email" type="email" placeholder="Email" required />
<input name="password" type="password" placeholder="Password" required />
<button>Sign in</button>
</form>
<form id="note" hidden>
<input name="text" placeholder="A note" required />
<button>Add</button>
<button id="sign-out" type="button">Sign out</button>
</form>
<ul id="notes"></ul>
<script type="module" src="/page.js"></script>
The page script
src/page.ts
type Profile = { username: string; email: string }
const status = document.querySelector('#status')
const list = document.querySelector('#notes')
const signInForm = document.querySelector<HTMLFormElement>('#sign-in')
const noteForm = document.querySelector<HTMLFormElement>('#note')
const signOut = document.querySelector('#sign-out')
if (!status || !list || !signInForm || !noteForm || !signOut) throw new Error('DuckNotes markup is missing')
// Every write echoes the readable duck-csrf cookie in x-csrf-token
const post = (path: string, body: unknown = {}) =>
fetch(path, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-csrf-token': document.cookie.match(/(?:^|; )duck-csrf=([^;]*)/)?.[1] ?? '',
},
body: JSON.stringify(body),
})
const showNotes = async () => {
const res = await fetch('/api/notes')
const notes: string[] = res.ok ? await res.json() : []
list.replaceChildren(...notes.map((text) => Object.assign(document.createElement('li'), { textContent: text })))
}
const render = async () => {
const { identity }: { identity: { profile: Profile } | null } = await (await fetch('/auth/session')).json()
status.textContent = identity ? `Signed in as ${identity.profile.email}` : 'Signed out'
signInForm.hidden = Boolean(identity)
noteForm.hidden = !identity
await showNotes()
}
signInForm.addEventListener('submit', async (event) => {
event.preventDefault()
const form = new FormData(signInForm)
const res = await post('/auth/signin', {
providerId: 'password',
input: { email: form.get('email'), password: form.get('password') },
}).catch(() => null)
if (res?.ok) return render()
const code = res ? (await res.json()).error?.code : null
status.textContent = code === 'AUTH_INVALID_CREDENTIALS' ? 'Wrong email or password' : 'Try again'
})
noteForm.addEventListener('submit', async (event) => {
event.preventDefault()
await post('/api/notes', { text: new FormData(noteForm).get('text') })
noteForm.reset()
await showNotes()
})
signOut.addEventListener('click', () => post('/auth/signout').then(render))
await render()
Bundle it and open the page
npm i -D esbuild
npx esbuild src/page.ts --bundle --format=esm --outfile=public/page.js
npx tsx src/server.ts
Open http://localhost:3000, sign up Ada with the curl call from chapter 4, then sign in from the page.
What just happened
renderasked the server once on load, and again after every sign-in and sign-out. The page remembers nothing, so there is one place that decides what is shown.- A wrong password was an envelope, not a throw. A refused request answers
{ ok: false, error }at the error's status, witherror.codeas the server wrote it. A request that never reached the server rejects, which the.catchturns into "Try again". - Every write sent the CSRF token.
postreads theduck-csrfcookie and echoes it inx-csrf-token. Sign-in has no session yet, soexpressCsrfchecks only the origin there; sign-out and the notes need the token. - The cookie name matches the server. Over https with the default cookie settings the cookie is
__Host-duck-csrf; chapter 8 changes the regex with it. - The session cookie stayed out of reach.
sidis HttpOnly, so no script on the page, yours or an injected one, can read it.duck-csrfis readable on purpose.
Try it
- Sign in, then reload. The notes come back, because the page asks the server rather than remembering anything.
- Delete the
duck-csrfcookie in the dev tools and add a note. The server answers 403AUTH_CSRF. - Stop the server and sign in. The
fetchrejects, and the page says "Try again".
Where we are
ducknotes/
public/
index.html
page.js - built from src/page.ts
src/
mailer.ts
auth.ts
server.ts - now serving public/
page.ts - the page script
See also
- Server adapters: the routes the page calls.
- The CSRF cookie: what the token is and its name over https.
- Chapter 6: more ways in