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/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
|
+
}
|