Skip to main content

Chapter 5: in the browser

WIP

Sign 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

Loading diagram...

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

  1. render asked 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.
  2. A wrong password was an envelope, not a throw. A refused request answers { ok: false, error } at the error's status, with error.code as the server wrote it. A request that never reached the server rejects, which the .catch turns into "Try again".
  3. Every write sent the CSRF token. post reads the duck-csrf cookie and echoes it in x-csrf-token. Sign-in has no session yet, so expressCsrf checks only the origin there; sign-out and the notes need the token.
  4. 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.
  5. The session cookie stayed out of reach. sid is HttpOnly, so no script on the page, yours or an injected one, can read it. duck-csrf is readable on purpose.

Try it

  1. Sign in, then reload. The notes come back, because the page asks the server rather than remembering anything.
  2. Delete the duck-csrf cookie in the dev tools and add a note. The server answers 403 AUTH_CSRF.
  3. Stop the server and sign in. The fetch rejects, 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