Topic

A code by mail

A code is kept sealed, so a copy of the data shows nothing of it, and is compared with sameSecret. It is good for ten minutes and is used once.

tables/signInCodes.ts
import { date, table, text } from 'tentjs'

export const signInCodes = table('signInCodes', {
  columns: { email: text({ personal: true }), code: text({ sealed: true, private: true }), until: date() },
  access: { read: 'nobody', add: 'nobody', change: 'nobody', remove: 'nobody' },
})
functions/codes.ts
import { action, anyone, attempts, change, date, fail, later, link, mail, now, sameSecret, sessions, text, token } from 'tentjs'

export const keepCode = change('keepCode', {
  access: 'nobody',
  system: 'only a sign-in by mail keeps its codes',
  input: { email: text(), code: text(), until: date() },
  run: ({ input, db }) => {
    for (const old of db.system.signInCodes.where({ email: input.email }).all()) db.system.signInCodes.remove(old.id)
    db.system.signInCodes.add(input)
  },
})

export const useCode = change('useCode', {
  access: 'nobody',
  system: 'only a sign-in by mail uses its codes',
  input: { code: link('signInCodes') },
  run: ({ input, db }) => {
    db.system.signInCodes.remove(input.code)
  },
})

export const sendCode = action('sendCode', {
  access: anyone('a visitor asks for a code'),
  system: 'mails a code to an email, whether it has an account or not',
  input: { email: text({ max: 254 }) },
  run: async ({ input, call }) => {
    const email = input.email.trim().toLowerCase()
    attempts.limit(`code:${email}`, { tries: 3, per: '10 minutes' })
    const code = token({ digits: 6 })
    call(keepCode, { email, code, until: later(now(), '10 minutes') })
    await mail.send({ to: email, subject: 'Your code', text: `Your code is ${code}. It works for ten minutes.` })
  },
})

export const signInWithCode = action('signInWithCode', {
  access: anyone('a visitor signs in with a code'),
  system: 'reads the codes and the users, which nobody may read',
  input: { email: text({ max: 254 }), code: text({ secret: true }) },
  run: async ({ input, db, call }) => {
    const email = input.email.trim().toLowerCase()
    attempts.limit(`signInWithCode:${email}`, { tries: 5, per: '10 minutes', address: true })
    const kept = await db.system.signInCodes.where({ email }).first()
    const user = await db.system.users.where({ email }).first()
    if (!kept || !user || kept.until.getTime() < now().getTime() || !sameSecret(kept.code, input.code))
      return fail('wrong-code', 'That code is not right, or no longer works.')
    call(useCode, { code: kept.id })
    sessions.start(user.id)
  },
})