slauthjs 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -2
  4. package/package.json +114 -4
  5. package/src/access.js +143 -0
  6. package/src/account-element.d.ts +8 -0
  7. package/src/account-element.js +244 -0
  8. package/src/client.d.ts +113 -0
  9. package/src/client.js +211 -0
  10. package/src/core.js +442 -0
  11. package/src/element.d.ts +6 -0
  12. package/src/element.js +253 -0
  13. package/src/email/cloudflare.d.ts +1 -0
  14. package/src/email/cloudflare.js +19 -0
  15. package/src/email/console.d.ts +1 -0
  16. package/src/email/console.js +14 -0
  17. package/src/email/index.d.ts +4 -0
  18. package/src/email/message.js +56 -0
  19. package/src/email/resend.d.ts +1 -0
  20. package/src/email/resend.js +21 -0
  21. package/src/email-code.js +75 -0
  22. package/src/index.d.ts +364 -0
  23. package/src/index.js +9 -0
  24. package/src/lock.js +225 -0
  25. package/src/node.d.ts +3 -0
  26. package/src/node.js +67 -0
  27. package/src/oauth/flow.js +81 -0
  28. package/src/oauth/providers.js +171 -0
  29. package/src/orgs.js +184 -0
  30. package/src/passkey/cbor.js +99 -0
  31. package/src/passkey/webauthn.js +183 -0
  32. package/src/password.js +81 -0
  33. package/src/providers.d.ts +28 -0
  34. package/src/providers.js +1 -0
  35. package/src/rate-limit.js +41 -0
  36. package/src/routes/account.js +130 -0
  37. package/src/routes/email.js +147 -0
  38. package/src/routes/oauth.js +106 -0
  39. package/src/routes/orgs.js +144 -0
  40. package/src/routes/passkeys.js +136 -0
  41. package/src/routes/password.js +78 -0
  42. package/src/routes/two-factor.js +89 -0
  43. package/src/session.js +131 -0
  44. package/src/stores/d1.d.ts +1 -0
  45. package/src/stores/d1.js +58 -0
  46. package/src/stores/index.d.ts +5 -0
  47. package/src/stores/kv.d.ts +1 -0
  48. package/src/stores/kv.js +27 -0
  49. package/src/stores/memory.d.ts +1 -0
  50. package/src/stores/memory.js +35 -0
  51. package/src/stores/sqlite.d.ts +1 -0
  52. package/src/stores/sqlite.js +42 -0
  53. package/src/tokens.js +119 -0
  54. package/src/two-factor.js +142 -0
  55. package/src/ui.js +49 -0
  56. package/src/users.js +126 -0
  57. package/src/util.js +106 -0
package/src/element.js ADDED
@@ -0,0 +1,253 @@
1
+ import { createClient } from './client.js'
2
+ import { MESSAGES, STYLE, escape } from './ui.js'
3
+
4
+ /**
5
+ * <slauth-sign-in>: a drop-in sign-in box. It asks the server what's turned on and
6
+ * offers it: "Continue with GitHub/Google/…", email → 6-digit code, a password, a
7
+ * passkey, then the two-factor code for people who set one up, and an offer to add a
8
+ * passkey afterwards. Works in any framework (it's a plain custom element) and is themed
9
+ * with CSS variables:
10
+ *
11
+ * slauth-sign-in {
12
+ * --slauth-accent: #f59e0b; --slauth-ink: #2b2118; --slauth-bg: #fff;
13
+ * --slauth-radius: 14px; --slauth-font: inherit;
14
+ * --slauth-accent-ink: #2b2118; (text on the accent; for dark themes)
15
+ * --slauth-error: #c2410c;
16
+ * }
17
+ *
18
+ * Attributes: base-path (default /auth), app-name, redirect (where to go once signed in),
19
+ * mode ("sign-up" to start on the password sign-up form), no-passkeys (no passkey
20
+ * button or offer).
21
+ * Fires `slauth:signed-in` with `{ detail: { user } }`.
22
+ *
23
+ * import 'slauthjs/element'
24
+ * <slauth-sign-in app-name="Planda" redirect="/"></slauth-sign-in>
25
+ */
26
+
27
+ class SlauthSignIn extends HTMLElement {
28
+ connectedCallback() {
29
+ this.auth = createClient({ basePath: this.getAttribute('base-path') ?? '/auth' })
30
+ this.appName = this.getAttribute('app-name') ?? ''
31
+ this.config = { providers: [], passwords: false, passkeys: true }
32
+ this.state = { step: 'email', mode: this.getAttribute('mode') === 'sign-up' ? 'sign-up' : 'code', email: '', error: '', busy: false, recovery: false }
33
+ this.attachShadow({ mode: 'open' })
34
+ this.readUrl()
35
+ this.render()
36
+ this.load()
37
+ }
38
+
39
+ /** Errors and the two-factor step can arrive in the URL, after a provider sends people back. */
40
+ readUrl() {
41
+ const params = new URLSearchParams(window.location.search)
42
+ const error = params.get('slauth_error')
43
+ if (error) this.state.error = MESSAGES[error] ?? 'Something went wrong. Please try again.'
44
+ if (params.get('slauth_step') === 'two-factor') this.state.step = 'two-factor'
45
+ this.afterRedirect = params.get('redirect')
46
+ this.invite = params.get('slauth_invite')
47
+ if (error || params.has('slauth_step')) {
48
+ for (const key of ['slauth_error', 'slauth_step']) params.delete(key)
49
+ const query = params.toString()
50
+ window.history.replaceState(window.history.state, '', window.location.pathname + (query ? `?${query}` : '') + window.location.hash)
51
+ }
52
+ }
53
+
54
+ async load() {
55
+ try {
56
+ const [config, session] = await Promise.all([this.auth.config(), this.auth.session()])
57
+ this.config = config
58
+ if (session.twoFactor === 'pending') this.state.step = 'two-factor'
59
+ if (this.state.mode === 'sign-up' && !config.passwords) this.state.mode = 'code'
60
+ this.render()
61
+ } catch {
62
+ // The box still works with email codes.
63
+ }
64
+ }
65
+
66
+ set(patch) {
67
+ this.state = { ...this.state, ...patch }
68
+ this.render()
69
+ }
70
+
71
+ async run(action) {
72
+ this.set({ busy: true, error: '' })
73
+ try {
74
+ await action()
75
+ } catch (error) {
76
+ this.set({ error: MESSAGES[error.code] ?? error.message ?? 'Something went wrong.' })
77
+ } finally {
78
+ if (this.isConnected) this.set({ busy: false })
79
+ }
80
+ }
81
+
82
+ /** After the first step: the two-factor code, the passkey offer, or done. */
83
+ async signedIn(result, { offer = true } = {}) {
84
+ if (result.twoFactor) return this.set({ step: 'two-factor', recovery: false })
85
+ this.user = result.user
86
+ if (offer && this.passkeys()) return this.set({ step: 'offer' })
87
+ return this.done(result.user)
88
+ }
89
+
90
+ async done(user) {
91
+ if (this.invite && this.config.organizations) await this.auth.orgs.accept(this.invite).catch(() => {})
92
+ // Back to the start, ready for the next time it's shown (e.g. after signing out).
93
+ this.state = { step: 'email', mode: 'code', email: '', error: '', busy: false, recovery: false }
94
+ this.render()
95
+ this.dispatchEvent(new CustomEvent('slauth:signed-in', { detail: { user }, bubbles: true, composed: true }))
96
+ const to = this.getAttribute('redirect') ?? this.afterRedirect
97
+ if (to) window.location.assign(to)
98
+ }
99
+
100
+ passkeys() {
101
+ return !this.hasAttribute('no-passkeys') && this.config.passkeys !== false && this.auth.passkey.supported()
102
+ }
103
+
104
+ render() {
105
+ const { step, mode, email, error, busy, recovery } = this.state
106
+ const { providers = [], passwords } = this.config
107
+ const app = this.appName || (this.config.appName !== 'your app' ? (this.config.appName ?? '') : '')
108
+ const name = app ? ` to ${escape(app)}` : ''
109
+ const off = busy ? 'disabled' : ''
110
+ const redirect = this.getAttribute('redirect') ?? window.location.pathname + window.location.search
111
+ const providerButtons = providers.length
112
+ ? `<div class="providers">${providers.map((p) => `<a class="button" part="provider" href="${escape(this.auth.oauth.url(p.id, { redirect }))}">Continue with ${escape(p.name)}</a>`).join('')}</div><div class="or">or</div>`
113
+ : ''
114
+
115
+ const emailViews = {
116
+ code: `
117
+ <h2>Sign in${name}</h2>
118
+ ${providerButtons}
119
+ <p>We'll email you a 6-digit code. No password needed.</p>
120
+ <form data-on="email">
121
+ <label for="email">Email</label>
122
+ <input id="email" type="email" autocomplete="email webauthn" required value="${escape(email)}" />
123
+ <button class="primary" ${off}>Email me a code</button>
124
+ </form>
125
+ ${this.passkeys() ? `<button class="ghost dashed" data-on="passkey" ${off}>Sign in with a passkey</button>` : ''}
126
+ ${passwords ? `<div class="row"><button class="link" data-on="mode-password">Use a password</button><button class="link" data-on="mode-sign-up">Create an account</button></div>` : ''}`,
127
+ password: `
128
+ <h2>Sign in${name}</h2>
129
+ ${providerButtons}
130
+ <form data-on="password">
131
+ <label for="email">Email</label>
132
+ <input id="email" type="email" autocomplete="username webauthn" required value="${escape(email)}" />
133
+ <label for="password">Password</label>
134
+ <input id="password" type="password" autocomplete="current-password" required />
135
+ <button class="primary" ${off}>Sign in</button>
136
+ </form>
137
+ <div class="row"><button class="link" data-on="mode-code">Email me a code instead</button><button class="link" data-on="mode-code">Forgot password?</button></div>`,
138
+ 'sign-up': `
139
+ <h2>Create an account${name}</h2>
140
+ ${providerButtons}
141
+ <form data-on="sign-up">
142
+ <label for="email">Email</label>
143
+ <input id="email" type="email" autocomplete="email" required value="${escape(email)}" />
144
+ <label for="password">Password</label>
145
+ <input id="password" type="password" autocomplete="new-password" minlength="8" required />
146
+ <button class="primary" ${off}>Create account</button>
147
+ </form>
148
+ <div class="row"><button class="link" data-on="mode-password">I have an account</button></div>`,
149
+ }
150
+
151
+ const views = {
152
+ email: emailViews[mode] ?? emailViews.code,
153
+ code: `
154
+ <h2>Check your email</h2>
155
+ <p>We sent a code to <strong>${escape(email)}</strong>${this.config.magicLinks ? ', with a link you can tap instead' : ''}.</p>
156
+ <form data-on="code">
157
+ <label for="code">Code</label>
158
+ <input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
159
+ <button class="primary" ${off}>${mode === 'sign-up' ? 'Create account' : 'Sign in'}</button>
160
+ </form>
161
+ <div class="row"><button class="link" data-on="back">Use another email</button><button class="link" data-on="resend">Send again</button></div>`,
162
+ 'two-factor': recovery
163
+ ? `
164
+ <h2>Use a recovery code</h2>
165
+ <p>One of the codes you saved when you turned on two-factor sign-in. Each works once.</p>
166
+ <form data-on="two-factor">
167
+ <label for="code">Recovery code</label>
168
+ <input id="code" autocomplete="off" autocapitalize="off" spellcheck="false" required />
169
+ <button class="primary" ${off}>Continue</button>
170
+ </form>
171
+ <div class="row"><button class="link" data-on="use-app">Use the app instead</button></div>`
172
+ : `
173
+ <h2>Two-factor sign-in</h2>
174
+ <p>Enter the 6-digit code from your authenticator app.</p>
175
+ <form data-on="two-factor">
176
+ <label for="code">Code</label>
177
+ <input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
178
+ <button class="primary" ${off}>Continue</button>
179
+ </form>
180
+ <div class="row"><button class="link" data-on="use-recovery">Lost your phone?</button><button class="link" data-on="back">Start over</button></div>`,
181
+ offer: `
182
+ <h2>You're in 🦥</h2>
183
+ <p>Add a passkey and next time it's just Face ID, Touch ID or your PIN.</p>
184
+ <button class="primary" data-on="add" ${off}>Add a passkey</button>
185
+ <button class="link" data-on="skip">Not now</button>`,
186
+ }
187
+ this.shadowRoot.innerHTML = `<style>${STYLE}</style><div class="card" part="card">${views[step]}<p class="error" role="alert">${escape(error)}</p></div>`
188
+
189
+ const on = (key, handler) => {
190
+ for (const el of this.shadowRoot.querySelectorAll(`[data-on="${key}"]`)) {
191
+ el.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
192
+ event.preventDefault()
193
+ handler()
194
+ })
195
+ }
196
+ }
197
+ const value = (id) => this.shadowRoot.getElementById(id)?.value ?? ''
198
+ // Read inputs before run(): it re-renders the form, which would empty them.
199
+ on('email', () => {
200
+ const typed = value('email').trim()
201
+ this.set({ email: typed })
202
+ return this.run(async () => {
203
+ await this.auth.email.start(typed, { redirect: this.getAttribute('redirect') ?? undefined })
204
+ this.set({ step: 'code' })
205
+ this.shadowRoot.getElementById('code')?.focus()
206
+ })
207
+ })
208
+ on('password', () => {
209
+ const typed = value('email').trim()
210
+ const password = value('password')
211
+ this.set({ email: typed })
212
+ return this.run(async () => this.signedIn(await this.auth.password.signIn(typed, password)))
213
+ })
214
+ on('sign-up', () => {
215
+ const typed = value('email').trim()
216
+ const password = value('password')
217
+ this.set({ email: typed })
218
+ return this.run(async () => {
219
+ await this.auth.password.signUp(typed, password)
220
+ this.set({ step: 'code' })
221
+ })
222
+ })
223
+ on('code', () => {
224
+ const code = value('code')
225
+ return this.run(async () => this.signedIn(await this.auth.email.verify(email, code)))
226
+ })
227
+ on('two-factor', () => {
228
+ const code = value('code')
229
+ return this.run(async () => this.signedIn(await this.auth.twoFactor.verify(recovery ? { recoveryCode: code } : { code }), { offer: false }))
230
+ })
231
+ on('passkey', () => this.run(async () => this.signedIn(await this.auth.passkey.signIn(), { offer: false })))
232
+ on('mode-code', () => this.set({ mode: 'code', error: '' }))
233
+ on('mode-password', () => this.set({ mode: 'password', error: '' }))
234
+ on('mode-sign-up', () => this.set({ mode: 'sign-up', error: '' }))
235
+ on('use-recovery', () => this.set({ recovery: true, error: '' }))
236
+ on('use-app', () => this.set({ recovery: false, error: '' }))
237
+ on('back', () => this.set({ step: 'email', error: '' }))
238
+ on('resend', () => this.run(() => this.auth.email.start(email)))
239
+ on('add', () =>
240
+ this.run(async () => {
241
+ await this.auth.passkey.register()
242
+ await this.done(this.user)
243
+ }),
244
+ )
245
+ on('skip', () => this.done(this.user))
246
+ const first = this.shadowRoot.querySelector(step === 'email' && email ? '#password, input' : 'input')
247
+ first?.focus({ preventScroll: true })
248
+ }
249
+ }
250
+
251
+ if (typeof customElements !== 'undefined' && !customElements.get('slauth-sign-in')) {
252
+ customElements.define('slauth-sign-in', SlauthSignIn)
253
+ }
@@ -0,0 +1 @@
1
+ export { cloudflareEmail } from './index.js'
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Sends codes with Cloudflare Email Service, through a `send_email` binding in your
3
+ * wrangler config:
4
+ *
5
+ * "send_email": [{ "name": "EMAIL" }]
6
+ *
7
+ * @param {{ send(message: { to: string, from: string, subject: string, text: string, html: string }): Promise<unknown> }} binding e.g. env.EMAIL
8
+ * @param {{ from: string }} options a sender on a domain set up for Email Service
9
+ * @returns {import('../index.js').EmailSender}
10
+ */
11
+ export function cloudflareEmail(binding, { from }) {
12
+ if (!binding?.send) throw new Error('slauth: cloudflareEmail() needs a send_email binding, e.g. env.EMAIL.')
13
+ if (!from) throw new Error('slauth: cloudflareEmail() needs a from address.')
14
+ return {
15
+ async send({ to, subject, text, html }) {
16
+ await binding.send({ to, from, subject, text, html })
17
+ },
18
+ }
19
+ }
@@ -0,0 +1 @@
1
+ export { consoleEmail } from './index.js'
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Prints sign-in codes (and links) to the console instead of emailing them. The default
3
+ * while you build, so there's nothing to set up to try slauth.
4
+ *
5
+ * @returns {import('../index.js').EmailSender}
6
+ */
7
+ export function consoleEmail() {
8
+ return {
9
+ async send({ to, code, link, appName, kind = 'sign-in', subject }) {
10
+ if (kind === 'sign-in') console.log(`\n 🦥 slauth: ${appName} sign-in code for ${to} is ${code}${link ? `\n or open ${link}` : ''}\n`)
11
+ else console.log(`\n 🦥 slauth: email to ${to}: ${subject}${code ? `\n code: ${code}` : ''}${link ? `\n link: ${link}` : ''}\n`)
12
+ },
13
+ }
14
+ }
@@ -0,0 +1,4 @@
1
+ import type { EmailSender } from '../index.js'
2
+ export function consoleEmail(): EmailSender
3
+ export function resend(options: { apiKey: string; from: string; fetch?: typeof fetch }): EmailSender
4
+ export function cloudflareEmail(binding: { send(message: { to: string; from: string; subject: string; text: string; html: string }): Promise<unknown> }, options: { from: string }): EmailSender
@@ -0,0 +1,56 @@
1
+ import { escapeHtml } from '../util.js'
2
+
3
+ /**
4
+ * The emails slauth sends, unless you pass your own `emails` function in the config.
5
+ *
6
+ * sign-in a 6-digit code (and a link, with magicLinks on)
7
+ * email-change a code to confirm a new address
8
+ * invite an invitation to join an organization
9
+ */
10
+ export function defaultMessage(kind, { code, link, appName, minutes, orgName, inviter }) {
11
+ if (kind === 'email-change') {
12
+ return box({
13
+ subject: `${code} is your code to confirm this email for ${appName}`,
14
+ lines: [`Enter this code in ${escapeHtml(appName)} to use this address from now on:`],
15
+ textLines: [`Enter this code in ${appName} to use this address from now on:`],
16
+ code,
17
+ footer: `It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`,
18
+ })
19
+ }
20
+ if (kind === 'invite') {
21
+ return box({
22
+ subject: `${inviter ? `${inviter} invited you` : "You're invited"} to join ${orgName} on ${appName}`,
23
+ lines: [`${inviter ? `${escapeHtml(inviter)} invited you` : "You're invited"} to join <strong>${escapeHtml(orgName)}</strong> on ${escapeHtml(appName)}.`],
24
+ textLines: [`${inviter ? `${inviter} invited you` : "You're invited"} to join ${orgName} on ${appName}.`],
25
+ link,
26
+ linkLabel: 'Accept the invitation',
27
+ footer: `Sign in with this email address to accept. The invitation lasts 7 days.`,
28
+ })
29
+ }
30
+ return box({
31
+ subject: `${code} is your ${appName} sign-in code`,
32
+ lines: [`Your <strong>${escapeHtml(appName)}</strong> sign-in code:`],
33
+ textLines: [`Your ${appName} sign-in code is ${code}`],
34
+ code,
35
+ link,
36
+ linkLabel: `Sign in to ${appName}`,
37
+ footer: `It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`,
38
+ })
39
+ }
40
+
41
+ /** The sign-in email, as slauth 1.0 exported it. */
42
+ export function codeMessage({ code, appName, minutes }) {
43
+ return defaultMessage('sign-in', { code, appName, minutes })
44
+ }
45
+
46
+ function box({ subject, lines, textLines = lines, code, link, linkLabel, footer }) {
47
+ const text = [...textLines, code ? `\n${code}` : '', link ? `\n${linkLabel}: ${link}` : '', `\n${footer}`].filter(Boolean).join('\n')
48
+ const html = `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#fdf6ec;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#2b2118">
49
+ <div style="max-width:420px;margin:0 auto;background:#fff;border:2px solid #2b2118;border-radius:16px;padding:28px">
50
+ ${lines.map((l) => `<p style="margin:0 0 8px;font-size:15px">${l}</p>`).join('\n')}
51
+ ${code ? `<p style="margin:0 0 16px;font-size:34px;font-weight:800;letter-spacing:6px">${escapeHtml(code)}</p>` : ''}
52
+ ${link ? `<p style="margin:8px 0 16px"><a href="${escapeHtml(link)}" style="display:inline-block;background:#f59e0b;color:#2b2118;border:2px solid #2b2118;border-radius:10px;padding:10px 16px;font-weight:700;text-decoration:none">${escapeHtml(linkLabel)}</a></p>` : ''}
53
+ <p style="margin:0;font-size:13px;color:#6b5d50">${escapeHtml(footer)}</p>
54
+ </div></body></html>`
55
+ return { subject, text, html }
56
+ }
@@ -0,0 +1 @@
1
+ export { resend } from './index.js'
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Sends codes with Resend (resend.com; the free tier is plenty for a side project).
3
+ *
4
+ * @param {{ apiKey: string, from: string, fetch?: typeof fetch }} options
5
+ * from a sender on a domain you verified in Resend, e.g. "My App <hi@myapp.dev>"
6
+ * @returns {import('../index.js').EmailSender}
7
+ */
8
+ export function resend({ apiKey, from, fetch: doFetch = (...args) => fetch(...args) }) {
9
+ if (!apiKey) throw new Error('slauth: resend() needs an apiKey.')
10
+ if (!from) throw new Error('slauth: resend() needs a from address on a domain you verified in Resend.')
11
+ return {
12
+ async send({ to, subject, text, html }) {
13
+ const res = await doFetch('https://api.resend.com/emails', {
14
+ method: 'POST',
15
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
16
+ body: JSON.stringify({ from, to: [to], subject, text, html }),
17
+ })
18
+ if (!res.ok) throw new Error(`slauth: Resend said ${res.status}: ${(await res.text()).slice(0, 200)}`)
19
+ },
20
+ }
21
+ }
@@ -0,0 +1,75 @@
1
+ import { defaultMessage } from './email/message.js'
2
+ import { hmac, safeEqual } from './util.js'
3
+
4
+ export const CODE_MINUTES = 10
5
+ const MAX_TRIES = 5
6
+ const RESEND_SECONDS = 60
7
+
8
+ /**
9
+ * 6-digit codes sent by email: for signing in, and for confirming a new address.
10
+ *
11
+ * code:<email> { hash, expiresAt, tries, sentAt, ... } (lives 10 minutes)
12
+ * code-change:<userId> the same, for a new email address
13
+ *
14
+ * Only an HMAC of the code is stored. Each code allows 5 tries, and a new code can be
15
+ * asked for once a minute per address.
16
+ *
17
+ * @param {{ store: import('./index.js').Store, secret: string, sender: import('./index.js').EmailSender, appName: string, message?: import('./index.js').SlauthConfig['emails'] }} options
18
+ */
19
+ export function createEmailCodes({ store, secret, sender, appName, message }) {
20
+ const hashOf = (email, code) => hmac(secret, `code:${email}:${code}`)
21
+
22
+ return {
23
+ /**
24
+ * @param {string} email
25
+ * @param {{ key?: string, kind?: 'sign-in' | 'email-change', link?: string, data?: object }} [options]
26
+ * @returns {Promise<{ ok: true } | { ok: false, code: 'slow-down', retryAfter: number }>}
27
+ */
28
+ async start(email, { key = `code:${email}`, kind = 'sign-in', link, data = {} } = {}) {
29
+ const existing = await store.get(key)
30
+ const wait = existing ? Math.ceil((existing.sentAt + RESEND_SECONDS * 1000 - Date.now()) / 1000) : 0
31
+ if (wait > 0) return { ok: false, code: 'slow-down', retryAfter: wait }
32
+
33
+ const code = randomCode()
34
+ const now = Date.now()
35
+ await store.put(key, { ...data, email, hash: await hashOf(email, code), expiresAt: now + CODE_MINUTES * 60_000, tries: 0, sentAt: now }, { ttl: CODE_MINUTES * 60 })
36
+ await send({ kind, to: email, code, link, minutes: CODE_MINUTES })
37
+ return { ok: true }
38
+ },
39
+
40
+ /** @returns {Promise<{ ok: true, entry: object } | { ok: false, code: 'expired' | 'too-many-tries' | 'bad-code', triesLeft?: number }>} */
41
+ async verify(email, code, { key = `code:${email}` } = {}) {
42
+ const entry = await store.get(key)
43
+ if (!entry || entry.expiresAt <= Date.now() || (entry.email && entry.email !== email)) return { ok: false, code: 'expired' }
44
+ if (entry.tries >= MAX_TRIES) return { ok: false, code: 'too-many-tries' }
45
+
46
+ const guess = String(code ?? '').replace(/\D/g, '')
47
+ if (guess.length === 6 && safeEqual(await hashOf(email, guess), entry.hash)) {
48
+ await store.delete(key)
49
+ return { ok: true, entry }
50
+ }
51
+ const tries = entry.tries + 1
52
+ const ttl = Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000))
53
+ await store.put(key, { ...entry, tries }, { ttl })
54
+ return tries >= MAX_TRIES ? { ok: false, code: 'too-many-tries' } : { ok: false, code: 'bad-code', triesLeft: MAX_TRIES - tries }
55
+ },
56
+
57
+ send,
58
+ }
59
+
60
+ /** Builds an email (yours, if you passed `emails`) and hands it to the sender. */
61
+ async function send({ kind, to, code, link, ...details }) {
62
+ const data = { code, link, appName, ...details }
63
+ const built = (message && (await message(kind, data))) || defaultMessage(kind, data)
64
+ await sender.send({ to, code, link, appName, kind, ...built })
65
+ }
66
+ }
67
+
68
+ /** Six uniformly random digits (rejection sampling, so no digit is likelier than another). */
69
+ function randomCode() {
70
+ const buf = new Uint32Array(1)
71
+ for (;;) {
72
+ crypto.getRandomValues(buf)
73
+ if (buf[0] < 4_294_000_000) return String(buf[0] % 1_000_000).padStart(6, '0')
74
+ }
75
+ }