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.
@@ -0,0 +1,106 @@
1
+ import { normalizeEmail } from '../users.js'
2
+ import { json, redirect, safeRedirect, withParams } from '../util.js'
3
+
4
+ /**
5
+ * "Sign in with GitHub/Google/…".
6
+ *
7
+ * GET oauth/<provider>?redirect=/somewhere start signing in
8
+ * GET oauth/<provider>?link=1&redirect=/settings link to the signed-in account
9
+ * GET oauth/<provider>/callback where the provider sends people back
10
+ *
11
+ * account:<provider>:<accountId> { provider, accountId, userId, email, name, createdAt, tokens? }
12
+ * accounts:<userId> ["github:123", ...]
13
+ *
14
+ * Who is it? In order: someone who's linked this account before; the signed-in person,
15
+ * when linking; the user with the same email, when the provider says it's verified;
16
+ * otherwise a new user (if the allow list agrees).
17
+ *
18
+ * @param {object} app the shared context from core.js
19
+ */
20
+ export function oauthRoutes(app) {
21
+ const { store, users, oauth, providers } = app
22
+
23
+ return async function route({ route, method, request, url }) {
24
+ if (!route.startsWith('oauth/') || method !== 'GET') return
25
+ const [, id, step] = route.split('/')
26
+ const provider = providers.find((p) => p.id === id)
27
+ if (!provider || (step && step !== 'callback')) return json({ error: 'No such provider.', code: 'not-found' }, 404)
28
+ const callbackUrl = `${app.origin(url)}${app.prefix}/oauth/${provider.id}/callback`
29
+ const secure = app.secure(request)
30
+
31
+ if (!step) {
32
+ // Back to the page the button was on when something needs the person (an error, a 2FA code).
33
+ const referer = sameSitePath(request.headers.get('referer'), url)
34
+ const redirectTo = safeRedirect(url.searchParams.get('redirect') ?? referer ?? '/')
35
+ let linkUserId = null
36
+ if (url.searchParams.get('link')) {
37
+ const me = await app.current(request)
38
+ if (!me) return redirect(withParams(app.signInPage ?? referer ?? '/', { slauth_error: 'signed-out' }))
39
+ if (!app.fresh(me.session)) return redirect(withParams(referer ?? redirectTo, { slauth_error: 'reauth-required' }))
40
+ linkUserId = me.user.id
41
+ }
42
+ const started = await oauth.start(provider, { callbackUrl, redirect: redirectTo, returnTo: app.signInPage ?? referer ?? redirectTo, linkUserId, secure })
43
+ return redirect(started.location, [started.cookie])
44
+ }
45
+
46
+ const result = await oauth.finish(provider, request, { callbackUrl })
47
+ const back = result.state?.returnTo ?? app.signInPage ?? '/'
48
+ const clear = oauth.clearCookie(secure)
49
+ const fail = (code) => redirect(withParams(back, { slauth_error: code }), [clear])
50
+ if (!result.ok) return fail(result.code)
51
+ const { profile, tokens, state: saved } = result
52
+ const accountKey = `${provider.id}:${profile.id}`
53
+ const linked = await store.get(`account:${accountKey}`)
54
+ const email = profile.email ? normalizeEmail(profile.email) : null
55
+ const context = { method: 'oauth', provider: provider.id, profile }
56
+
57
+ let found = null
58
+ if (saved.linkUserId) {
59
+ if (linked && linked.userId !== saved.linkUserId) return fail('account-in-use')
60
+ found = await users.byId(saved.linkUserId)
61
+ if (!found) return fail('signed-out')
62
+ } else if (linked) {
63
+ found = await users.byId(linked.userId)
64
+ if (found && !(await users.allowed(found.email, context))) return fail('not-allowed')
65
+ } else {
66
+ if (!email || !profile.emailVerified) return fail('email-not-verified')
67
+ found = await users.signIn(email, context, { name: profile.name, image: profile.image })
68
+ if (!found) return fail('not-allowed')
69
+ }
70
+ if (!found) return fail('oauth-failed')
71
+
72
+ const account = {
73
+ provider: provider.id,
74
+ accountId: profile.id,
75
+ userId: found.id,
76
+ email,
77
+ name: profile.name ?? null,
78
+ createdAt: linked?.createdAt ?? Date.now(),
79
+ updatedAt: Date.now(),
80
+ ...(provider.keepTokens ? { tokens: { ...tokens, savedAt: Date.now() } } : {}),
81
+ }
82
+ await store.put(`account:${accountKey}`, account)
83
+ const keys = (await store.get(`accounts:${found.id}`)) ?? []
84
+ if (!keys.includes(accountKey)) {
85
+ // One account per provider per person: linking a second GitHub replaces the first.
86
+ for (const old of keys.filter((k) => k.split(':')[0] === provider.id)) await store.delete(`account:${old}`)
87
+ await store.put(`accounts:${found.id}`, [...keys.filter((k) => k.split(':')[0] !== provider.id), accountKey])
88
+ }
89
+
90
+ if (saved.linkUserId) return redirect(withParams(saved.redirect, { slauth_linked: provider.id }), [clear])
91
+
92
+ const done = await app.complete(found, request, { method: `oauth:${provider.id}` })
93
+ if (done.kind === 'refused') return fail(done.code)
94
+ if (done.kind === 'two-factor') return redirect(withParams(back, { slauth_step: 'two-factor', redirect: saved.redirect }), [clear, done.cookie])
95
+ return redirect(saved.redirect, [clear, done.cookie])
96
+ }
97
+ }
98
+
99
+ function sameSitePath(referer, url) {
100
+ try {
101
+ const from = new URL(referer)
102
+ return from.origin === url.origin ? from.pathname + from.search : null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
@@ -0,0 +1,144 @@
1
+ import { normalizeEmail, publicUser, validEmail } from '../users.js'
2
+ import { json, withParams } from '../util.js'
3
+
4
+ const ROLE = /^[a-z0-9_-]{1,40}$/i
5
+
6
+ /**
7
+ * Organizations, when `organizations` is on. Everything here is for signed-in people.
8
+ *
9
+ * GET orgs → { orgs } mine, each with my role
10
+ * POST orgs { name } → { org } I'm its owner
11
+ * GET orgs/<id> → { org, role, members, invites? }
12
+ * POST orgs/<id> { name } rename (owner, admin)
13
+ * POST orgs/<id>/delete (owner)
14
+ * POST orgs/<id>/invite { email, role? } (owner, admin)
15
+ * POST orgs/<id>/invites/revoke { id } (owner, admin)
16
+ * POST orgs/<id>/members/role { userId, role } (owner, admin; only owners make owners)
17
+ * POST orgs/<id>/members/remove { userId } (owner, admin, or yourself to leave)
18
+ * GET invites → { invites } addressed to my email
19
+ * POST invites/accept { id }
20
+ * POST invites/decline { id }
21
+ *
22
+ * @param {object} app the shared context from core.js
23
+ */
24
+ export function orgRoutes(app) {
25
+ const { orgs, users } = app
26
+ const forbidden = () => json({ error: "You don't have access to that.", code: 'forbidden' }, 403)
27
+ const notFound = () => json({ error: 'No such organization.', code: 'not-found' }, 404)
28
+
29
+ return async function route({ route, method, request, url, body }) {
30
+ if (!(route === 'orgs' || route.startsWith('orgs/') || route === 'invites' || route.startsWith('invites/'))) return
31
+ if (!orgs) return json({ error: 'Organizations are turned off.', code: 'not-found' }, 404)
32
+ const me = await app.signedIn(request)
33
+ if (me instanceof Response) return me
34
+ const { user } = me
35
+
36
+ if (route === 'orgs' && method === 'GET') return json({ orgs: await orgs.forUser(user.id) })
37
+
38
+ if (route === 'orgs' && method === 'POST') {
39
+ const allowCreate = app.orgOptions.allowCreate
40
+ if (allowCreate === false || (typeof allowCreate === 'function' && !(await allowCreate(publicUser(user))))) return forbidden()
41
+ const { name } = await body()
42
+ if (typeof name !== 'string' || !name.trim()) return json({ error: 'Give it a name.', code: 'bad-request' }, 400)
43
+ const org = await orgs.create({ name }, user.id)
44
+ return json({ org: { ...org, role: 'owner' } })
45
+ }
46
+
47
+ if (route === 'invites' && method === 'GET') {
48
+ const out = []
49
+ for (const invite of await orgs.invitesFor(user.email)) {
50
+ const org = await orgs.get(invite.orgId)
51
+ if (org) out.push({ id: invite.id, role: invite.role, org: { id: org.id, name: org.name }, expiresAt: invite.expiresAt })
52
+ }
53
+ return json({ invites: out })
54
+ }
55
+
56
+ if (route === 'invites/accept' && method === 'POST') {
57
+ const accepted = await orgs.accept((await body()).id, user)
58
+ if (!accepted) return json({ error: "That invitation has expired or isn't for you.", code: 'not-found' }, 404)
59
+ return json({ ok: true, org: { ...(await orgs.get(accepted.orgId)), role: accepted.role } })
60
+ }
61
+
62
+ if (route === 'invites/decline' && method === 'POST') {
63
+ const invite = await orgs.getInvite((await body()).id)
64
+ if (!invite || invite.email !== user.email) return json({ error: 'No such invitation.', code: 'not-found' }, 404)
65
+ await orgs.revokeInvite(invite.id)
66
+ return json({ ok: true })
67
+ }
68
+
69
+ const [, orgId, ...rest] = route.split('/')
70
+ const action = rest.join('/')
71
+ const org = await orgs.get(orgId)
72
+ const role = org && (await orgs.role(orgId, user.id))
73
+ if (!org || !role) return notFound()
74
+ const manager = orgs.canManage(role)
75
+
76
+ if (action === '' && method === 'GET') {
77
+ const members = []
78
+ for (const m of await orgs.members(orgId)) {
79
+ const u = await users.byId(m.userId)
80
+ if (u) members.push({ userId: m.userId, role: m.role, joinedAt: m.joinedAt, email: u.email, name: u.name, image: u.image })
81
+ }
82
+ const out = { org, role, members }
83
+ if (manager) out.invites = (await orgs.invitesOf(orgId)).map(({ id, email, role: r, createdAt, expiresAt }) => ({ id, email, role: r, createdAt, expiresAt }))
84
+ return json(out)
85
+ }
86
+
87
+ if (method !== 'POST') return
88
+ const input = await body()
89
+
90
+ if (action === '') {
91
+ if (!manager) return forbidden()
92
+ return json({ org: await orgs.update(orgId, { name: input.name }) })
93
+ }
94
+
95
+ if (action === 'delete') {
96
+ if (role !== 'owner') return forbidden()
97
+ await orgs.remove(orgId)
98
+ return json({ ok: true })
99
+ }
100
+
101
+ if (action === 'invite') {
102
+ if (!manager) return forbidden()
103
+ const email = normalizeEmail(input.email)
104
+ const inviteRole = input.role ?? 'member'
105
+ if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
106
+ if (!ROLE.test(inviteRole)) return json({ error: 'Roles are letters, digits, dashes and underscores.', code: 'bad-request' }, 400)
107
+ if (inviteRole === 'owner' && role !== 'owner') return forbidden()
108
+ const existing = await users.byEmail(email)
109
+ if (existing && (await orgs.role(orgId, existing.id))) return json({ error: "They're already a member.", code: 'already-member' }, 409)
110
+ const invite = await orgs.invite(orgId, email, inviteRole, user.id)
111
+ const link = `${app.origin(url)}${withParams(app.orgOptions.invitePage ?? app.signInPage ?? '/', { slauth_invite: invite.id })}`
112
+ await app.codes.send({ kind: 'invite', to: email, link, orgName: org.name, inviter: user.name ?? user.email })
113
+ return json({ ok: true, invite: { id: invite.id, email, role: inviteRole, expiresAt: invite.expiresAt } })
114
+ }
115
+
116
+ if (action === 'invites/revoke') {
117
+ if (!manager) return forbidden()
118
+ const invite = await orgs.getInvite(input.id)
119
+ if (!invite || invite.orgId !== orgId) return json({ error: 'No such invitation.', code: 'not-found' }, 404)
120
+ await orgs.revokeInvite(invite.id)
121
+ return json({ ok: true })
122
+ }
123
+
124
+ if (action === 'members/role') {
125
+ if (!manager) return forbidden()
126
+ if (typeof input.role !== 'string' || !ROLE.test(input.role)) return json({ error: 'Roles are letters, digits, dashes and underscores.', code: 'bad-request' }, 400)
127
+ const theirs = await orgs.role(orgId, input.userId)
128
+ if (!theirs) return json({ error: "They're not a member.", code: 'not-found' }, 404)
129
+ // Only owners can make owners, or change what an owner is.
130
+ if ((input.role === 'owner' || theirs === 'owner') && role !== 'owner') return forbidden()
131
+ await orgs.setRole(orgId, input.userId, input.role)
132
+ return json({ ok: true })
133
+ }
134
+
135
+ if (action === 'members/remove') {
136
+ const self = input.userId === user.id
137
+ const theirs = await orgs.role(orgId, input.userId)
138
+ if (!theirs) return json({ error: "They're not a member.", code: 'not-found' }, 404)
139
+ if (!self && (!manager || (theirs === 'owner' && role !== 'owner'))) return forbidden()
140
+ await orgs.removeMember(orgId, input.userId)
141
+ return json({ ok: true })
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,136 @@
1
+ import { PasskeyError, readChallenge, verifyAuthentication, verifyRegistration } from '../passkey/webauthn.js'
2
+ import { base64Url, json } from '../util.js'
3
+
4
+ const CHALLENGE_SECONDS = 5 * 60
5
+
6
+ /**
7
+ * Passkeys (WebAuthn).
8
+ *
9
+ * challenge:<challenge> { type, userId? } (lives 5 minutes, single use)
10
+ * passkey:<id> { id, publicKey, alg, counter, userId, name, createdAt, lastUsedAt }
11
+ * passkeys:<userId> [id, ...]
12
+ *
13
+ * @param {object} app the shared context from core.js
14
+ */
15
+ export function passkeyRoutes(app) {
16
+ const { store, users, passkeys } = app
17
+
18
+ const expect = (url, challenge) => ({
19
+ rpId: passkeys.rpId ?? url.hostname,
20
+ origin: passkeys.origin ?? app.origins ?? url.origin,
21
+ challenge,
22
+ requireUserVerification: passkeys.userVerification === 'required',
23
+ })
24
+
25
+ async function takeChallenge(clientDataJSON, type) {
26
+ const challenge = readChallenge(clientDataJSON)
27
+ if (!challenge) throw new PasskeyError('The response has no challenge.')
28
+ const stored = await store.get(`challenge:${challenge}`)
29
+ // Single use, whatever happens next.
30
+ await store.delete(`challenge:${challenge}`)
31
+ if (!stored || stored.type !== type) throw new PasskeyError('That passkey request expired. Try again.')
32
+ return { challenge, stored }
33
+ }
34
+
35
+ async function newChallenge(value) {
36
+ const challenge = base64Url(crypto.getRandomValues(new Uint8Array(32)))
37
+ await store.put(`challenge:${challenge}`, value, { ttl: CHALLENGE_SECONDS })
38
+ return challenge
39
+ }
40
+
41
+ return async function route({ route, method, request, url, body }) {
42
+ if (!route.startsWith('passkey') && route !== 'passkeys') return
43
+ if (passkeys === false) return json({ error: 'Passkeys are turned off.', code: 'not-found' }, 404)
44
+
45
+ if (route === 'passkey/login/options' && method === 'POST') {
46
+ const challenge = await newChallenge({ type: 'login' })
47
+ return json({
48
+ challenge,
49
+ rpId: passkeys.rpId ?? url.hostname,
50
+ timeout: CHALLENGE_SECONDS * 1000,
51
+ userVerification: passkeys.userVerification ?? 'preferred',
52
+ allowCredentials: [],
53
+ })
54
+ }
55
+
56
+ if (route === 'passkey/login/verify' && method === 'POST') {
57
+ const { credential } = await body()
58
+ await takeChallenge(credential?.response?.clientDataJSON, 'login')
59
+ const stored = typeof credential?.id === 'string' ? await store.get(`passkey:${credential.id}`) : null
60
+ if (!stored) return json({ error: "This passkey isn't registered here.", code: 'passkey-unknown' }, 400)
61
+ if (credential.response.userHandle && credential.response.userHandle !== base64Url(new TextEncoder().encode(stored.userId))) {
62
+ throw new PasskeyError('This passkey belongs to someone else.')
63
+ }
64
+ const { counter } = await verifyAuthentication(credential, stored, expect(url, readChallenge(credential.response.clientDataJSON)))
65
+ await store.put(`passkey:${stored.id}`, { ...stored, counter, lastUsedAt: Date.now() })
66
+ const found = await users.byId(stored.userId)
67
+ if (!found || !(await users.allowed(found.email, { method: 'passkey' }))) return json({ error: "You're not on the list for this app.", code: 'not-allowed' }, 403)
68
+ return app.reply(await app.complete(found, request, { method: 'passkey' }), {}, request)
69
+ }
70
+
71
+ // Everything below is for someone already signed in.
72
+ const me = await app.signedIn(request)
73
+ if (me instanceof Response) return me
74
+ const found = me.user
75
+ const ids = (await store.get(`passkeys:${found.id}`)) ?? []
76
+
77
+ if (route === 'passkey/register/options' && method === 'POST') {
78
+ // A passkey is a way back in: only add one soon after signing in.
79
+ if (!app.fresh(me.session)) return app.reauth()
80
+ const challenge = await newChallenge({ type: 'register', userId: found.id })
81
+ return json({
82
+ challenge,
83
+ rp: { id: passkeys.rpId ?? url.hostname, name: passkeys.rpName ?? app.appName },
84
+ user: { id: base64Url(new TextEncoder().encode(found.id)), name: found.email, displayName: found.name ?? found.email },
85
+ pubKeyCredParams: [
86
+ { type: 'public-key', alg: -7 },
87
+ { type: 'public-key', alg: -257 },
88
+ ],
89
+ timeout: CHALLENGE_SECONDS * 1000,
90
+ attestation: 'none',
91
+ authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: passkeys.userVerification ?? 'preferred' },
92
+ excludeCredentials: ids.map((id) => ({ type: 'public-key', id })),
93
+ })
94
+ }
95
+
96
+ if (route === 'passkey/register/verify' && method === 'POST') {
97
+ const { credential, name } = await body()
98
+ const { challenge, stored } = await takeChallenge(credential?.response?.clientDataJSON, 'register')
99
+ if (stored.userId !== found.id) throw new PasskeyError('That passkey request was for someone else.')
100
+ const verified = await verifyRegistration(credential, expect(url, challenge))
101
+ if (await store.get(`passkey:${verified.id}`)) return json({ error: 'That passkey is already registered.', code: 'passkey-exists' }, 409)
102
+ const label = typeof name === 'string' && name.trim() ? name.trim().slice(0, 60) : `Passkey added ${new Date().toISOString().slice(0, 10)}`
103
+ await store.put(`passkey:${verified.id}`, { ...verified, userId: found.id, name: label, createdAt: Date.now(), lastUsedAt: null })
104
+ await store.put(`passkeys:${found.id}`, [...ids, verified.id])
105
+ return json({ ok: true, passkey: { id: verified.id, name: label } })
106
+ }
107
+
108
+ if (route === 'passkeys' && method === 'GET') {
109
+ const list = []
110
+ for (const id of ids) {
111
+ const pk = await store.get(`passkey:${id}`)
112
+ if (pk) list.push({ id: pk.id, name: pk.name, createdAt: pk.createdAt, lastUsedAt: pk.lastUsedAt })
113
+ }
114
+ return json({ passkeys: list })
115
+ }
116
+
117
+ if (route === 'passkey/rename' && method === 'POST') {
118
+ const { id, name } = await body()
119
+ if (!ids.includes(id)) return json({ error: 'No such passkey.', code: 'passkey-unknown' }, 404)
120
+ if (typeof name !== 'string' || !name.trim()) return json({ error: 'Give it a name.', code: 'bad-request' }, 400)
121
+ const pk = await store.get(`passkey:${id}`)
122
+ await store.put(`passkey:${id}`, { ...pk, name: name.trim().slice(0, 60) })
123
+ return json({ ok: true })
124
+ }
125
+
126
+ if (route === 'passkey/delete' && method === 'POST') {
127
+ const { id } = await body()
128
+ if (!ids.includes(id)) return json({ error: 'No such passkey.', code: 'passkey-unknown' }, 404)
129
+ await store.delete(`passkey:${id}`)
130
+ await store.put(`passkeys:${found.id}`, ids.filter((x) => x !== id))
131
+ return json({ ok: true })
132
+ }
133
+
134
+ return json({ error: 'Not found.', code: 'not-found' }, 404)
135
+ }
136
+ }
@@ -0,0 +1,78 @@
1
+ import { normalizeEmail, validEmail } from '../users.js'
2
+ import { json } from '../util.js'
3
+
4
+ /**
5
+ * Passwords, when `passwords` is on.
6
+ *
7
+ * POST password/signin { email, password } sign in
8
+ * POST password/signup { email, password } emails a code; the password is saved once it's used
9
+ * POST password/set { password, current? } add or change (signed in)
10
+ * POST password/remove { } go back to codes and passkeys (signed in)
11
+ *
12
+ * @param {object} app the shared context from core.js
13
+ */
14
+ export function passwordRoutes(app) {
15
+ const { store, users, passwords, limits } = app
16
+
17
+ return async function route({ route, method, request, url, body }) {
18
+ if (!route.startsWith('password/')) return
19
+ if (!passwords) return json({ error: 'Passwords are turned off.', code: 'not-found' }, 404)
20
+ if (method !== 'POST') return
21
+
22
+ if (route === 'password/signin') {
23
+ const input = await body()
24
+ const email = normalizeEmail(input.email)
25
+ if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
26
+ const wait = await limits.check('password/signin', request)
27
+ if (wait) return json({ error: `Too many tries. Please wait ${wait}s.`, code: 'slow-down', retryAfter: wait }, 429)
28
+ const found = await users.byEmail(email)
29
+ const checked = await passwords.check(email, found?.id ?? null, input.password)
30
+ if (!checked.ok) {
31
+ if (checked.code === 'too-many-tries') return json({ error: 'Too many wrong passwords. Sign in with an email code, or wait 15 minutes.', code: 'too-many-tries' }, 429)
32
+ return json({ error: "That email and password don't match.", code: 'bad-password' }, 400)
33
+ }
34
+ if (!(await users.allowed(email, { method: 'password' }))) return json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
35
+ return app.reply(await app.complete(found, request, { method: 'password' }), {}, request)
36
+ }
37
+
38
+ if (route === 'password/signup') {
39
+ const input = await body()
40
+ const email = normalizeEmail(input.email)
41
+ if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
42
+ const problem = await passwords.problem(input.password)
43
+ if (problem) return json({ error: problem, code: 'weak-password' }, 400)
44
+ const wait = await limits.check('password/signup', request)
45
+ if (wait) return json({ error: `Please wait ${wait}s before trying again.`, code: 'slow-down', retryAfter: wait }, 429)
46
+ if (!(await users.allowed(email, { method: 'password' }))) return json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
47
+ // Same answer whether or not they have an account, so this can't be used to find out.
48
+ const sent = await app.sendSignInCode(email, url, input.redirect)
49
+ if (!sent.ok) return json({ error: `Please wait ${sent.retryAfter}s before asking for another code.`, code: 'slow-down', retryAfter: sent.retryAfter }, 429)
50
+ await store.put(`signup:${email}`, { hash: await passwords.hash(input.password) }, { ttl: 10 * 60 })
51
+ return json({ ok: true, verify: 'email' })
52
+ }
53
+
54
+ const me = await app.signedIn(request)
55
+ if (me instanceof Response) return me
56
+
57
+ if (route === 'password/set') {
58
+ const input = await body()
59
+ const has = await passwords.has(me.user.id)
60
+ // Changing a password takes the old one, or a recent sign-in.
61
+ if (has && input.current !== undefined) {
62
+ if (!(await passwords.verify(me.user.id, input.current))) return json({ error: "That's not your current password.", code: 'bad-password' }, 400)
63
+ } else if (!app.fresh(me.session)) return app.reauth()
64
+ const problem = await passwords.problem(input.password)
65
+ if (problem) return json({ error: problem, code: 'weak-password' }, 400)
66
+ await passwords.set(me.user.id, input.password)
67
+ // Anyone else signed in with the old password is signed out.
68
+ if (has) await app.sessions.revokeAll(me.user.id, me.session?.id)
69
+ return json({ ok: true })
70
+ }
71
+
72
+ if (route === 'password/remove') {
73
+ if (!app.fresh(me.session)) return app.reauth()
74
+ await passwords.remove(me.user.id)
75
+ return json({ ok: true })
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,89 @@
1
+ import { publicUser } from '../users.js'
2
+ import { json, serializeCookie } from '../util.js'
3
+
4
+ const MAX_TRIES = 5
5
+
6
+ /**
7
+ * Two-factor sign-in, when `twoFactor` is on. After the first step (a code, a link, a
8
+ * password or a provider) someone with an authenticator app set up gets
9
+ * `{ twoFactor: true }` instead of a session, and finishes here.
10
+ *
11
+ * POST two-factor/verify { code } or { recoveryCode } (+ pendingToken for apps)
12
+ * POST two-factor/setup → { secret, uri } (signed in, recently)
13
+ * POST two-factor/enable { code } → { recoveryCodes }
14
+ * POST two-factor/disable { code } or { recoveryCode }
15
+ * POST two-factor/recovery-codes { code } → { recoveryCodes } (a fresh set)
16
+ *
17
+ * 2fa:<sha256 of token> { userId, method, tries } (lives 10 minutes)
18
+ *
19
+ * Passkeys skip this step: a passkey is already something you have and something you are.
20
+ *
21
+ * @param {object} app the shared context from core.js
22
+ */
23
+ export function twoFactorRoutes(app) {
24
+ const { store, users, twoFactor, limits } = app
25
+
26
+ return async function route({ route, method, request, body }) {
27
+ if (!route.startsWith('two-factor/')) return
28
+ if (!twoFactor) return json({ error: 'Two-factor sign-in is turned off.', code: 'not-found' }, 404)
29
+ if (method !== 'POST') return
30
+ const input = await body()
31
+ const clearPending = serializeCookie(app.pendingCookie, '', { maxAge: 0, secure: app.secure(request) })
32
+
33
+ if (route === 'two-factor/verify') {
34
+ const wait = await limits.check('two-factor/verify', request)
35
+ if (wait) return json({ error: `Too many tries. Please wait ${wait}s.`, code: 'slow-down', retryAfter: wait }, 429)
36
+ const pending = await app.pending(request, typeof input.pendingToken === 'string' ? input.pendingToken : undefined)
37
+ if (!pending) return json({ error: 'That sign-in has expired. Please start again.', code: 'expired' }, 410, { 'Set-Cookie': clearPending })
38
+ const ok = await twoFactor.check(pending.userId, { code: input.code, recoveryCode: input.recoveryCode })
39
+ if (!ok) {
40
+ const tries = pending.tries + 1
41
+ const { key, ...doc } = pending
42
+ if (tries >= MAX_TRIES) {
43
+ await store.delete(key)
44
+ return json({ error: 'Too many tries. Please start again.', code: 'too-many-tries' }, 410, { 'Set-Cookie': clearPending })
45
+ }
46
+ await store.put(key, { ...doc, tries }, { ttl: 10 * 60 })
47
+ return json({ error: 'That code is wrong.', code: 'bad-code', triesLeft: MAX_TRIES - tries }, 400)
48
+ }
49
+ await store.delete(pending.key)
50
+ const found = await users.byId(pending.userId)
51
+ if (!found || found.bannedAt) return json({ error: "This account can't sign in.", code: 'banned' }, 403, { 'Set-Cookie': clearPending })
52
+ const made = await app.sessions.create(found.id, request, { method: `${pending.method}+totp` })
53
+ await app.emit('signIn', { user: publicUser(found), method: pending.method, request })
54
+ const extra = input.recoveryCode ? { recoveryCodesLeft: await twoFactor.recoveryLeft(found.id) } : {}
55
+ if (request.headers.get('x-slauth-transport') === 'bearer') return json({ user: publicUser(found), token: made.token, ...extra })
56
+ const headers = new Headers({ 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
57
+ headers.append('Set-Cookie', made.cookie)
58
+ headers.append('Set-Cookie', clearPending)
59
+ return new Response(JSON.stringify({ user: publicUser(found), ...extra }), { headers })
60
+ }
61
+
62
+ const me = await app.signedIn(request)
63
+ if (me instanceof Response) return me
64
+ const userId = me.user.id
65
+
66
+ if (route === 'two-factor/setup') {
67
+ if (await twoFactor.enabled(userId)) return json({ error: 'Two-factor sign-in is already on.', code: 'two-factor-on' }, 409)
68
+ if (!app.fresh(me.session)) return app.reauth()
69
+ return json(await twoFactor.setup(me.user))
70
+ }
71
+
72
+ if (route === 'two-factor/enable') {
73
+ const recoveryCodes = await twoFactor.enable(userId, input.code)
74
+ if (!recoveryCodes) return json({ error: 'That code is wrong. Check the time on your phone, and try the newest code.', code: 'bad-code' }, 400)
75
+ return json({ ok: true, recoveryCodes })
76
+ }
77
+
78
+ if (route === 'two-factor/disable') {
79
+ if (!(await twoFactor.check(userId, input))) return json({ error: 'That code is wrong.', code: 'bad-code' }, 400)
80
+ await twoFactor.disable(userId)
81
+ return json({ ok: true })
82
+ }
83
+
84
+ if (route === 'two-factor/recovery-codes') {
85
+ if (!(await twoFactor.check(userId, { code: input.code }))) return json({ error: 'That code is wrong.', code: 'bad-code' }, 400)
86
+ return json({ ok: true, recoveryCodes: await twoFactor.regenerate(userId) })
87
+ }
88
+ }
89
+ }