slauthjs 1.0.0 → 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.
package/src/orgs.js ADDED
@@ -0,0 +1,184 @@
1
+ import { randomId } from './util.js'
2
+ import { normalizeEmail } from './users.js'
3
+
4
+ const INVITE_DAYS = 7
5
+ /** Roles that can manage an organization. Any other role string is just a label for your app. */
6
+ const MANAGERS = new Set(['owner', 'admin'])
7
+
8
+ /**
9
+ * Organizations (teams, workspaces, families: whatever your app calls them).
10
+ *
11
+ * org:<orgId> { id, name, metadata, createdAt, createdBy }
12
+ * members:<orgId> [{ userId, role, joinedAt }]
13
+ * orgs:<userId> [orgId, ...]
14
+ * invite:<inviteId> { id, orgId, email, role, invitedBy, createdAt, expiresAt }
15
+ * invites:<email> [inviteId, ...]
16
+ * org-invites:<orgId> [inviteId, ...]
17
+ *
18
+ * Roles: "owner" (everything, including deleting the org), "admin" (members and
19
+ * invitations), and anything else you like ("member", "billing", "viewer").
20
+ *
21
+ * @param {import('./index.js').Store} store
22
+ */
23
+ export function createOrgs(store) {
24
+ const list = async (key) => (await store.get(key)) ?? []
25
+ const save = async (key, items) => (items.length ? store.put(key, items) : store.delete(key))
26
+
27
+ async function get(orgId) {
28
+ return typeof orgId === 'string' ? store.get(`org:${orgId}`) : null
29
+ }
30
+
31
+ async function members(orgId) {
32
+ return list(`members:${orgId}`)
33
+ }
34
+
35
+ async function role(orgId, userId) {
36
+ return (await members(orgId)).find((m) => m.userId === userId)?.role ?? null
37
+ }
38
+
39
+ async function addMember(orgId, userId, memberRole = 'member') {
40
+ const current = await members(orgId)
41
+ if (current.some((m) => m.userId === userId)) return setRole(orgId, userId, memberRole)
42
+ await store.put(`members:${orgId}`, [...current, { userId, role: memberRole, joinedAt: Date.now() }])
43
+ const mine = await list(`orgs:${userId}`)
44
+ if (!mine.includes(orgId)) await store.put(`orgs:${userId}`, [...mine, orgId])
45
+ return true
46
+ }
47
+
48
+ async function setRole(orgId, userId, memberRole) {
49
+ const current = await members(orgId)
50
+ const member = current.find((m) => m.userId === userId)
51
+ if (!member) return false
52
+ if (member.role === 'owner' && memberRole !== 'owner' && current.filter((m) => m.role === 'owner').length === 1) {
53
+ throw new OrgError('An organization needs at least one owner.', 'last-owner')
54
+ }
55
+ await store.put(`members:${orgId}`, current.map((m) => (m.userId === userId ? { ...m, role: memberRole } : m)))
56
+ return true
57
+ }
58
+
59
+ async function removeMember(orgId, userId) {
60
+ const current = await members(orgId)
61
+ const member = current.find((m) => m.userId === userId)
62
+ if (!member) return false
63
+ if (member.role === 'owner' && current.filter((m) => m.role === 'owner').length === 1 && current.length > 1) {
64
+ throw new OrgError('Make someone else an owner first.', 'last-owner')
65
+ }
66
+ await save(`members:${orgId}`, current.filter((m) => m.userId !== userId))
67
+ await save(`orgs:${userId}`, (await list(`orgs:${userId}`)).filter((id) => id !== orgId))
68
+ // The last person out turns off the lights.
69
+ if (current.length === 1) await remove(orgId)
70
+ return true
71
+ }
72
+
73
+ async function remove(orgId) {
74
+ for (const m of await members(orgId)) await save(`orgs:${m.userId}`, (await list(`orgs:${m.userId}`)).filter((id) => id !== orgId))
75
+ for (const id of await list(`org-invites:${orgId}`)) await revokeInvite(id)
76
+ await store.delete(`members:${orgId}`)
77
+ await store.delete(`org-invites:${orgId}`)
78
+ await store.delete(`org:${orgId}`)
79
+ }
80
+
81
+ async function invite(orgId, email, memberRole = 'member', invitedBy = null) {
82
+ const to = normalizeEmail(email)
83
+ const id = randomId('inv')
84
+ const now = Date.now()
85
+ const doc = { id, orgId, email: to, role: memberRole, invitedBy, createdAt: now, expiresAt: now + INVITE_DAYS * 86_400_000 }
86
+ await store.put(`invite:${id}`, doc, { ttl: INVITE_DAYS * 86_400 })
87
+ await store.put(`invites:${to}`, [...(await list(`invites:${to}`)), id])
88
+ await store.put(`org-invites:${orgId}`, [...(await list(`org-invites:${orgId}`)), id])
89
+ return doc
90
+ }
91
+
92
+ async function revokeInvite(inviteId) {
93
+ const doc = typeof inviteId === 'string' ? await store.get(`invite:${inviteId}`) : null
94
+ if (!doc) return false
95
+ await store.delete(`invite:${inviteId}`)
96
+ await save(`invites:${doc.email}`, (await list(`invites:${doc.email}`)).filter((id) => id !== inviteId))
97
+ await save(`org-invites:${doc.orgId}`, (await list(`org-invites:${doc.orgId}`)).filter((id) => id !== inviteId))
98
+ return true
99
+ }
100
+
101
+ async function live(ids) {
102
+ const out = []
103
+ for (const id of ids) {
104
+ const doc = await store.get(`invite:${id}`)
105
+ if (doc && doc.expiresAt > Date.now()) out.push(doc)
106
+ }
107
+ return out
108
+ }
109
+
110
+ return {
111
+ get,
112
+ members,
113
+ role,
114
+ addMember,
115
+ setRole,
116
+ removeMember,
117
+ remove,
118
+ invite,
119
+ revokeInvite,
120
+
121
+ async create({ name, metadata = {} }, ownerId) {
122
+ const org = { id: randomId('org'), name: String(name).trim().slice(0, 100), metadata, createdAt: Date.now(), createdBy: ownerId ?? null }
123
+ await store.put(`org:${org.id}`, org)
124
+ if (ownerId) await addMember(org.id, ownerId, 'owner')
125
+ return org
126
+ },
127
+
128
+ async update(orgId, patch) {
129
+ const org = await get(orgId)
130
+ if (!org) return null
131
+ const next = { ...org }
132
+ if (typeof patch.name === 'string' && patch.name.trim()) next.name = patch.name.trim().slice(0, 100)
133
+ if (patch.metadata !== undefined) next.metadata = patch.metadata
134
+ await store.put(`org:${orgId}`, next)
135
+ return next
136
+ },
137
+
138
+ /** The organizations someone belongs to, each with their role. */
139
+ async forUser(userId) {
140
+ const out = []
141
+ for (const id of await list(`orgs:${userId}`)) {
142
+ const org = await get(id)
143
+ const r = org && (await role(id, userId))
144
+ if (r) out.push({ ...org, role: r })
145
+ }
146
+ return out
147
+ },
148
+
149
+ invitesFor: async (email) => live(await list(`invites:${normalizeEmail(email)}`)),
150
+ invitesOf: async (orgId) => live(await list(`org-invites:${orgId}`)),
151
+ getInvite: async (inviteId) => (typeof inviteId === 'string' ? store.get(`invite:${inviteId}`) : null),
152
+
153
+ /** Accepts an invitation addressed to this user's email. */
154
+ async accept(inviteId, user) {
155
+ const doc = typeof inviteId === 'string' ? await store.get(`invite:${inviteId}`) : null
156
+ if (!doc || doc.email !== user.email || doc.expiresAt <= Date.now() || !(await get(doc.orgId))) return null
157
+ await addMember(doc.orgId, user.id, doc.role)
158
+ await revokeInvite(inviteId)
159
+ return doc
160
+ },
161
+
162
+ /** Called when a user is deleted. */
163
+ async forget(userId) {
164
+ for (const id of await list(`orgs:${userId}`)) {
165
+ const current = await members(id)
166
+ const rest = current.filter((m) => m.userId !== userId)
167
+ // Hand ownership on to the longest-standing member rather than leave it ownerless.
168
+ if (rest.length && !rest.some((m) => m.role === 'owner')) rest[0] = { ...rest[0], role: 'owner' }
169
+ if (rest.length) await store.put(`members:${id}`, rest)
170
+ else await remove(id)
171
+ }
172
+ await store.delete(`orgs:${userId}`)
173
+ },
174
+
175
+ canManage: (memberRole) => MANAGERS.has(memberRole),
176
+ }
177
+ }
178
+
179
+ export class OrgError extends Error {
180
+ constructor(message, code) {
181
+ super(message)
182
+ this.code = code
183
+ }
184
+ }
@@ -0,0 +1,81 @@
1
+ import { base64Url, fromBase64Url, safeEqual } from './util.js'
2
+
3
+ const MAX_FAILURES = 10
4
+ const FAILURE_WINDOW_SECONDS = 15 * 60
5
+
6
+ /**
7
+ * Optional passwords, hashed with PBKDF2-SHA256 from Web Crypto. 100,000 rounds by
8
+ * default: the most Cloudflare Workers allows. Raise `iterations` elsewhere if you like;
9
+ * old hashes keep working because each one records its own count.
10
+ *
11
+ * password:<userId> { hash: "pbkdf2-sha256$<iterations>$<salt>$<hash>", updatedAt }
12
+ * pwfail:<email> { count } (10 wrong guesses → wait 15 minutes)
13
+ *
14
+ * People only get a password after proving their email (a code, a link or a provider),
15
+ * so "forgot password" is just "sign in with a code, then set a new one".
16
+ *
17
+ * @param {{ store: import('./index.js').Store, iterations?: number, minLength?: number, validate?: (password: string) => string | null | Promise<string | null> }} options
18
+ */
19
+ export function createPasswords({ store, iterations = 100_000, minLength = 8, validate }) {
20
+ // Run a real hash even for unknown emails, so timing doesn't reveal who has an account.
21
+ let decoy = null
22
+
23
+ return {
24
+ /** An error message when the password won't do, else null. */
25
+ async problem(password) {
26
+ if (typeof password !== 'string' || password.length < minLength) return `Passwords need at least ${minLength} characters.`
27
+ if (password.length > 256) return 'That password is too long.'
28
+ return validate ? ((await validate(password)) ?? null) : null
29
+ },
30
+
31
+ hash: (password) => hashPassword(password, iterations),
32
+
33
+ async set(userId, password) {
34
+ await store.put(`password:${userId}`, { hash: await hashPassword(password, iterations), updatedAt: Date.now() })
35
+ },
36
+
37
+ async has(userId) {
38
+ return Boolean(await store.get(`password:${userId}`))
39
+ },
40
+
41
+ async remove(userId) {
42
+ await store.delete(`password:${userId}`)
43
+ },
44
+
45
+ /** Checks a guess for a user (or null for "no such user"), counting failures per email. */
46
+ async check(email, userId, password) {
47
+ const failures = (await store.get(`pwfail:${email}`)) ?? { count: 0 }
48
+ if (failures.count >= MAX_FAILURES) return { ok: false, code: 'too-many-tries' }
49
+ const stored = userId ? await store.get(`password:${userId}`) : null
50
+ decoy ??= await hashPassword('decoy-password', iterations)
51
+ const ok = await verifyPassword(String(password ?? ''), stored?.hash ?? decoy)
52
+ if (ok && stored) {
53
+ await store.delete(`pwfail:${email}`)
54
+ return { ok: true }
55
+ }
56
+ await store.put(`pwfail:${email}`, { count: failures.count + 1 }, { ttl: FAILURE_WINDOW_SECONDS })
57
+ return { ok: false, code: 'bad-password' }
58
+ },
59
+
60
+ async verify(userId, password) {
61
+ const stored = await store.get(`password:${userId}`)
62
+ return Boolean(stored) && verifyPassword(String(password ?? ''), stored.hash)
63
+ },
64
+ }
65
+ }
66
+
67
+ export async function hashPassword(password, iterations = 100_000) {
68
+ const salt = crypto.getRandomValues(new Uint8Array(16))
69
+ return `pbkdf2-sha256$${iterations}$${base64Url(salt)}$${base64Url(await derive(password, salt, iterations))}`
70
+ }
71
+
72
+ export async function verifyPassword(password, stored) {
73
+ const [scheme, rounds, salt, hash] = String(stored).split('$')
74
+ if (scheme !== 'pbkdf2-sha256') return false
75
+ return safeEqual(base64Url(await derive(password, fromBase64Url(salt), Number(rounds))), hash)
76
+ }
77
+
78
+ async function derive(password, salt, iterations) {
79
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password.normalize('NFKC')), 'PBKDF2', false, ['deriveBits'])
80
+ return crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, 256)
81
+ }
@@ -0,0 +1,28 @@
1
+ import type { Profile, Provider } from './index.js'
2
+
3
+ interface Common {
4
+ clientId: string
5
+ clientSecret?: string
6
+ /** Count the provider's email as verified even when it doesn't say so. */
7
+ trustEmail?: boolean
8
+ /** Save access and refresh tokens, for auth.accounts.list(). */
9
+ keepTokens?: boolean
10
+ scope?: string
11
+ params?: Record<string, string>
12
+ }
13
+
14
+ export function oauth2(options: Common & {
15
+ id: string
16
+ name: string
17
+ authorization: string
18
+ token: string
19
+ pkce?: boolean
20
+ tokenAuth?: 'post' | 'basic'
21
+ profile(tokens: Record<string, any>, fetch: typeof globalThis.fetch): Promise<Profile>
22
+ }): Provider
23
+ export function oidc(options: Common & { id: string; name: string; issuer: string; tokenAuth?: 'post' | 'basic' }): Provider
24
+ export function github(options: Common): Provider
25
+ export function google(options: Common): Provider
26
+ export function discord(options: Common): Provider
27
+ export function gitlab(options: Common & { baseUrl?: string }): Provider
28
+ export function microsoft(options: Common & { tenant?: string }): Provider
@@ -0,0 +1 @@
1
+ export { oauth2, oidc, github, google, discord, gitlab, microsoft } from './oauth/providers.js'
@@ -0,0 +1,41 @@
1
+ import { clientIp } from './util.js'
2
+
3
+ /** Per-IP limits on the routes that send email or check a guess: [requests, per seconds]. */
4
+ export const DEFAULT_LIMITS = {
5
+ 'email/start': [10, 600],
6
+ 'password/signin': [20, 600],
7
+ 'password/signup': [10, 600],
8
+ 'two-factor/verify': [20, 600],
9
+ }
10
+
11
+ /**
12
+ * A fixed-window counter per IP and route, kept in the store.
13
+ *
14
+ * rate:<route>:<ip>:<window> count
15
+ *
16
+ * Off when no IP can be trusted (see `ip` in the config): the per-email and per-code
17
+ * limits still apply then.
18
+ *
19
+ * @param {import('./index.js').Store} store
20
+ * @param {false | { limits?: Record<string, [number, number]>, ip?: (request: Request) => string | null }} [options]
21
+ */
22
+ export function createRateLimit(store, options = {}) {
23
+ if (options === false) return { check: async () => null }
24
+ const limits = { ...DEFAULT_LIMITS, ...options.limits }
25
+ const ipOf = options.ip ?? clientIp
26
+
27
+ return {
28
+ /** Null when it's fine, or how many seconds to wait. */
29
+ async check(route, request) {
30
+ const limit = limits[route]
31
+ const ip = limit && ipOf(request)
32
+ if (!ip) return null
33
+ const [max, seconds] = limit
34
+ const window = Math.floor(Date.now() / 1000 / seconds)
35
+ const key = `rate:${route}:${ip}:${window}`
36
+ const count = ((await store.get(key)) ?? 0) + 1
37
+ await store.put(key, count, { ttl: seconds })
38
+ return count > max ? (window + 1) * seconds - Math.floor(Date.now() / 1000) : null
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,130 @@
1
+ import { publicUser } from '../users.js'
2
+ import { json } from '../util.js'
3
+
4
+ /**
5
+ * The signed-in person's own account: the session, their profile, their other
6
+ * sessions, linked providers, and tokens for your APIs.
7
+ *
8
+ * @param {object} app the shared context from core.js
9
+ */
10
+ export function accountRoutes(app) {
11
+ const { store, sessions, users, lock, tokens } = app
12
+
13
+ return async function route({ route, method, request, url, body }) {
14
+ if (route === 'session' && method === 'GET') {
15
+ const me = await app.current(request)
16
+ const headers = {}
17
+ if (me?.session) {
18
+ const refreshed = await sessions.refresh(me.session, request)
19
+ if (refreshed) headers['Set-Cookie'] = refreshed
20
+ }
21
+ const out = { user: me ? publicUser(me.user) : null }
22
+ if (me?.session?.impersonatorId) out.impersonatorId = me.session.impersonatorId
23
+ if (!me && app.twoFactor && (await app.pending(request))) out.twoFactor = 'pending'
24
+ if (lock && me) out.lock = (await lock.handle('session', 'GET', body, { email: me.user.email, userId: me.user.id }, request.headers.get('cookie'), url.protocol === 'https:')).body
25
+ return json(out, 200, headers)
26
+ }
27
+
28
+ // What the sign-in box should offer.
29
+ if (route === 'config' && method === 'GET') {
30
+ return json({
31
+ appName: app.appName,
32
+ emailCodes: true,
33
+ magicLinks: Boolean(app.magicLinks),
34
+ passkeys: app.passkeys !== false,
35
+ passwords: Boolean(app.passwords),
36
+ twoFactor: Boolean(app.twoFactor),
37
+ organizations: Boolean(app.orgs),
38
+ providers: app.providers.map((p) => ({ id: p.id, name: p.name })),
39
+ })
40
+ }
41
+
42
+ if (route === 'signout' && method === 'POST') {
43
+ const me = await app.current(request)
44
+ const cookie = await sessions.destroy(request)
45
+ if (me) await app.emit('signOut', { user: publicUser(me.user), request })
46
+ return json({ ok: true }, 200, { 'Set-Cookie': cookie })
47
+ }
48
+
49
+ if (route === 'jwks.json' && method === 'GET' && tokens) {
50
+ return new Response(JSON.stringify(await tokens.jwks()), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' } })
51
+ }
52
+
53
+ const mine = new Set(['sessions', 'sessions/revoke', 'sessions/revoke-others', 'user', 'user/delete', 'accounts', 'accounts/unlink', 'token'])
54
+ if (!mine.has(route)) return
55
+
56
+ const me = await app.signedIn(request)
57
+ if (me instanceof Response) return me
58
+ const { user, session } = me
59
+
60
+ if (route === 'token' && method === 'GET' && tokens) {
61
+ return json(await tokens.create(publicUser(user)))
62
+ }
63
+
64
+ if (route === 'sessions' && method === 'GET') {
65
+ const list = await sessions.list(user.id)
66
+ return json({ sessions: list.map((s) => ({ ...s, current: s.id === session?.id })) })
67
+ }
68
+
69
+ if (route === 'sessions/revoke' && method === 'POST') {
70
+ const { id } = await body()
71
+ if (!(await sessions.revoke(user.id, id))) return json({ error: 'No such session.', code: 'not-found' }, 404)
72
+ return json({ ok: true })
73
+ }
74
+
75
+ if (route === 'sessions/revoke-others' && method === 'POST') {
76
+ await sessions.revokeAll(user.id, session?.id)
77
+ return json({ ok: true })
78
+ }
79
+
80
+ if (route === 'user' && method === 'POST') {
81
+ const input = await body()
82
+ const patch = {}
83
+ if (input.name !== undefined) {
84
+ if (input.name !== null && typeof input.name !== 'string') return json({ error: 'A name is text.', code: 'bad-request' }, 400)
85
+ patch.name = input.name?.trim().slice(0, 100) || null
86
+ }
87
+ if (input.image !== undefined) {
88
+ if (input.image !== null && !(typeof input.image === 'string' && /^https:\/\/\S+$/.test(input.image) && input.image.length <= 2000)) {
89
+ return json({ error: 'A picture is an https:// link.', code: 'bad-request' }, 400)
90
+ }
91
+ patch.image = input.image
92
+ }
93
+ const updated = await users.update(user.id, patch)
94
+ await app.emit('userUpdated', { user: publicUser(updated) })
95
+ return json({ user: publicUser(updated) })
96
+ }
97
+
98
+ if (route === 'user/delete' && method === 'POST') {
99
+ if (app.config.allowDeletion === false) return json({ error: "Accounts can't be deleted here.", code: 'not-found' }, 404)
100
+ if (!app.fresh(session)) return app.reauth()
101
+ await app.deleteUser(user.id)
102
+ return json({ ok: true }, 200, { 'Set-Cookie': sessions.clear(request) })
103
+ }
104
+
105
+ if (route === 'accounts' && method === 'GET') {
106
+ const out = []
107
+ for (const key of (await store.get(`accounts:${user.id}`)) ?? []) {
108
+ const doc = await store.get(`account:${key}`)
109
+ if (doc) out.push({ provider: doc.provider, name: app.providers.find((p) => p.id === doc.provider)?.name ?? doc.provider, email: doc.email ?? null, username: doc.name ?? null, createdAt: doc.createdAt })
110
+ }
111
+ return json({
112
+ accounts: out,
113
+ password: app.passwords ? await app.passwords.has(user.id) : false,
114
+ twoFactor: app.twoFactor ? await app.twoFactor.enabled(user.id) : false,
115
+ })
116
+ }
117
+
118
+ if (route === 'accounts/unlink' && method === 'POST') {
119
+ const { provider } = await body()
120
+ const keys = (await store.get(`accounts:${user.id}`)) ?? []
121
+ const key = keys.find((k) => k.split(':')[0] === provider)
122
+ if (!key) return json({ error: "That account isn't linked.", code: 'not-found' }, 404)
123
+ await store.delete(`account:${key}`)
124
+ const rest = keys.filter((k) => k !== key)
125
+ if (rest.length) await store.put(`accounts:${user.id}`, rest)
126
+ else await store.delete(`accounts:${user.id}`)
127
+ return json({ ok: true })
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,147 @@
1
+ import { CODE_MINUTES } from '../email-code.js'
2
+ import { normalizeEmail, publicUser, validEmail } from '../users.js'
3
+ import { base64Url, escapeHtml, json, randomToken, safeRedirect, sha256 } from '../util.js'
4
+
5
+ const MESSAGES = { expired: 'That code has expired. Ask for a new one.', 'too-many-tries': 'Too many tries. Ask for a new code.', 'bad-code': 'That code is wrong.' }
6
+
7
+ /**
8
+ * Email codes (and, with magicLinks on, a link in the same email), and changing the
9
+ * address on an account.
10
+ *
11
+ * link:<sha256 of token> { email, redirect } (lives as long as the code)
12
+ * signup:<email> { hash } (a password to set once the code is used)
13
+ *
14
+ * @param {object} app the shared context from core.js
15
+ */
16
+ export function emailRoutes(app) {
17
+ const { store, users, codes, limits } = app
18
+ const notAllowed = () => json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
19
+ const badEmail = () => json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
20
+ const slowDown = (retryAfter) => json({ error: `Please wait ${retryAfter}s before trying again.`, code: 'slow-down', retryAfter }, 429)
21
+ const failed = (result) => json({ error: MESSAGES[result.code], code: result.code, triesLeft: result.triesLeft }, result.code === 'bad-code' ? 400 : 410)
22
+
23
+ /** Sends a sign-in code (and link) to an address that's allowed in. Shared with password sign-up. */
24
+ async function sendSignInCode(email, url, redirect) {
25
+ let link
26
+ let data = {}
27
+ if (app.magicLinks) {
28
+ const token = randomToken()
29
+ const linkId = base64Url(await sha256(token))
30
+ link = `${app.origin(url)}${app.prefix}/email/link?token=${token}`
31
+ data = { linkId }
32
+ const sent = await codes.start(email, { link, data })
33
+ if (sent.ok) await store.put(`link:${linkId}`, { email, redirect: safeRedirect(redirect, null) }, { ttl: CODE_MINUTES * 60 })
34
+ return sent
35
+ }
36
+ return codes.start(email, { data })
37
+ }
38
+ app.sendSignInCode = sendSignInCode
39
+
40
+ return async function route({ route, method, request, url, body }) {
41
+ if (route === 'email/start' && method === 'POST') {
42
+ const input = await body()
43
+ const email = normalizeEmail(input.email)
44
+ if (!validEmail(email)) return badEmail()
45
+ const wait = await limits.check('email/start', request)
46
+ if (wait) return slowDown(wait)
47
+ if (!(await users.allowed(email, { method: 'email' }))) return notAllowed()
48
+ const sent = await sendSignInCode(email, url, input.redirect)
49
+ if (!sent.ok) return slowDown(sent.retryAfter)
50
+ return json({ ok: true })
51
+ }
52
+
53
+ if (route === 'email/verify' && method === 'POST') {
54
+ const input = await body()
55
+ const email = normalizeEmail(input.email)
56
+ if (!validEmail(email)) return badEmail()
57
+ const result = await codes.verify(email, input.code)
58
+ if (!result.ok) return failed(result)
59
+ if (result.entry.linkId) await store.delete(`link:${result.entry.linkId}`)
60
+ const found = await users.signIn(email, { method: 'email' })
61
+ if (!found) return notAllowed()
62
+ await finishSignup(found)
63
+ return app.reply(await app.complete(found, request, { method: 'email' }), {}, request)
64
+ }
65
+
66
+ // The link in the email opens a page with a button, rather than signing in on GET:
67
+ // mail scanners open links to check them, and would use the link up.
68
+ if (route === 'email/link' && method === 'GET') {
69
+ return new Response(linkPage(app.appName, `${app.prefix}/email/link`), {
70
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'" },
71
+ })
72
+ }
73
+
74
+ if (route === 'email/link' && method === 'POST') {
75
+ const { token } = await body()
76
+ const linkId = typeof token === 'string' && token.length <= 128 ? base64Url(await sha256(token)) : null
77
+ const link = linkId ? await store.get(`link:${linkId}`) : null
78
+ if (!link) return json({ error: 'That link has expired or was already used. Ask for a new one.', code: 'expired' }, 410)
79
+ await store.delete(`link:${linkId}`)
80
+ const code = await store.get(`code:${link.email}`)
81
+ if (code?.linkId === linkId) await store.delete(`code:${link.email}`)
82
+ const found = await users.signIn(link.email, { method: 'link' })
83
+ if (!found) return notAllowed()
84
+ await finishSignup(found)
85
+ const result = await app.complete(found, request, { method: 'link' })
86
+ const to = result.kind === 'two-factor' ? (app.signInPage ?? link.redirect ?? '/') : (link.redirect ?? '/')
87
+ return app.reply(result, { redirect: to }, request)
88
+ }
89
+
90
+ if (route === 'email/change/start' && method === 'POST') {
91
+ const me = await app.signedIn(request)
92
+ if (me instanceof Response) return me
93
+ if (!app.fresh(me.session)) return app.reauth()
94
+ const email = normalizeEmail((await body()).email)
95
+ if (!validEmail(email)) return badEmail()
96
+ if (email === me.user.email) return json({ error: "That's already your email.", code: 'same-email' }, 400)
97
+ if (!(await users.allowed(email, { method: 'email-change', user: publicUser(me.user) }))) return notAllowed()
98
+ if (await users.byEmail(email)) return json({ error: 'Another account uses that email.', code: 'email-taken' }, 409)
99
+ const sent = await codes.start(email, { key: `code-change:${me.user.id}`, kind: 'email-change' })
100
+ if (!sent.ok) return slowDown(sent.retryAfter)
101
+ return json({ ok: true })
102
+ }
103
+
104
+ if (route === 'email/change/verify' && method === 'POST') {
105
+ const me = await app.signedIn(request)
106
+ if (me instanceof Response) return me
107
+ const input = await body()
108
+ const email = normalizeEmail(input.email)
109
+ if (!validEmail(email)) return badEmail()
110
+ const result = await codes.verify(email, input.code, { key: `code-change:${me.user.id}` })
111
+ if (!result.ok) return failed(result)
112
+ if (await users.byEmail(email)) return json({ error: 'Another account uses that email.', code: 'email-taken' }, 409)
113
+ const updated = await users.update(me.user.id, { email })
114
+ await app.emit('userUpdated', { user: publicUser(updated) })
115
+ return json({ user: publicUser(updated) })
116
+ }
117
+ }
118
+
119
+ /** Someone who signed up with a password has now proved their email: save the password. */
120
+ async function finishSignup(found) {
121
+ const pending = await store.get(`signup:${found.email}`)
122
+ if (!pending) return
123
+ await store.delete(`signup:${found.email}`)
124
+ await store.put(`password:${found.id}`, { hash: pending.hash, updatedAt: Date.now() })
125
+ }
126
+ }
127
+
128
+ function linkPage(appName, endpoint) {
129
+ const name = escapeHtml(appName)
130
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in to ${name}</title>
131
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#fdf6ec;color:#2b2118;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
132
+ main{max-width:360px;margin:16px;background:#fff;border:2px solid #2b2118;border-radius:16px;padding:28px;box-shadow:4px 4px 0 #2b2118;text-align:center}
133
+ button{font:inherit;font-weight:700;font-size:16px;background:#f59e0b;color:#2b2118;border:2px solid #2b2118;border-radius:10px;padding:12px 18px;width:100%;cursor:pointer}
134
+ p{opacity:.8}.error{color:#c2410c;font-weight:600;opacity:1}</style></head>
135
+ <body><main><h1 style="font-size:22px;margin:0 0 8px">Sign in to ${name}</h1><p>You're one tap away.</p>
136
+ <button id="go">Continue</button><p id="msg" class="error" role="alert"></p></main>
137
+ <script>
138
+ const token = new URLSearchParams(location.search).get('token')
139
+ document.getElementById('go').addEventListener('click', async (e) => {
140
+ e.target.disabled = true
141
+ const res = await fetch(${JSON.stringify(endpoint)}, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => null)
142
+ const data = res ? await res.json().catch(() => ({})) : { error: "Couldn't reach the server." }
143
+ if (res && res.ok) location.replace(data.redirect || '/')
144
+ else { document.getElementById('msg').textContent = data.error || 'Something went wrong.'; e.target.disabled = false }
145
+ })
146
+ </script></body></html>`
147
+ }