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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +92 -2
- package/package.json +114 -4
- package/src/access.js +143 -0
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +113 -0
- package/src/client.js +211 -0
- package/src/core.js +442 -0
- package/src/element.d.ts +6 -0
- package/src/element.js +253 -0
- package/src/email/cloudflare.d.ts +1 -0
- package/src/email/cloudflare.js +19 -0
- package/src/email/console.d.ts +1 -0
- package/src/email/console.js +14 -0
- package/src/email/index.d.ts +4 -0
- package/src/email/message.js +56 -0
- package/src/email/resend.d.ts +1 -0
- package/src/email/resend.js +21 -0
- package/src/email-code.js +75 -0
- package/src/index.d.ts +364 -0
- package/src/index.js +9 -0
- package/src/lock.js +225 -0
- package/src/node.d.ts +3 -0
- package/src/node.js +67 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -0
- package/src/passkey/cbor.js +99 -0
- package/src/passkey/webauthn.js +183 -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 +131 -0
- package/src/stores/d1.d.ts +1 -0
- package/src/stores/d1.js +58 -0
- package/src/stores/index.d.ts +5 -0
- package/src/stores/kv.d.ts +1 -0
- package/src/stores/kv.js +27 -0
- package/src/stores/memory.d.ts +1 -0
- package/src/stores/memory.js +35 -0
- package/src/stores/sqlite.d.ts +1 -0
- package/src/stores/sqlite.js +42 -0
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +126 -0
- package/src/util.js +106 -0
package/src/users.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomId } from './util.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* People.
|
|
5
|
+
*
|
|
6
|
+
* user:<id> { id, email, name, image, roles, metadata, privateMetadata, createdAt, updatedAt, bannedAt }
|
|
7
|
+
* email:<email> -> id
|
|
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
|
+
*
|
|
12
|
+
* @param {import('./index.js').Store} store
|
|
13
|
+
* @param {undefined | string[] | ((email: string, context: object) => boolean | Promise<boolean>)} allow
|
|
14
|
+
*/
|
|
15
|
+
export function createUsers(store, allow) {
|
|
16
|
+
const list = Array.isArray(allow) ? allow.map((e) => e.trim().toLowerCase()) : null
|
|
17
|
+
|
|
18
|
+
async function allowed(email, context = {}) {
|
|
19
|
+
if (typeof allow === 'function') return Boolean(await allow(email, context))
|
|
20
|
+
if (!list) return true
|
|
21
|
+
// "@family.com" lets in anyone at that domain.
|
|
22
|
+
return list.includes(email) || list.some((entry) => entry.startsWith('@') && email.endsWith(entry))
|
|
23
|
+
}
|
|
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
|
+
|
|
69
|
+
return {
|
|
70
|
+
allowed,
|
|
71
|
+
byId,
|
|
72
|
+
byEmail,
|
|
73
|
+
create,
|
|
74
|
+
update,
|
|
75
|
+
|
|
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 }
|
|
92
|
+
},
|
|
93
|
+
|
|
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}`)
|
|
98
|
+
},
|
|
99
|
+
|
|
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 }
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
}
|
|
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
|
+
|
|
119
|
+
export function normalizeEmail(email) {
|
|
120
|
+
return String(email ?? '').trim().toLowerCase()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Deliberately loose: one @, something either side, a dot in the domain, no spaces. */
|
|
124
|
+
export function validEmail(email) {
|
|
125
|
+
return typeof email === 'string' && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
126
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/** Small helpers shared across slauth. Web Crypto only, so they run anywhere. */
|
|
2
|
+
|
|
3
|
+
export function readCookie(header, name) {
|
|
4
|
+
for (const part of (header ?? '').split(';')) {
|
|
5
|
+
const [key, ...rest] = part.trim().split('=')
|
|
6
|
+
if (key === name) return rest.join('=')
|
|
7
|
+
}
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function randomToken() {
|
|
12
|
+
return base64Url(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function base64Url(buffer) {
|
|
16
|
+
let binary = ''
|
|
17
|
+
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
|
|
18
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Compares without stopping at the first difference, so timing gives nothing away. */
|
|
22
|
+
export function safeEqual(a, b) {
|
|
23
|
+
if (a.length !== b.length) return false
|
|
24
|
+
let diff = 0
|
|
25
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
26
|
+
return diff === 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fromBase64Url(text) {
|
|
30
|
+
const base64 = String(text).replace(/-/g, '+').replace(/_/g, '/')
|
|
31
|
+
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))
|
|
32
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const encoder = new TextEncoder()
|
|
36
|
+
|
|
37
|
+
export async function sha256(data) {
|
|
38
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', typeof data === 'string' ? encoder.encode(data) : data))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function hmac(secret, message) {
|
|
42
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
43
|
+
return base64Url(await crypto.subtle.sign('HMAC', key, encoder.encode(message)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Set-Cookie value. maxAge null makes a browser-session cookie; 0 deletes it. */
|
|
47
|
+
export function serializeCookie(name, value, { maxAge = null, secure = true, sameSite = 'Lax', path = '/' } = {}) {
|
|
48
|
+
const parts = [`${name}=${value}`, `Path=${path}`, 'HttpOnly', `SameSite=${sameSite}`]
|
|
49
|
+
if (maxAge !== null) parts.push(`Max-Age=${maxAge}`)
|
|
50
|
+
if (secure) parts.push('Secure')
|
|
51
|
+
return parts.join('; ')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function json(body, status = 200, headers = {}) {
|
|
55
|
+
return new Response(JSON.stringify(body), {
|
|
56
|
+
status,
|
|
57
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function concat(...parts) {
|
|
62
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
63
|
+
let at = 0
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
out.set(part, at)
|
|
66
|
+
at += part.length
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
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
|
+
}
|