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/users.js
CHANGED
|
@@ -1,51 +1,121 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomId } from './util.js'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* People.
|
|
5
|
-
* your own tables, keyed by `user.id`.
|
|
4
|
+
* People.
|
|
6
5
|
*
|
|
7
|
-
* user:<id>
|
|
6
|
+
* user:<id> { id, email, name, image, roles, metadata, privateMetadata, createdAt, updatedAt, bannedAt }
|
|
8
7
|
* email:<email> -> id
|
|
9
8
|
*
|
|
9
|
+
* `metadata` is yours to fill and is sent to the browser; `privateMetadata` never leaves
|
|
10
|
+
* the server. Anything bigger belongs in your own tables, keyed by `user.id`.
|
|
11
|
+
*
|
|
10
12
|
* @param {import('./index.js').Store} store
|
|
11
|
-
* @param {undefined | string[] | ((email: string) => boolean | Promise<boolean>)} allow
|
|
13
|
+
* @param {undefined | string[] | ((email: string, context: object) => boolean | Promise<boolean>)} allow
|
|
12
14
|
*/
|
|
13
15
|
export function createUsers(store, allow) {
|
|
14
16
|
const list = Array.isArray(allow) ? allow.map((e) => e.trim().toLowerCase()) : null
|
|
15
17
|
|
|
16
|
-
async function allowed(email) {
|
|
17
|
-
if (typeof allow === 'function') return Boolean(await allow(email))
|
|
18
|
+
async function allowed(email, context = {}) {
|
|
19
|
+
if (typeof allow === 'function') return Boolean(await allow(email, context))
|
|
18
20
|
if (!list) return true
|
|
19
21
|
// "@family.com" lets in anyone at that domain.
|
|
20
22
|
return list.includes(email) || list.some((entry) => entry.startsWith('@') && email.endsWith(entry))
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
async function byId(id) {
|
|
26
|
+
if (typeof id !== 'string') return null
|
|
27
|
+
const user = await store.get(`user:${id}`)
|
|
28
|
+
return user ? withDefaults(user) : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function byEmail(email) {
|
|
32
|
+
const id = await store.get(`email:${normalizeEmail(email)}`)
|
|
33
|
+
return id ? byId(id) : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Makes a user; no allow-list check, so it's for sign-in flows that already did one, and for admins. */
|
|
37
|
+
async function create({ email: rawEmail, name = null, image = null, roles = [], metadata = {}, privateMetadata = {} }) {
|
|
38
|
+
const email = normalizeEmail(rawEmail)
|
|
39
|
+
if (!validEmail(email)) throw new Error(`slauth: "${rawEmail}" isn't an email address.`)
|
|
40
|
+
if (await store.get(`email:${email}`)) throw new Error(`slauth: there's already a user with ${email}.`)
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
const user = { id: randomId('usr'), email, name, image, roles, metadata, privateMetadata, createdAt: now, updatedAt: now, bannedAt: null }
|
|
43
|
+
await store.put(`user:${user.id}`, user)
|
|
44
|
+
await store.put(`email:${email}`, user.id)
|
|
45
|
+
return user
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Changes what's given; `email` moves the email index too. Returns the new user, or null. */
|
|
49
|
+
async function update(id, patch) {
|
|
50
|
+
const user = await byId(id)
|
|
51
|
+
if (!user) return null
|
|
52
|
+
const next = { ...user, updatedAt: Date.now() }
|
|
53
|
+
for (const key of ['name', 'image', 'roles', 'metadata', 'privateMetadata', 'bannedAt']) if (patch[key] !== undefined) next[key] = patch[key]
|
|
54
|
+
if (patch.email !== undefined) {
|
|
55
|
+
const email = normalizeEmail(patch.email)
|
|
56
|
+
if (!validEmail(email)) throw new Error(`slauth: "${patch.email}" isn't an email address.`)
|
|
57
|
+
if (email !== user.email) {
|
|
58
|
+
const owner = await store.get(`email:${email}`)
|
|
59
|
+
if (owner && owner !== id) throw new Error(`slauth: there's already a user with ${email}.`)
|
|
60
|
+
await store.put(`email:${email}`, id)
|
|
61
|
+
await store.delete(`email:${user.email}`)
|
|
62
|
+
next.email = email
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
await store.put(`user:${id}`, next)
|
|
66
|
+
return next
|
|
67
|
+
}
|
|
68
|
+
|
|
23
69
|
return {
|
|
24
70
|
allowed,
|
|
71
|
+
byId,
|
|
72
|
+
byEmail,
|
|
73
|
+
create,
|
|
74
|
+
update,
|
|
25
75
|
|
|
26
|
-
|
|
27
|
-
|
|
76
|
+
/**
|
|
77
|
+
* The user with this email, made on first sign-in. Null when they aren't allowed in.
|
|
78
|
+
* `profile` fills in a name and picture the first time (e.g. from GitHub).
|
|
79
|
+
*/
|
|
80
|
+
async signIn(rawEmail, context = {}, profile = {}) {
|
|
81
|
+
const email = normalizeEmail(rawEmail)
|
|
82
|
+
if (!(await allowed(email, context))) return null
|
|
83
|
+
const existing = await byEmail(email)
|
|
84
|
+
if (existing) {
|
|
85
|
+
const fill = {}
|
|
86
|
+
if (!existing.name && profile.name) fill.name = profile.name
|
|
87
|
+
if (!existing.image && profile.image) fill.image = profile.image
|
|
88
|
+
return Object.keys(fill).length ? update(existing.id, fill) : existing
|
|
89
|
+
}
|
|
90
|
+
const user = await create({ email, name: profile.name ?? null, image: profile.image ?? null })
|
|
91
|
+
return { ...user, isNew: true }
|
|
28
92
|
},
|
|
29
93
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
94
|
+
/** Forgets the user record and email; core.js removes everything hanging off it. */
|
|
95
|
+
async remove(user) {
|
|
96
|
+
await store.delete(`email:${user.email}`)
|
|
97
|
+
await store.delete(`user:${user.id}`)
|
|
33
98
|
},
|
|
34
99
|
|
|
35
|
-
/**
|
|
36
|
-
async
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (existing) return existing
|
|
41
|
-
const user = { id: `usr_${base64Url(crypto.getRandomValues(new Uint8Array(15)))}`, email, createdAt: Date.now() }
|
|
42
|
-
await store.put(`user:${user.id}`, user)
|
|
43
|
-
await store.put(`email:${email}`, user.id)
|
|
44
|
-
return user
|
|
100
|
+
/** Every user, a page at a time. Needs a store with list() (all the built-in ones). */
|
|
101
|
+
async list({ limit = 100, cursor } = {}) {
|
|
102
|
+
if (typeof store.list !== 'function') throw new Error("slauth: listing users needs a store with list(); the built-in stores all have one.")
|
|
103
|
+
const page = await store.list('user:', { limit, cursor })
|
|
104
|
+
return { users: page.items.map((item) => withDefaults(item.value)), cursor: page.cursor }
|
|
45
105
|
},
|
|
46
106
|
}
|
|
47
107
|
}
|
|
48
108
|
|
|
109
|
+
/** Users saved by slauth 1.0 only had { id, email, createdAt }. */
|
|
110
|
+
function withDefaults(user) {
|
|
111
|
+
return { name: null, image: null, roles: [], metadata: {}, privateMetadata: {}, updatedAt: user.createdAt, bannedAt: null, ...user }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** What the browser (and your app, via auth.user) sees. */
|
|
115
|
+
export function publicUser(user) {
|
|
116
|
+
return { id: user.id, email: user.email, name: user.name ?? null, image: user.image ?? null, roles: user.roles ?? [], metadata: user.metadata ?? {}, createdAt: user.createdAt }
|
|
117
|
+
}
|
|
118
|
+
|
|
49
119
|
export function normalizeEmail(email) {
|
|
50
120
|
return String(email ?? '').trim().toLowerCase()
|
|
51
121
|
}
|
package/src/util.js
CHANGED
|
@@ -67,3 +67,40 @@ export function concat(...parts) {
|
|
|
67
67
|
}
|
|
68
68
|
return out
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/** A random, URL-safe id with a readable prefix, e.g. id('usr') → "usr_k3J…". */
|
|
72
|
+
export function randomId(prefix, bytes = 15) {
|
|
73
|
+
return `${prefix}_${base64Url(crypto.getRandomValues(new Uint8Array(bytes)))}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A 302 to `location`, with any Set-Cookie values. */
|
|
77
|
+
export function redirect(location, cookies = []) {
|
|
78
|
+
const headers = new Headers({ Location: location, 'Cache-Control': 'no-store' })
|
|
79
|
+
for (const c of cookies) headers.append('Set-Cookie', c)
|
|
80
|
+
return new Response(null, { status: 302, headers })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Only paths on this site ("/dashboard?tab=1"), never "//evil.example" or
|
|
85
|
+
* "https://evil.example", so a sign-in link can't send someone elsewhere.
|
|
86
|
+
*/
|
|
87
|
+
export function safeRedirect(path, fallback = '/') {
|
|
88
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.startsWith('/\\') || /[\r\n]/.test(path)) return fallback
|
|
89
|
+
return path
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Adds query parameters to a same-site path. */
|
|
93
|
+
export function withParams(path, params) {
|
|
94
|
+
const url = new URL(path, 'http://x')
|
|
95
|
+
for (const [key, value] of Object.entries(params)) if (value != null) url.searchParams.set(key, value)
|
|
96
|
+
return url.pathname + url.search + url.hash
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The visitor's IP from the headers hosts set for it, or null when there's none we can trust. */
|
|
100
|
+
export function clientIp(request) {
|
|
101
|
+
return request.headers.get('cf-connecting-ip') ?? request.headers.get('fly-client-ip') ?? request.headers.get('x-real-ip') ?? null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function escapeHtml(text) {
|
|
105
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
106
|
+
}
|