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/core.js ADDED
@@ -0,0 +1,442 @@
1
+ import { createAccess } from './access.js'
2
+ import { createEmailCodes } from './email-code.js'
3
+ import { consoleEmail } from './email/console.js'
4
+ import { createLock } from './lock.js'
5
+ import { createOAuthFlow } from './oauth/flow.js'
6
+ import { createOrgs, OrgError } from './orgs.js'
7
+ import { PasskeyError } from './passkey/webauthn.js'
8
+ import { createPasswords } from './password.js'
9
+ import { createRateLimit } from './rate-limit.js'
10
+ import { accountRoutes } from './routes/account.js'
11
+ import { emailRoutes } from './routes/email.js'
12
+ import { oauthRoutes } from './routes/oauth.js'
13
+ import { orgRoutes } from './routes/orgs.js'
14
+ import { passkeyRoutes } from './routes/passkeys.js'
15
+ import { passwordRoutes } from './routes/password.js'
16
+ import { twoFactorRoutes } from './routes/two-factor.js'
17
+ import { createSessions } from './session.js'
18
+ import { createTokens } from './tokens.js'
19
+ import { createTwoFactor } from './two-factor.js'
20
+ import { createUsers, publicUser } from './users.js'
21
+ import { base64Url, json, randomToken, readCookie, serializeCookie, sha256 } from './util.js'
22
+
23
+ const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
24
+ const PENDING_SECONDS = 10 * 60
25
+ const PENDING_COOKIE = 'slauth_2fa'
26
+ let warned = false
27
+
28
+ /**
29
+ * slauth: sign-in for side projects, and for the projects they grow into.
30
+ *
31
+ * const auth = slauth({ secret, store })
32
+ * if (url.pathname.startsWith('/auth/')) return auth.handle(request)
33
+ * const user = await auth.user(request)
34
+ *
35
+ * Email codes and passkeys are on from the start. Everything else (providers,
36
+ * passwords, two-factor, tokens, organizations) is one config key away.
37
+ *
38
+ * @param {import('./index.js').SlauthConfig} config
39
+ */
40
+ export function slauth(config) {
41
+ const {
42
+ secret,
43
+ store,
44
+ appName = 'your app',
45
+ basePath = '/auth',
46
+ cookie = 'slauth_session',
47
+ sessionDays = 30,
48
+ allow,
49
+ passkeys = {},
50
+ origins,
51
+ baseUrl,
52
+ signInPage,
53
+ freshMinutes = 15,
54
+ providers = [],
55
+ magicLinks = false,
56
+ on = {},
57
+ } = config ?? {}
58
+
59
+ if (!store || typeof store.get !== 'function') {
60
+ throw new Error("slauth: pass a store, e.g. store: memory() from 'slauthjs/stores/memory'.")
61
+ }
62
+ if (typeof secret !== 'string' || secret.length < 16) {
63
+ throw new Error('slauth: `secret` must be a random string of at least 16 characters. Make one with: openssl rand -base64 32')
64
+ }
65
+
66
+ const sender = config.email ?? consoleEmail()
67
+ if (!config.email && !warned) {
68
+ warned = true
69
+ console.warn('slauth: no `email` sender set, so sign-in codes are printed to the console.')
70
+ }
71
+
72
+ const ids = new Set()
73
+ for (const p of providers) {
74
+ if (ids.has(p.id)) throw new Error(`slauth: two providers are called "${p.id}".`)
75
+ ids.add(p.id)
76
+ }
77
+
78
+ const option = (value, defaults = {}) => (value ? (value === true ? defaults : { ...defaults, ...value }) : null)
79
+ const passwordOptions = option(config.passwords)
80
+ const twoFactorOptions = option(config.twoFactor)
81
+ const tokenOptions = option(config.tokens)
82
+ const orgOptions = option(config.organizations, { allowCreate: true })
83
+
84
+ const users = createUsers(store, allow)
85
+ const sessions = createSessions({ store, cookie, days: sessionDays })
86
+ const codes = createEmailCodes({ store, secret, sender, appName, message: config.emails })
87
+ const limits = createRateLimit(store, config.rateLimit)
88
+ const passwords = passwordOptions ? createPasswords({ store, ...passwordOptions }) : null
89
+ const twoFactor = twoFactorOptions ? createTwoFactor({ store, issuer: twoFactorOptions.issuer ?? appName }) : null
90
+ const tokens = tokenOptions ? createTokens({ store, secret, ...tokenOptions }) : null
91
+ const orgs = orgOptions ? createOrgs(store) : null
92
+ const oauth = createOAuthFlow({ store, fetch: config.fetch })
93
+ const access = config.access ? createAccess({ teamDomain: config.access.team, audience: config.access.aud, devUser: config.access.devUser, fetch: config.access.fetch }) : null
94
+ const lock = config.lock
95
+ ? createLock(
96
+ { get: (userId) => store.get(`lock:${userId}`), put: (userId, doc) => store.put(`lock:${userId}`, doc) },
97
+ { cookie: config.lock === true ? 'slauth_lock' : (config.lock.cookie ?? 'slauth_lock'), defaultPolicy: config.lock === true ? 'never' : (config.lock.default ?? 'never') },
98
+ )
99
+ : null
100
+
101
+ const prefix = basePath.replace(/\/+$/, '')
102
+
103
+ /* ---------- who's asking ---------- */
104
+
105
+ /**
106
+ * The session and user behind a request, or null. JWTs count only for your own routes
107
+ * (auth.user), never for slauth's: a token must not be able to mint the next one.
108
+ */
109
+ async function current(request, { jwt = false } = {}) {
110
+ const session = await sessions.read(request)
111
+ if (session) {
112
+ const found = await users.byId(session.userId)
113
+ // Taking someone off the allow list (or banning them) signs them out everywhere.
114
+ if (found && !found.bannedAt && (await users.allowed(found.email, { method: 'session' }))) return { user: found, session }
115
+ }
116
+ const bearer = request.headers.get('authorization')
117
+ if (jwt && tokens && bearer?.startsWith('Bearer ') && bearer.split('.').length === 3) {
118
+ const claims = await tokens.verify(bearer.slice(7).trim())
119
+ const found = claims && (await users.byId(claims.sub))
120
+ if (found && !found.bannedAt) return { user: found, session: null, claims }
121
+ }
122
+ if (access) {
123
+ const fromAccess = await access.getUser(request)
124
+ if (fromAccess) {
125
+ const found = await users.signIn(fromAccess.email, { method: 'access' })
126
+ if (found && !found.bannedAt) return { user: found, session: null }
127
+ }
128
+ }
129
+ return null
130
+ }
131
+
132
+ /** The signed-in person, or null. Doesn't look at the app lock; see requireUser. */
133
+ async function user(request) {
134
+ const found = await current(request, { jwt: true })
135
+ return found ? publicUser(found.user) : null
136
+ }
137
+
138
+ /**
139
+ * The signed-in, unlocked person, or a 401/403 Response to send back as it is:
140
+ *
141
+ * const user = await auth.requireUser(request)
142
+ * if (user instanceof Response) return user
143
+ *
144
+ * @param {Request} request
145
+ * @param {{ role?: string | string[] }} [options] only people with one of these roles
146
+ */
147
+ async function requireUser(request, { role } = {}) {
148
+ const found = await user(request)
149
+ if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
150
+ if (lock && !(await lock.isUnlocked(lockUser(found), request.headers.get('cookie')))) {
151
+ return json({ error: 'Please enter your PIN.', code: 'locked' }, 401)
152
+ }
153
+ if (role && ![role].flat().some((r) => found.roles.includes(r))) return json({ error: "You don't have access to that.", code: 'forbidden' }, 403)
154
+ return found
155
+ }
156
+
157
+ /* ---------- the end of every sign-in ---------- */
158
+
159
+ async function emit(name, data) {
160
+ if (typeof on[name] !== 'function') return
161
+ try {
162
+ await on[name](data)
163
+ } catch (error) {
164
+ console.error(`slauth: the ${name} handler threw:`, error)
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Every way of signing in ends here: bans, then two-factor, then a session.
170
+ * @returns {Promise<{ kind: 'session', user: object, cookie: string, token: string } | { kind: 'two-factor', cookie: string, token: string } | { kind: 'refused', status: number, code: string, error: string }>}
171
+ */
172
+ async function complete(found, request, { method }) {
173
+ if (found.bannedAt) return { kind: 'refused', status: 403, code: 'banned', error: "This account can't sign in." }
174
+ if (found.isNew) {
175
+ delete found.isNew
176
+ await emit('signUp', { user: publicUser(found), method, request })
177
+ }
178
+ if (twoFactor && method !== 'passkey' && (await twoFactor.enabled(found.id))) {
179
+ const token = randomToken()
180
+ await store.put(`2fa:${base64Url(await sha256(token))}`, { userId: found.id, method, tries: 0 }, { ttl: PENDING_SECONDS })
181
+ return { kind: 'two-factor', token, cookie: serializeCookie(PENDING_COOKIE, token, { maxAge: PENDING_SECONDS, secure: isSecure(request) }) }
182
+ }
183
+ const made = await sessions.create(found.id, request, { method })
184
+ await emit('signIn', { user: publicUser(found), method, request })
185
+ return { kind: 'session', user: publicUser(found), cookie: made.cookie, token: made.token }
186
+ }
187
+
188
+ /** The JSON answer for a sign-in route: a cookie for browsers, or the token for apps that asked for one. */
189
+ function reply(result, extra = {}, request) {
190
+ if (result.kind === 'refused') return json({ error: result.error, code: result.code }, result.status)
191
+ const bearer = request?.headers.get('x-slauth-transport') === 'bearer'
192
+ if (result.kind === 'two-factor') return json({ twoFactor: true, ...(bearer ? { pendingToken: result.token } : {}), ...extra }, 200, bearer ? {} : { 'Set-Cookie': result.cookie })
193
+ return json({ user: result.user, ...(bearer ? { token: result.token } : {}), ...extra }, 200, bearer ? {} : { 'Set-Cookie': result.cookie })
194
+ }
195
+
196
+ /** The two-factor sign-in waiting to be finished in this browser (or for this token). */
197
+ async function pending(request, token) {
198
+ const value = token ?? readCookie(request.headers.get('cookie'), PENDING_COOKIE)
199
+ if (!value || value.length > 128) return null
200
+ const key = `2fa:${base64Url(await sha256(value))}`
201
+ const doc = await store.get(key)
202
+ return doc ? { ...doc, key } : null
203
+ }
204
+
205
+ /* ---------- routes ---------- */
206
+
207
+ const app = {
208
+ config,
209
+ appName,
210
+ prefix,
211
+ store,
212
+ users,
213
+ sessions,
214
+ codes,
215
+ limits,
216
+ passwords,
217
+ twoFactor,
218
+ tokens,
219
+ orgs,
220
+ orgOptions,
221
+ oauth,
222
+ lock,
223
+ providers,
224
+ magicLinks,
225
+ signInPage,
226
+ passkeys,
227
+ origins,
228
+ current,
229
+ complete,
230
+ reply,
231
+ pending,
232
+ emit,
233
+ deleteUser,
234
+ pendingCookie: PENDING_COOKIE,
235
+ /** Where links in emails and provider callbacks point. */
236
+ origin: (url) => (baseUrl ?? url.origin).replace(/\/+$/, ''),
237
+ /** The signed-in user and session, or a 401 to return. */
238
+ async signedIn(request) {
239
+ return (await current(request)) ?? json({ error: 'Please sign in.', code: 'signed-out' }, 401)
240
+ },
241
+ /** True when the session started recently enough for something sensitive. */
242
+ fresh(session) {
243
+ return freshMinutes === false || (session != null && Date.now() - session.createdAt < freshMinutes * 60_000)
244
+ },
245
+ reauth: () => json({ error: 'Please sign in again to do that.', code: 'reauth-required' }, 403),
246
+ secure: isSecure,
247
+ }
248
+
249
+ const routers = [emailRoutes(app), accountRoutes(app), passkeyRoutes(app), oauthRoutes(app), passwordRoutes(app), twoFactorRoutes(app), orgRoutes(app)]
250
+
251
+ /** Every route under basePath. Returns a 404 for anything else, so it's safe to call for any request. */
252
+ async function handle(request) {
253
+ const url = new URL(request.url)
254
+ if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return json({ error: 'Not found.', code: 'not-found' }, 404)
255
+ const route = url.pathname.slice(prefix.length + 1)
256
+ const method = request.method
257
+
258
+ if (UNSAFE.has(method)) {
259
+ const refused = checkSameSite(request, url, origins)
260
+ if (refused) return refused
261
+ }
262
+
263
+ let parsed
264
+ const body = async () => {
265
+ if (parsed === undefined) {
266
+ try {
267
+ parsed = (await request.json()) ?? {}
268
+ } catch {
269
+ parsed = {}
270
+ }
271
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) parsed = {}
272
+ }
273
+ return parsed
274
+ }
275
+ const r = { route, method, request, url, body }
276
+
277
+ try {
278
+ for (const router of routers) {
279
+ const answer = await router(r)
280
+ if (answer) return answer
281
+ }
282
+ if (route.startsWith('lock/') && lock) {
283
+ const found = await user(request)
284
+ if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
285
+ const answer = await lock.handle(route.slice(5), method, body, lockUser(found), request.headers.get('cookie'), url.protocol === 'https:')
286
+ if (answer) return json(answer.body, answer.status, answer.cookie ? { 'Set-Cookie': answer.cookie } : {})
287
+ }
288
+ return json({ error: 'Not found.', code: 'not-found' }, 404)
289
+ } catch (error) {
290
+ if (error instanceof PasskeyError) return json({ error: error.message, code: 'passkey-failed' }, 400)
291
+ if (error instanceof OrgError) return json({ error: error.message, code: error.code }, 409)
292
+ console.error('slauth:', error)
293
+ return json({ error: 'Something went wrong.', code: 'server-error' }, 500)
294
+ }
295
+ }
296
+
297
+ /* ---------- the server-side API ---------- */
298
+
299
+ async function deleteUser(id) {
300
+ const found = await users.byId(id)
301
+ if (!found) return false
302
+ await sessions.revokeAll(id)
303
+ for (const pk of (await store.get(`passkeys:${id}`)) ?? []) await store.delete(`passkey:${pk}`)
304
+ await store.delete(`passkeys:${id}`)
305
+ for (const key of (await store.get(`accounts:${id}`)) ?? []) await store.delete(`account:${key}`)
306
+ for (const key of [`accounts:${id}`, `password:${id}`, `totp:${id}`, `lock:${id}`]) await store.delete(key)
307
+ if (orgs) await orgs.forget(id)
308
+ await users.remove(found)
309
+ await emit('userDeleted', { user: publicUser(found) })
310
+ return true
311
+ }
312
+
313
+ async function updateUser(id, patch) {
314
+ const updated = await users.update(id, patch)
315
+ if (updated) await emit('userUpdated', { user: publicUser(updated) })
316
+ return updated
317
+ }
318
+
319
+ return {
320
+ handle,
321
+ user,
322
+ requireUser,
323
+ /** The user and the session behind a request (session is null for Access or a JWT), or null. */
324
+ async session(request) {
325
+ const found = await current(request, { jwt: true })
326
+ if (!found) return null
327
+ const s = found.session
328
+ return { user: publicUser(found.user), session: s ? { id: s.id, userId: s.userId, createdAt: s.createdAt, method: s.method ?? null, impersonatorId: s.impersonatorId ?? null } : null }
329
+ },
330
+ store,
331
+
332
+ users: {
333
+ get: users.byId,
334
+ byEmail: users.byEmail,
335
+ list: users.list,
336
+ async create(details) {
337
+ const made = await users.create(details)
338
+ await emit('signUp', { user: publicUser(made), method: 'admin' })
339
+ return made
340
+ },
341
+ update: updateUser,
342
+ delete: deleteUser,
343
+ /** Signs them out everywhere and stops them signing in again, until unban. */
344
+ async ban(id) {
345
+ const updated = await updateUser(id, { bannedAt: Date.now() })
346
+ if (updated) await sessions.revokeAll(id)
347
+ return updated
348
+ },
349
+ unban: (id) => updateUser(id, { bannedAt: null }),
350
+ },
351
+
352
+ sessions: {
353
+ list: sessions.list,
354
+ revoke: sessions.revoke,
355
+ revokeAll: (userId) => sessions.revokeAll(userId),
356
+ /**
357
+ * Signs someone in without asking them: for your own sign-in methods, tests, or
358
+ * support staff seeing what a user sees (pass impersonatorId so it's on record).
359
+ * Returns the Set-Cookie value and the bearer token.
360
+ */
361
+ async create(userId, request, { method = 'admin', impersonatorId = null } = {}) {
362
+ const found = await users.byId(userId)
363
+ if (!found) throw new Error(`slauth: no user ${userId}.`)
364
+ const made = await sessions.create(userId, request, { method, impersonatorId })
365
+ return { cookie: made.cookie, token: made.token }
366
+ },
367
+ },
368
+
369
+ accounts: {
370
+ /** The providers someone has linked, with their tokens when the provider has keepTokens. */
371
+ async list(userId) {
372
+ const out = []
373
+ for (const key of (await store.get(`accounts:${userId}`)) ?? []) {
374
+ const doc = await store.get(`account:${key}`)
375
+ if (doc) out.push(doc)
376
+ }
377
+ return out
378
+ },
379
+ },
380
+
381
+ passwords: passwords && { set: passwords.set, remove: passwords.remove, has: passwords.has, hash: passwords.hash },
382
+ twoFactor: twoFactor && { enabled: twoFactor.enabled, disable: twoFactor.disable },
383
+ orgs: orgs && {
384
+ create: (details, ownerId) => orgs.create(details, ownerId),
385
+ get: orgs.get,
386
+ update: orgs.update,
387
+ delete: orgs.remove,
388
+ forUser: orgs.forUser,
389
+ members: orgs.members,
390
+ role: orgs.role,
391
+ addMember: orgs.addMember,
392
+ setRole: orgs.setRole,
393
+ removeMember: orgs.removeMember,
394
+ invite: orgs.invite,
395
+ revokeInvite: orgs.revokeInvite,
396
+ /**
397
+ * The signed-in person if they belong to this org (with one of these roles), else a
398
+ * 401/403 Response to return as it is.
399
+ */
400
+ async require(request, orgId, { role } = {}) {
401
+ const found = await requireUser(request)
402
+ if (found instanceof Response) return found
403
+ const theirs = await orgs.role(orgId, found.id)
404
+ if (!theirs || (role && ![role].flat().includes(theirs))) return json({ error: "You don't have access to that.", code: 'forbidden' }, 403)
405
+ return { ...found, orgRole: theirs }
406
+ },
407
+ },
408
+
409
+ createToken: (u, extra) => {
410
+ if (!tokens) throw new Error('slauth: turn on `tokens` to make tokens.')
411
+ return tokens.create(u, extra)
412
+ },
413
+ verifyToken: (token) => {
414
+ if (!tokens) throw new Error('slauth: turn on `tokens` to check tokens.')
415
+ return tokens.verify(token)
416
+ },
417
+ }
418
+ }
419
+
420
+ /** createLock works with { email, userId }. */
421
+ function lockUser(user) {
422
+ return { email: user.email, userId: user.id }
423
+ }
424
+
425
+ function isSecure(request) {
426
+ return new URL(request.url).protocol === 'https:'
427
+ }
428
+
429
+ /**
430
+ * Stops other websites using a visitor's cookies to post here (CSRF): writes must be JSON
431
+ * (which a plain HTML form can't send) and, when the browser says where they came from,
432
+ * come from this site.
433
+ */
434
+ function checkSameSite(request, url, origins) {
435
+ const type = request.headers.get('content-type') ?? ''
436
+ if (!type.startsWith('application/json')) return json({ error: 'Expected JSON.', code: 'bad-request' }, 415)
437
+ const origin = request.headers.get('origin')
438
+ const allowed = [origins ?? url.origin].flat()
439
+ if (origin && !allowed.includes(origin)) return json({ error: 'Cross-site request refused.', code: 'cross-site' }, 403)
440
+ if (!origin && request.headers.get('sec-fetch-site') === 'cross-site') return json({ error: 'Cross-site request refused.', code: 'cross-site' }, 403)
441
+ return null
442
+ }
@@ -0,0 +1,6 @@
1
+ export {}
2
+ declare global {
3
+ interface HTMLElementTagNameMap {
4
+ 'slauth-sign-in': HTMLElement
5
+ }
6
+ }