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/CHANGELOG.md +72 -0
- package/README.md +46 -9
- package/package.json +31 -7
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +86 -7
- package/src/client.js +99 -6
- package/src/core.js +313 -182
- package/src/element.js +161 -59
- package/src/email/console.js +5 -4
- package/src/email/message.js +50 -10
- package/src/email-code.js +32 -18
- package/src/index.d.ts +221 -6
- package/src/index.js +3 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -0
- package/src/password.js +81 -0
- package/src/providers.d.ts +28 -0
- package/src/providers.js +1 -0
- package/src/rate-limit.js +41 -0
- package/src/routes/account.js +130 -0
- package/src/routes/email.js +147 -0
- package/src/routes/oauth.js +106 -0
- package/src/routes/orgs.js +144 -0
- package/src/routes/passkeys.js +136 -0
- package/src/routes/password.js +78 -0
- package/src/routes/two-factor.js +89 -0
- package/src/session.js +98 -16
- package/src/stores/d1.js +14 -0
- package/src/stores/index.d.ts +2 -2
- package/src/stores/kv.js +9 -0
- package/src/stores/memory.js +5 -0
- package/src/stores/sqlite.js +8 -1
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +92 -22
- package/src/util.js +37 -0
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
|
+
}
|
package/src/password.js
ADDED
|
@@ -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
|
package/src/providers.js
ADDED
|
@@ -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
|
+
}
|