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/CHANGELOG.md +72 -0
- package/README.md +46 -9
- package/package.json +31 -7
- package/src/account-element.d.ts +8 -0
- package/src/account-element.js +244 -0
- package/src/client.d.ts +86 -7
- package/src/client.js +99 -6
- package/src/core.js +313 -182
- package/src/element.js +161 -59
- package/src/email/console.js +5 -4
- package/src/email/message.js +50 -10
- package/src/email-code.js +32 -18
- package/src/index.d.ts +221 -6
- package/src/index.js +3 -0
- package/src/oauth/flow.js +81 -0
- package/src/oauth/providers.js +171 -0
- package/src/orgs.js +184 -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 +98 -16
- package/src/stores/d1.js +14 -0
- package/src/stores/index.d.ts +2 -2
- package/src/stores/kv.js +9 -0
- package/src/stores/memory.js +5 -0
- package/src/stores/sqlite.js +8 -1
- package/src/tokens.js +119 -0
- package/src/two-factor.js +142 -0
- package/src/ui.js +49 -0
- package/src/users.js +92 -22
- package/src/util.js +37 -0
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 {
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
162
|
+
await on[name](data)
|
|
109
163
|
} catch (error) {
|
|
110
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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({
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
290
|
-
|
|
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,
|