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/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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
106
+ }