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/src/core.js CHANGED
@@ -2,22 +2,39 @@ import { createAccess } from './access.js'
2
2
  import { createEmailCodes } from './email-code.js'
3
3
  import { consoleEmail } from './email/console.js'
4
4
  import { createLock } from './lock.js'
5
- import { PasskeyError, readChallenge, verifyAuthentication, verifyRegistration } from './passkey/webauthn.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'
6
17
  import { createSessions } from './session.js'
7
- import { createUsers, normalizeEmail, validEmail } from './users.js'
8
- import { base64Url, json } from './util.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'
9
22
 
10
- const CHALLENGE_SECONDS = 5 * 60
11
23
  const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
24
+ const PENDING_SECONDS = 10 * 60
25
+ const PENDING_COOKIE = 'slauth_2fa'
12
26
  let warned = false
13
27
 
14
28
  /**
15
- * slauth: sign-in for side projects.
29
+ * slauth: sign-in for side projects, and for the projects they grow into.
16
30
  *
17
31
  * const auth = slauth({ secret, store })
18
32
  * if (url.pathname.startsWith('/auth/')) return auth.handle(request)
19
33
  * const user = await auth.user(request)
20
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
+ *
21
38
  * @param {import('./index.js').SlauthConfig} config
22
39
  */
23
40
  export function slauth(config) {
@@ -31,6 +48,12 @@ export function slauth(config) {
31
48
  allow,
32
49
  passkeys = {},
33
50
  origins,
51
+ baseUrl,
52
+ signInPage,
53
+ freshMinutes = 15,
54
+ providers = [],
55
+ magicLinks = false,
56
+ on = {},
34
57
  } = config ?? {}
35
58
 
36
59
  if (!store || typeof store.get !== 'function') {
@@ -46,9 +69,27 @@ export function slauth(config) {
46
69
  console.warn('slauth: no `email` sender set, so sign-in codes are printed to the console.')
47
70
  }
48
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
+
49
84
  const users = createUsers(store, allow)
50
85
  const sessions = createSessions({ store, cookie, days: sessionDays })
51
- const codes = createEmailCodes({ store, secret, sender, appName })
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 })
52
93
  const access = config.access ? createAccess({ teamDomain: config.access.team, audience: config.access.aud, devUser: config.access.devUser, fetch: config.access.fetch }) : null
53
94
  const lock = config.lock
54
95
  ? createLock(
@@ -59,235 +100,321 @@ export function slauth(config) {
59
100
 
60
101
  const prefix = basePath.replace(/\/+$/, '')
61
102
 
62
- /** The signed-in person, or null. Doesn't look at the app lock; see requireUser. */
63
- async function user(request) {
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 } = {}) {
64
110
  const session = await sessions.read(request)
65
111
  if (session) {
66
112
  const found = await users.byId(session.userId)
67
- // Taking someone off the allow list signs them out everywhere.
68
- if (found && (await users.allowed(found.email))) return publicUser(found)
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 }
69
121
  }
70
122
  if (access) {
71
123
  const fromAccess = await access.getUser(request)
72
124
  if (fromAccess) {
73
- const found = await users.signIn(fromAccess.email)
74
- if (found) return publicUser(found)
125
+ const found = await users.signIn(fromAccess.email, { method: 'access' })
126
+ if (found && !found.bannedAt) return { user: found, session: null }
75
127
  }
76
128
  }
77
129
  return null
78
130
  }
79
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
+
80
138
  /**
81
- * The signed-in, unlocked person, or a 401 Response to send back as it is:
139
+ * The signed-in, unlocked person, or a 401/403 Response to send back as it is:
82
140
  *
83
141
  * const user = await auth.requireUser(request)
84
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
85
146
  */
86
- async function requireUser(request) {
147
+ async function requireUser(request, { role } = {}) {
87
148
  const found = await user(request)
88
149
  if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
89
150
  if (lock && !(await lock.isUnlocked(lockUser(found), request.headers.get('cookie')))) {
90
151
  return json({ error: 'Please enter your PIN.', code: 'locked' }, 401)
91
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)
92
154
  return found
93
155
  }
94
156
 
95
- /** Every route under basePath. Returns a 404 for anything else, so it's safe to call for any request. */
96
- async function handle(request) {
97
- const url = new URL(request.url)
98
- if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return json({ error: 'Not found.', code: 'not-found' }, 404)
99
- const route = url.pathname.slice(prefix.length + 1)
100
- const method = request.method
101
-
102
- if (UNSAFE.has(method)) {
103
- const refused = checkSameSite(request, url, origins)
104
- if (refused) return refused
105
- }
157
+ /* ---------- the end of every sign-in ---------- */
106
158
 
159
+ async function emit(name, data) {
160
+ if (typeof on[name] !== 'function') return
107
161
  try {
108
- return await routeRequest(route, method, request, url)
162
+ await on[name](data)
109
163
  } catch (error) {
110
- if (error instanceof PasskeyError) return json({ error: error.message, code: 'passkey-failed' }, 400)
111
- console.error('slauth:', error)
112
- return json({ error: 'Something went wrong.', code: 'server-error' }, 500)
164
+ console.error(`slauth: the ${name} handler threw:`, error)
113
165
  }
114
166
  }
115
167
 
116
- async function routeRequest(route, method, request, url) {
117
- const body = async () => {
118
- try {
119
- return (await request.json()) ?? {}
120
- } catch {
121
- return {}
122
- }
123
- }
124
-
125
- if (route === 'session' && method === 'GET') {
126
- const session = await sessions.read(request)
127
- const found = await user(request)
128
- const headers = {}
129
- if (session && found) {
130
- const refreshed = await sessions.refresh(session, request)
131
- if (refreshed) headers['Set-Cookie'] = refreshed
132
- }
133
- const out = { user: found }
134
- if (lock && found) out.lock = (await lock.handle('session', 'GET', body, lockUser(found), request.headers.get('cookie'), url.protocol === 'https:')).body
135
- return json(out, 200, headers)
136
- }
137
-
138
- if (route === 'email/start' && method === 'POST') {
139
- const email = normalizeEmail((await body()).email)
140
- if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
141
- if (!(await users.allowed(email))) return json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
142
- const sent = await codes.start(email)
143
- if (!sent.ok) return json({ error: `Please wait ${sent.retryAfter}s before asking for another code.`, code: sent.code, retryAfter: sent.retryAfter }, 429)
144
- return json({ ok: true })
145
- }
146
-
147
- if (route === 'email/verify' && method === 'POST') {
148
- const input = await body()
149
- const email = normalizeEmail(input.email)
150
- if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
151
- const result = await codes.verify(email, input.code)
152
- if (!result.ok) {
153
- 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.' }
154
- return json({ error: messages[result.code], code: result.code, triesLeft: result.triesLeft }, result.code === 'bad-code' ? 400 : 410)
155
- }
156
- const found = await users.signIn(email)
157
- if (!found) return json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
158
- return json({ user: publicUser(found) }, 200, { 'Set-Cookie': await sessions.create(found.id, request) })
159
- }
160
-
161
- if (route === 'signout' && method === 'POST') {
162
- return json({ ok: true }, 200, { 'Set-Cookie': await sessions.destroy(request) })
163
- }
164
-
165
- if (route.startsWith('passkey') || route === 'passkeys') {
166
- if (passkeys === false) return json({ error: 'Passkeys are turned off.', code: 'not-found' }, 404)
167
- return passkeyRoute(route, method, request, url, body)
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 })
168
177
  }
169
-
170
- if (route.startsWith('lock/') && lock) {
171
- const found = await user(request)
172
- if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
173
- const reply = await lock.handle(route.slice(5), method, body, lockUser(found), request.headers.get('cookie'), url.protocol === 'https:')
174
- if (reply) return json(reply.body, reply.status, reply.cookie ? { 'Set-Cookie': reply.cookie } : {})
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) }) }
175
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
+ }
176
187
 
177
- return json({ error: 'Not found.', code: 'not-found' }, 404)
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 })
178
194
  }
179
195
 
180
- /* ---------- passkeys ---------- */
181
-
182
- const expect = (url, challenge) => ({
183
- rpId: passkeys.rpId ?? url.hostname,
184
- origin: passkeys.origin ?? origins ?? url.origin,
185
- challenge,
186
- requireUserVerification: passkeys.userVerification === 'required',
187
- })
188
-
189
- async function takeChallenge(clientDataJSON, type) {
190
- const challenge = readChallenge(clientDataJSON)
191
- if (!challenge) throw new PasskeyError('The response has no challenge.')
192
- const stored = await store.get(`challenge:${challenge}`)
193
- // Single use, whatever happens next.
194
- await store.delete(`challenge:${challenge}`)
195
- if (!stored || stored.type !== type) throw new PasskeyError('That passkey request expired. Try again.')
196
- return { challenge, stored }
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
197
203
  }
198
204
 
199
- async function newChallenge(value) {
200
- const challenge = base64Url(crypto.getRandomValues(new Uint8Array(32)))
201
- await store.put(`challenge:${challenge}`, value, { ttl: CHALLENGE_SECONDS })
202
- return challenge
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,
203
247
  }
204
248
 
205
- async function passkeyRoute(route, method, request, url, body) {
206
- if (route === 'passkey/login/options' && method === 'POST') {
207
- const challenge = await newChallenge({ type: 'login' })
208
- return json({
209
- challenge,
210
- rpId: passkeys.rpId ?? url.hostname,
211
- timeout: CHALLENGE_SECONDS * 1000,
212
- userVerification: passkeys.userVerification ?? 'preferred',
213
- allowCredentials: [],
214
- })
215
- }
249
+ const routers = [emailRoutes(app), accountRoutes(app), passkeyRoutes(app), oauthRoutes(app), passwordRoutes(app), twoFactorRoutes(app), orgRoutes(app)]
216
250
 
217
- if (route === 'passkey/login/verify' && method === 'POST') {
218
- const { credential } = await body()
219
- await takeChallenge(credential?.response?.clientDataJSON, 'login')
220
- const stored = typeof credential?.id === 'string' ? await store.get(`passkey:${credential.id}`) : null
221
- if (!stored) return json({ error: "This passkey isn't registered here.", code: 'passkey-unknown' }, 400)
222
- if (credential.response.userHandle && credential.response.userHandle !== base64Url(new TextEncoder().encode(stored.userId))) {
223
- throw new PasskeyError('This passkey belongs to someone else.')
224
- }
225
- const { counter } = await verifyAuthentication(credential, stored, expect(url, readChallenge(credential.response.clientDataJSON)))
226
- await store.put(`passkey:${stored.id}`, { ...stored, counter, lastUsedAt: Date.now() })
227
- const found = await users.byId(stored.userId)
228
- if (!found || !(await users.allowed(found.email))) return json({ error: "You're not on the list for this app.", code: 'not-allowed' }, 403)
229
- return json({ user: publicUser(found) }, 200, { 'Set-Cookie': await sessions.create(found.id, request) })
230
- }
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
231
257
 
232
- // Everything below is for someone already signed in.
233
- const found = await user(request)
234
- if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
235
- const ids = (await store.get(`passkeys:${found.id}`)) ?? []
236
-
237
- if (route === 'passkey/register/options' && method === 'POST') {
238
- const challenge = await newChallenge({ type: 'register', userId: found.id })
239
- return json({
240
- challenge,
241
- rp: { id: passkeys.rpId ?? url.hostname, name: passkeys.rpName ?? appName },
242
- user: { id: base64Url(new TextEncoder().encode(found.id)), name: found.email, displayName: found.email },
243
- pubKeyCredParams: [
244
- { type: 'public-key', alg: -7 },
245
- { type: 'public-key', alg: -257 },
246
- ],
247
- timeout: CHALLENGE_SECONDS * 1000,
248
- attestation: 'none',
249
- authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: passkeys.userVerification ?? 'preferred' },
250
- excludeCredentials: ids.map((id) => ({ type: 'public-key', id })),
251
- })
258
+ if (UNSAFE.has(method)) {
259
+ const refused = checkSameSite(request, url, origins)
260
+ if (refused) return refused
252
261
  }
253
262
 
254
- if (route === 'passkey/register/verify' && method === 'POST') {
255
- const { credential, name } = await body()
256
- const { challenge, stored } = await takeChallenge(credential?.response?.clientDataJSON, 'register')
257
- if (stored.userId !== found.id) throw new PasskeyError('That passkey request was for someone else.')
258
- const verified = await verifyRegistration(credential, expect(url, challenge))
259
- if (await store.get(`passkey:${verified.id}`)) return json({ error: 'That passkey is already registered.', code: 'passkey-exists' }, 409)
260
- const label = typeof name === 'string' && name.trim() ? name.trim().slice(0, 60) : `Passkey added ${new Date().toISOString().slice(0, 10)}`
261
- await store.put(`passkey:${verified.id}`, { ...verified, userId: found.id, name: label, createdAt: Date.now(), lastUsedAt: null })
262
- await store.put(`passkeys:${found.id}`, [...ids, verified.id])
263
- return json({ ok: true, passkey: { id: verified.id, name: label } })
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
264
274
  }
275
+ const r = { route, method, request, url, body }
265
276
 
266
- if (route === 'passkeys' && method === 'GET') {
267
- const list = []
268
- for (const id of ids) {
269
- const pk = await store.get(`passkey:${id}`)
270
- if (pk) list.push({ id: pk.id, name: pk.name, createdAt: pk.createdAt, lastUsedAt: pk.lastUsedAt })
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 } : {})
271
287
  }
272
- return json({ passkeys: list })
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)
273
294
  }
295
+ }
274
296
 
275
- if (route === 'passkey/delete' && method === 'POST') {
276
- const { id } = await body()
277
- if (!ids.includes(id)) return json({ error: 'No such passkey.', code: 'passkey-unknown' }, 404)
278
- await store.delete(`passkey:${id}`)
279
- await store.put(`passkeys:${found.id}`, ids.filter((x) => x !== id))
280
- return json({ ok: true })
281
- }
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
+ }
282
312
 
283
- return json({ error: 'Not found.', code: 'not-found' }, 404)
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
284
317
  }
285
318
 
286
- return { handle, user, requireUser, store }
287
- }
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,
288
331
 
289
- function publicUser(user) {
290
- return { id: user.id, email: user.email, createdAt: user.createdAt }
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
+ }
291
418
  }
292
419
 
293
420
  /** createLock works with { email, userId }. */
@@ -295,6 +422,10 @@ function lockUser(user) {
295
422
  return { email: user.email, userId: user.id }
296
423
  }
297
424
 
425
+ function isSecure(request) {
426
+ return new URL(request.url).protocol === 'https:'
427
+ }
428
+
298
429
  /**
299
430
  * Stops other websites using a visitor's cookies to post here (CSRF): writes must be JSON
300
431
  * (which a plain HTML form can't send) and, when the browser says where they came from,