slauthjs 0.0.0-stage → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +92 -2
  4. package/package.json +114 -4
  5. package/src/access.js +143 -0
  6. package/src/account-element.d.ts +8 -0
  7. package/src/account-element.js +244 -0
  8. package/src/client.d.ts +113 -0
  9. package/src/client.js +211 -0
  10. package/src/core.js +442 -0
  11. package/src/element.d.ts +6 -0
  12. package/src/element.js +253 -0
  13. package/src/email/cloudflare.d.ts +1 -0
  14. package/src/email/cloudflare.js +19 -0
  15. package/src/email/console.d.ts +1 -0
  16. package/src/email/console.js +14 -0
  17. package/src/email/index.d.ts +4 -0
  18. package/src/email/message.js +56 -0
  19. package/src/email/resend.d.ts +1 -0
  20. package/src/email/resend.js +21 -0
  21. package/src/email-code.js +75 -0
  22. package/src/index.d.ts +364 -0
  23. package/src/index.js +9 -0
  24. package/src/lock.js +225 -0
  25. package/src/node.d.ts +3 -0
  26. package/src/node.js +67 -0
  27. package/src/oauth/flow.js +81 -0
  28. package/src/oauth/providers.js +171 -0
  29. package/src/orgs.js +184 -0
  30. package/src/passkey/cbor.js +99 -0
  31. package/src/passkey/webauthn.js +183 -0
  32. package/src/password.js +81 -0
  33. package/src/providers.d.ts +28 -0
  34. package/src/providers.js +1 -0
  35. package/src/rate-limit.js +41 -0
  36. package/src/routes/account.js +130 -0
  37. package/src/routes/email.js +147 -0
  38. package/src/routes/oauth.js +106 -0
  39. package/src/routes/orgs.js +144 -0
  40. package/src/routes/passkeys.js +136 -0
  41. package/src/routes/password.js +78 -0
  42. package/src/routes/two-factor.js +89 -0
  43. package/src/session.js +131 -0
  44. package/src/stores/d1.d.ts +1 -0
  45. package/src/stores/d1.js +58 -0
  46. package/src/stores/index.d.ts +5 -0
  47. package/src/stores/kv.d.ts +1 -0
  48. package/src/stores/kv.js +27 -0
  49. package/src/stores/memory.d.ts +1 -0
  50. package/src/stores/memory.js +35 -0
  51. package/src/stores/sqlite.d.ts +1 -0
  52. package/src/stores/sqlite.js +42 -0
  53. package/src/tokens.js +119 -0
  54. package/src/two-factor.js +142 -0
  55. package/src/ui.js +49 -0
  56. package/src/users.js +126 -0
  57. package/src/util.js +106 -0
@@ -0,0 +1,183 @@
1
+ import { decodeCbor } from './cbor.js'
2
+ import { base64Url, concat, fromBase64Url, sha256 } from '../util.js'
3
+
4
+ /**
5
+ * WebAuthn (passkeys), the parts a server needs, with Web Crypto only:
6
+ *
7
+ * - registration: check the browser's answer to navigator.credentials.create() and pull
8
+ * out the new public key;
9
+ * - sign-in: check a signature from navigator.credentials.get() with that key.
10
+ *
11
+ * Attestation is not checked ("none"): for a personal project what matters is that the
12
+ * same passkey signs in later, not which company made the authenticator.
13
+ *
14
+ * Supports ES256 (every platform passkey) and RS256 (some Windows Hello keys).
15
+ */
16
+
17
+ export const ES256 = -7
18
+ export const RS256 = -257
19
+
20
+ const FLAG_UP = 0x01
21
+ const FLAG_UV = 0x04
22
+ const FLAG_AT = 0x40
23
+
24
+ /**
25
+ * @typedef {{ origin: string | string[], rpId: string, challenge: string, requireUserVerification?: boolean }} Expect
26
+ * @typedef {{ id: string, publicKey: JsonWebKey, alg: number, counter: number, transports?: string[] }} Credential
27
+ */
28
+
29
+ /**
30
+ * Checks a registration response.
31
+ * @param {{ id: string, rawId?: string, type: string, response: { clientDataJSON: string, attestationObject: string, transports?: string[] } }} credential
32
+ * @param {Expect} expect
33
+ * @returns {Promise<Credential>}
34
+ */
35
+ export async function verifyRegistration(credential, expect) {
36
+ if (credential?.type !== 'public-key' || typeof credential.id !== 'string') throw new PasskeyError('Not a passkey.')
37
+ await checkClientData(credential.response?.clientDataJSON, 'webauthn.create', expect)
38
+
39
+ const attestation = decodeCbor(fromBase64Url(credential.response.attestationObject)).value
40
+ if (!(attestation instanceof Map) || !(attestation.get('authData') instanceof Uint8Array)) {
41
+ throw new PasskeyError('The attestation is malformed.')
42
+ }
43
+ const auth = await parseAuthData(attestation.get('authData'), expect)
44
+ if (!auth.credentialId || !auth.coseKey) throw new PasskeyError('No new credential in the response.')
45
+ if (base64Url(auth.credentialId) !== credential.id) throw new PasskeyError('Credential id mismatch.')
46
+
47
+ const { jwk, alg } = coseToJwk(auth.coseKey)
48
+ // Make sure Web Crypto accepts the key now, rather than at the first sign-in.
49
+ await importKey(jwk, alg)
50
+ const transports = Array.isArray(credential.response.transports) ? credential.response.transports.filter((t) => typeof t === 'string') : undefined
51
+ return { id: credential.id, publicKey: jwk, alg, counter: auth.signCount, transports }
52
+ }
53
+
54
+ /**
55
+ * Checks a sign-in (assertion) response against a stored credential.
56
+ * @param {{ id: string, type: string, response: { clientDataJSON: string, authenticatorData: string, signature: string, userHandle?: string | null } }} assertion
57
+ * @param {Credential} stored
58
+ * @param {Expect} expect
59
+ * @returns {Promise<{ counter: number }>} the new signature counter to store
60
+ */
61
+ export async function verifyAuthentication(assertion, stored, expect) {
62
+ if (assertion?.type !== 'public-key' || assertion.id !== stored.id) throw new PasskeyError('Not the expected passkey.')
63
+ const clientDataBytes = fromBase64Url(assertion.response?.clientDataJSON ?? '')
64
+ await checkClientData(assertion.response.clientDataJSON, 'webauthn.get', expect)
65
+
66
+ const authData = fromBase64Url(assertion.response.authenticatorData)
67
+ const auth = await parseAuthData(authData, expect)
68
+
69
+ const signed = concat(authData, await sha256(clientDataBytes))
70
+ let signature = fromBase64Url(assertion.response.signature)
71
+ const key = await importKey(stored.publicKey, stored.alg)
72
+ if (stored.alg === ES256) signature = derToRaw(signature)
73
+ const algorithm = stored.alg === ES256 ? { name: 'ECDSA', hash: 'SHA-256' } : { name: 'RSASSA-PKCS1-v1_5' }
74
+ if (!(await crypto.subtle.verify(algorithm, key, signature, signed))) throw new PasskeyError('The signature is wrong.')
75
+
76
+ // A counter that goes backwards means the passkey was copied. Many passkeys always
77
+ // send 0, which means "no counter" and is fine.
78
+ if ((auth.signCount !== 0 || stored.counter !== 0) && auth.signCount <= stored.counter) {
79
+ throw new PasskeyError('This passkey may have been cloned (its counter went backwards).')
80
+ }
81
+ return { counter: auth.signCount }
82
+ }
83
+
84
+ export class PasskeyError extends Error {}
85
+
86
+ /** The challenge a response answers, so the server can look up what it sent. */
87
+ export function readChallenge(clientDataJSON) {
88
+ try {
89
+ const challenge = JSON.parse(new TextDecoder().decode(fromBase64Url(clientDataJSON))).challenge
90
+ return typeof challenge === 'string' ? challenge : null
91
+ } catch {
92
+ return null
93
+ }
94
+ }
95
+
96
+ async function checkClientData(encoded, type, expect) {
97
+ let data
98
+ try {
99
+ data = JSON.parse(new TextDecoder().decode(fromBase64Url(encoded)))
100
+ } catch {
101
+ throw new PasskeyError('clientDataJSON is malformed.')
102
+ }
103
+ if (data.type !== type) throw new PasskeyError(`Expected a ${type} response.`)
104
+ if (data.challenge !== expect.challenge) throw new PasskeyError('The challenge does not match.')
105
+ const origins = [expect.origin].flat()
106
+ if (!origins.includes(data.origin)) throw new PasskeyError(`Unexpected origin ${data.origin}.`)
107
+ if (data.crossOrigin === true) throw new PasskeyError('Cross-origin passkey requests are not allowed.')
108
+ return data
109
+ }
110
+
111
+ async function parseAuthData(bytes, expect) {
112
+ if (bytes.length < 37) throw new PasskeyError('authenticatorData is too short.')
113
+ const rpIdHash = bytes.subarray(0, 32)
114
+ const flags = bytes[32]
115
+ const signCount = new DataView(bytes.buffer, bytes.byteOffset + 33, 4).getUint32(0)
116
+
117
+ const expected = await sha256(expect.rpId)
118
+ if (base64Url(rpIdHash) !== base64Url(expected)) throw new PasskeyError('The passkey is for a different site (rpId).')
119
+ if (!(flags & FLAG_UP)) throw new PasskeyError('The user was not present.')
120
+ if (expect.requireUserVerification && !(flags & FLAG_UV)) throw new PasskeyError('The user was not verified.')
121
+
122
+ const out = { flags, signCount, credentialId: null, coseKey: null }
123
+ if (flags & FLAG_AT) {
124
+ let at = 37 + 16 // skip the AAGUID
125
+ if (bytes.length < at + 2) throw new PasskeyError('authenticatorData is truncated.')
126
+ const idLength = (bytes[at] << 8) | bytes[at + 1]
127
+ at += 2
128
+ if (bytes.length < at + idLength) throw new PasskeyError('authenticatorData is truncated.')
129
+ out.credentialId = bytes.slice(at, at + idLength)
130
+ at += idLength
131
+ out.coseKey = decodeCbor(bytes, at).value
132
+ }
133
+ return out
134
+ }
135
+
136
+ /** COSE_Key (RFC 9053) -> JWK. */
137
+ function coseToJwk(cose) {
138
+ if (!(cose instanceof Map)) throw new PasskeyError('The public key is malformed.')
139
+ const kty = cose.get(1)
140
+ const alg = cose.get(3)
141
+ if (kty === 2 && alg === ES256 && cose.get(-1) === 1) {
142
+ const x = cose.get(-2)
143
+ const y = cose.get(-3)
144
+ if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) {
145
+ throw new PasskeyError('The P-256 key is malformed.')
146
+ }
147
+ return { alg, jwk: { kty: 'EC', crv: 'P-256', x: base64Url(x), y: base64Url(y) } }
148
+ }
149
+ if (kty === 3 && alg === RS256) {
150
+ const n = cose.get(-1)
151
+ const e = cose.get(-2)
152
+ if (!(n instanceof Uint8Array) || !(e instanceof Uint8Array)) throw new PasskeyError('The RSA key is malformed.')
153
+ return { alg, jwk: { kty: 'RSA', n: base64Url(n), e: base64Url(e), alg: 'RS256' } }
154
+ }
155
+ throw new PasskeyError(`Unsupported passkey algorithm (${alg}).`)
156
+ }
157
+
158
+ function importKey(jwk, alg) {
159
+ const algorithm = alg === ES256 ? { name: 'ECDSA', namedCurve: 'P-256' } : { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
160
+ return crypto.subtle.importKey('jwk', jwk, algorithm, false, ['verify'])
161
+ }
162
+
163
+ /** ECDSA signatures arrive DER-encoded; Web Crypto wants r || s, 32 bytes each. */
164
+ function derToRaw(der) {
165
+ const fail = () => {
166
+ throw new PasskeyError('The signature is malformed.')
167
+ }
168
+ if (der[0] !== 0x30) fail()
169
+ let at = 2
170
+ if (der[1] & 0x80) at = 2 + (der[1] & 0x7f)
171
+ const int = () => {
172
+ if (der[at] !== 0x02) fail()
173
+ const len = der[at + 1]
174
+ let value = der.subarray(at + 2, at + 2 + len)
175
+ at += 2 + len
176
+ while (value.length > 32 && value[0] === 0) value = value.subarray(1)
177
+ if (value.length > 32) fail()
178
+ const out = new Uint8Array(32)
179
+ out.set(value, 32 - value.length)
180
+ return out
181
+ }
182
+ return concat(int(), int())
183
+ }
@@ -0,0 +1,81 @@
1
+ import { base64Url, fromBase64Url, safeEqual } from './util.js'
2
+
3
+ const MAX_FAILURES = 10
4
+ const FAILURE_WINDOW_SECONDS = 15 * 60
5
+
6
+ /**
7
+ * Optional passwords, hashed with PBKDF2-SHA256 from Web Crypto. 100,000 rounds by
8
+ * default: the most Cloudflare Workers allows. Raise `iterations` elsewhere if you like;
9
+ * old hashes keep working because each one records its own count.
10
+ *
11
+ * password:<userId> { hash: "pbkdf2-sha256$<iterations>$<salt>$<hash>", updatedAt }
12
+ * pwfail:<email> { count } (10 wrong guesses → wait 15 minutes)
13
+ *
14
+ * People only get a password after proving their email (a code, a link or a provider),
15
+ * so "forgot password" is just "sign in with a code, then set a new one".
16
+ *
17
+ * @param {{ store: import('./index.js').Store, iterations?: number, minLength?: number, validate?: (password: string) => string | null | Promise<string | null> }} options
18
+ */
19
+ export function createPasswords({ store, iterations = 100_000, minLength = 8, validate }) {
20
+ // Run a real hash even for unknown emails, so timing doesn't reveal who has an account.
21
+ let decoy = null
22
+
23
+ return {
24
+ /** An error message when the password won't do, else null. */
25
+ async problem(password) {
26
+ if (typeof password !== 'string' || password.length < minLength) return `Passwords need at least ${minLength} characters.`
27
+ if (password.length > 256) return 'That password is too long.'
28
+ return validate ? ((await validate(password)) ?? null) : null
29
+ },
30
+
31
+ hash: (password) => hashPassword(password, iterations),
32
+
33
+ async set(userId, password) {
34
+ await store.put(`password:${userId}`, { hash: await hashPassword(password, iterations), updatedAt: Date.now() })
35
+ },
36
+
37
+ async has(userId) {
38
+ return Boolean(await store.get(`password:${userId}`))
39
+ },
40
+
41
+ async remove(userId) {
42
+ await store.delete(`password:${userId}`)
43
+ },
44
+
45
+ /** Checks a guess for a user (or null for "no such user"), counting failures per email. */
46
+ async check(email, userId, password) {
47
+ const failures = (await store.get(`pwfail:${email}`)) ?? { count: 0 }
48
+ if (failures.count >= MAX_FAILURES) return { ok: false, code: 'too-many-tries' }
49
+ const stored = userId ? await store.get(`password:${userId}`) : null
50
+ decoy ??= await hashPassword('decoy-password', iterations)
51
+ const ok = await verifyPassword(String(password ?? ''), stored?.hash ?? decoy)
52
+ if (ok && stored) {
53
+ await store.delete(`pwfail:${email}`)
54
+ return { ok: true }
55
+ }
56
+ await store.put(`pwfail:${email}`, { count: failures.count + 1 }, { ttl: FAILURE_WINDOW_SECONDS })
57
+ return { ok: false, code: 'bad-password' }
58
+ },
59
+
60
+ async verify(userId, password) {
61
+ const stored = await store.get(`password:${userId}`)
62
+ return Boolean(stored) && verifyPassword(String(password ?? ''), stored.hash)
63
+ },
64
+ }
65
+ }
66
+
67
+ export async function hashPassword(password, iterations = 100_000) {
68
+ const salt = crypto.getRandomValues(new Uint8Array(16))
69
+ return `pbkdf2-sha256$${iterations}$${base64Url(salt)}$${base64Url(await derive(password, salt, iterations))}`
70
+ }
71
+
72
+ export async function verifyPassword(password, stored) {
73
+ const [scheme, rounds, salt, hash] = String(stored).split('$')
74
+ if (scheme !== 'pbkdf2-sha256') return false
75
+ return safeEqual(base64Url(await derive(password, fromBase64Url(salt), Number(rounds))), hash)
76
+ }
77
+
78
+ async function derive(password, salt, iterations) {
79
+ const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password.normalize('NFKC')), 'PBKDF2', false, ['deriveBits'])
80
+ return crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, key, 256)
81
+ }
@@ -0,0 +1,28 @@
1
+ import type { Profile, Provider } from './index.js'
2
+
3
+ interface Common {
4
+ clientId: string
5
+ clientSecret?: string
6
+ /** Count the provider's email as verified even when it doesn't say so. */
7
+ trustEmail?: boolean
8
+ /** Save access and refresh tokens, for auth.accounts.list(). */
9
+ keepTokens?: boolean
10
+ scope?: string
11
+ params?: Record<string, string>
12
+ }
13
+
14
+ export function oauth2(options: Common & {
15
+ id: string
16
+ name: string
17
+ authorization: string
18
+ token: string
19
+ pkce?: boolean
20
+ tokenAuth?: 'post' | 'basic'
21
+ profile(tokens: Record<string, any>, fetch: typeof globalThis.fetch): Promise<Profile>
22
+ }): Provider
23
+ export function oidc(options: Common & { id: string; name: string; issuer: string; tokenAuth?: 'post' | 'basic' }): Provider
24
+ export function github(options: Common): Provider
25
+ export function google(options: Common): Provider
26
+ export function discord(options: Common): Provider
27
+ export function gitlab(options: Common & { baseUrl?: string }): Provider
28
+ export function microsoft(options: Common & { tenant?: string }): Provider
@@ -0,0 +1 @@
1
+ export { oauth2, oidc, github, google, discord, gitlab, microsoft } from './oauth/providers.js'
@@ -0,0 +1,41 @@
1
+ import { clientIp } from './util.js'
2
+
3
+ /** Per-IP limits on the routes that send email or check a guess: [requests, per seconds]. */
4
+ export const DEFAULT_LIMITS = {
5
+ 'email/start': [10, 600],
6
+ 'password/signin': [20, 600],
7
+ 'password/signup': [10, 600],
8
+ 'two-factor/verify': [20, 600],
9
+ }
10
+
11
+ /**
12
+ * A fixed-window counter per IP and route, kept in the store.
13
+ *
14
+ * rate:<route>:<ip>:<window> count
15
+ *
16
+ * Off when no IP can be trusted (see `ip` in the config): the per-email and per-code
17
+ * limits still apply then.
18
+ *
19
+ * @param {import('./index.js').Store} store
20
+ * @param {false | { limits?: Record<string, [number, number]>, ip?: (request: Request) => string | null }} [options]
21
+ */
22
+ export function createRateLimit(store, options = {}) {
23
+ if (options === false) return { check: async () => null }
24
+ const limits = { ...DEFAULT_LIMITS, ...options.limits }
25
+ const ipOf = options.ip ?? clientIp
26
+
27
+ return {
28
+ /** Null when it's fine, or how many seconds to wait. */
29
+ async check(route, request) {
30
+ const limit = limits[route]
31
+ const ip = limit && ipOf(request)
32
+ if (!ip) return null
33
+ const [max, seconds] = limit
34
+ const window = Math.floor(Date.now() / 1000 / seconds)
35
+ const key = `rate:${route}:${ip}:${window}`
36
+ const count = ((await store.get(key)) ?? 0) + 1
37
+ await store.put(key, count, { ttl: seconds })
38
+ return count > max ? (window + 1) * seconds - Math.floor(Date.now() / 1000) : null
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,130 @@
1
+ import { publicUser } from '../users.js'
2
+ import { json } from '../util.js'
3
+
4
+ /**
5
+ * The signed-in person's own account: the session, their profile, their other
6
+ * sessions, linked providers, and tokens for your APIs.
7
+ *
8
+ * @param {object} app the shared context from core.js
9
+ */
10
+ export function accountRoutes(app) {
11
+ const { store, sessions, users, lock, tokens } = app
12
+
13
+ return async function route({ route, method, request, url, body }) {
14
+ if (route === 'session' && method === 'GET') {
15
+ const me = await app.current(request)
16
+ const headers = {}
17
+ if (me?.session) {
18
+ const refreshed = await sessions.refresh(me.session, request)
19
+ if (refreshed) headers['Set-Cookie'] = refreshed
20
+ }
21
+ const out = { user: me ? publicUser(me.user) : null }
22
+ if (me?.session?.impersonatorId) out.impersonatorId = me.session.impersonatorId
23
+ if (!me && app.twoFactor && (await app.pending(request))) out.twoFactor = 'pending'
24
+ if (lock && me) out.lock = (await lock.handle('session', 'GET', body, { email: me.user.email, userId: me.user.id }, request.headers.get('cookie'), url.protocol === 'https:')).body
25
+ return json(out, 200, headers)
26
+ }
27
+
28
+ // What the sign-in box should offer.
29
+ if (route === 'config' && method === 'GET') {
30
+ return json({
31
+ appName: app.appName,
32
+ emailCodes: true,
33
+ magicLinks: Boolean(app.magicLinks),
34
+ passkeys: app.passkeys !== false,
35
+ passwords: Boolean(app.passwords),
36
+ twoFactor: Boolean(app.twoFactor),
37
+ organizations: Boolean(app.orgs),
38
+ providers: app.providers.map((p) => ({ id: p.id, name: p.name })),
39
+ })
40
+ }
41
+
42
+ if (route === 'signout' && method === 'POST') {
43
+ const me = await app.current(request)
44
+ const cookie = await sessions.destroy(request)
45
+ if (me) await app.emit('signOut', { user: publicUser(me.user), request })
46
+ return json({ ok: true }, 200, { 'Set-Cookie': cookie })
47
+ }
48
+
49
+ if (route === 'jwks.json' && method === 'GET' && tokens) {
50
+ return new Response(JSON.stringify(await tokens.jwks()), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' } })
51
+ }
52
+
53
+ const mine = new Set(['sessions', 'sessions/revoke', 'sessions/revoke-others', 'user', 'user/delete', 'accounts', 'accounts/unlink', 'token'])
54
+ if (!mine.has(route)) return
55
+
56
+ const me = await app.signedIn(request)
57
+ if (me instanceof Response) return me
58
+ const { user, session } = me
59
+
60
+ if (route === 'token' && method === 'GET' && tokens) {
61
+ return json(await tokens.create(publicUser(user)))
62
+ }
63
+
64
+ if (route === 'sessions' && method === 'GET') {
65
+ const list = await sessions.list(user.id)
66
+ return json({ sessions: list.map((s) => ({ ...s, current: s.id === session?.id })) })
67
+ }
68
+
69
+ if (route === 'sessions/revoke' && method === 'POST') {
70
+ const { id } = await body()
71
+ if (!(await sessions.revoke(user.id, id))) return json({ error: 'No such session.', code: 'not-found' }, 404)
72
+ return json({ ok: true })
73
+ }
74
+
75
+ if (route === 'sessions/revoke-others' && method === 'POST') {
76
+ await sessions.revokeAll(user.id, session?.id)
77
+ return json({ ok: true })
78
+ }
79
+
80
+ if (route === 'user' && method === 'POST') {
81
+ const input = await body()
82
+ const patch = {}
83
+ if (input.name !== undefined) {
84
+ if (input.name !== null && typeof input.name !== 'string') return json({ error: 'A name is text.', code: 'bad-request' }, 400)
85
+ patch.name = input.name?.trim().slice(0, 100) || null
86
+ }
87
+ if (input.image !== undefined) {
88
+ if (input.image !== null && !(typeof input.image === 'string' && /^https:\/\/\S+$/.test(input.image) && input.image.length <= 2000)) {
89
+ return json({ error: 'A picture is an https:// link.', code: 'bad-request' }, 400)
90
+ }
91
+ patch.image = input.image
92
+ }
93
+ const updated = await users.update(user.id, patch)
94
+ await app.emit('userUpdated', { user: publicUser(updated) })
95
+ return json({ user: publicUser(updated) })
96
+ }
97
+
98
+ if (route === 'user/delete' && method === 'POST') {
99
+ if (app.config.allowDeletion === false) return json({ error: "Accounts can't be deleted here.", code: 'not-found' }, 404)
100
+ if (!app.fresh(session)) return app.reauth()
101
+ await app.deleteUser(user.id)
102
+ return json({ ok: true }, 200, { 'Set-Cookie': sessions.clear(request) })
103
+ }
104
+
105
+ if (route === 'accounts' && method === 'GET') {
106
+ const out = []
107
+ for (const key of (await store.get(`accounts:${user.id}`)) ?? []) {
108
+ const doc = await store.get(`account:${key}`)
109
+ if (doc) out.push({ provider: doc.provider, name: app.providers.find((p) => p.id === doc.provider)?.name ?? doc.provider, email: doc.email ?? null, username: doc.name ?? null, createdAt: doc.createdAt })
110
+ }
111
+ return json({
112
+ accounts: out,
113
+ password: app.passwords ? await app.passwords.has(user.id) : false,
114
+ twoFactor: app.twoFactor ? await app.twoFactor.enabled(user.id) : false,
115
+ })
116
+ }
117
+
118
+ if (route === 'accounts/unlink' && method === 'POST') {
119
+ const { provider } = await body()
120
+ const keys = (await store.get(`accounts:${user.id}`)) ?? []
121
+ const key = keys.find((k) => k.split(':')[0] === provider)
122
+ if (!key) return json({ error: "That account isn't linked.", code: 'not-found' }, 404)
123
+ await store.delete(`account:${key}`)
124
+ const rest = keys.filter((k) => k !== key)
125
+ if (rest.length) await store.put(`accounts:${user.id}`, rest)
126
+ else await store.delete(`accounts:${user.id}`)
127
+ return json({ ok: true })
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,147 @@
1
+ import { CODE_MINUTES } from '../email-code.js'
2
+ import { normalizeEmail, publicUser, validEmail } from '../users.js'
3
+ import { base64Url, escapeHtml, json, randomToken, safeRedirect, sha256 } from '../util.js'
4
+
5
+ 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.' }
6
+
7
+ /**
8
+ * Email codes (and, with magicLinks on, a link in the same email), and changing the
9
+ * address on an account.
10
+ *
11
+ * link:<sha256 of token> { email, redirect } (lives as long as the code)
12
+ * signup:<email> { hash } (a password to set once the code is used)
13
+ *
14
+ * @param {object} app the shared context from core.js
15
+ */
16
+ export function emailRoutes(app) {
17
+ const { store, users, codes, limits } = app
18
+ const notAllowed = () => json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
19
+ const badEmail = () => json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
20
+ const slowDown = (retryAfter) => json({ error: `Please wait ${retryAfter}s before trying again.`, code: 'slow-down', retryAfter }, 429)
21
+ const failed = (result) => json({ error: MESSAGES[result.code], code: result.code, triesLeft: result.triesLeft }, result.code === 'bad-code' ? 400 : 410)
22
+
23
+ /** Sends a sign-in code (and link) to an address that's allowed in. Shared with password sign-up. */
24
+ async function sendSignInCode(email, url, redirect) {
25
+ let link
26
+ let data = {}
27
+ if (app.magicLinks) {
28
+ const token = randomToken()
29
+ const linkId = base64Url(await sha256(token))
30
+ link = `${app.origin(url)}${app.prefix}/email/link?token=${token}`
31
+ data = { linkId }
32
+ const sent = await codes.start(email, { link, data })
33
+ if (sent.ok) await store.put(`link:${linkId}`, { email, redirect: safeRedirect(redirect, null) }, { ttl: CODE_MINUTES * 60 })
34
+ return sent
35
+ }
36
+ return codes.start(email, { data })
37
+ }
38
+ app.sendSignInCode = sendSignInCode
39
+
40
+ return async function route({ route, method, request, url, body }) {
41
+ if (route === 'email/start' && method === 'POST') {
42
+ const input = await body()
43
+ const email = normalizeEmail(input.email)
44
+ if (!validEmail(email)) return badEmail()
45
+ const wait = await limits.check('email/start', request)
46
+ if (wait) return slowDown(wait)
47
+ if (!(await users.allowed(email, { method: 'email' }))) return notAllowed()
48
+ const sent = await sendSignInCode(email, url, input.redirect)
49
+ if (!sent.ok) return slowDown(sent.retryAfter)
50
+ return json({ ok: true })
51
+ }
52
+
53
+ if (route === 'email/verify' && method === 'POST') {
54
+ const input = await body()
55
+ const email = normalizeEmail(input.email)
56
+ if (!validEmail(email)) return badEmail()
57
+ const result = await codes.verify(email, input.code)
58
+ if (!result.ok) return failed(result)
59
+ if (result.entry.linkId) await store.delete(`link:${result.entry.linkId}`)
60
+ const found = await users.signIn(email, { method: 'email' })
61
+ if (!found) return notAllowed()
62
+ await finishSignup(found)
63
+ return app.reply(await app.complete(found, request, { method: 'email' }), {}, request)
64
+ }
65
+
66
+ // The link in the email opens a page with a button, rather than signing in on GET:
67
+ // mail scanners open links to check them, and would use the link up.
68
+ if (route === 'email/link' && method === 'GET') {
69
+ return new Response(linkPage(app.appName, `${app.prefix}/email/link`), {
70
+ headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer', 'Content-Security-Policy': "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'" },
71
+ })
72
+ }
73
+
74
+ if (route === 'email/link' && method === 'POST') {
75
+ const { token } = await body()
76
+ const linkId = typeof token === 'string' && token.length <= 128 ? base64Url(await sha256(token)) : null
77
+ const link = linkId ? await store.get(`link:${linkId}`) : null
78
+ if (!link) return json({ error: 'That link has expired or was already used. Ask for a new one.', code: 'expired' }, 410)
79
+ await store.delete(`link:${linkId}`)
80
+ const code = await store.get(`code:${link.email}`)
81
+ if (code?.linkId === linkId) await store.delete(`code:${link.email}`)
82
+ const found = await users.signIn(link.email, { method: 'link' })
83
+ if (!found) return notAllowed()
84
+ await finishSignup(found)
85
+ const result = await app.complete(found, request, { method: 'link' })
86
+ const to = result.kind === 'two-factor' ? (app.signInPage ?? link.redirect ?? '/') : (link.redirect ?? '/')
87
+ return app.reply(result, { redirect: to }, request)
88
+ }
89
+
90
+ if (route === 'email/change/start' && method === 'POST') {
91
+ const me = await app.signedIn(request)
92
+ if (me instanceof Response) return me
93
+ if (!app.fresh(me.session)) return app.reauth()
94
+ const email = normalizeEmail((await body()).email)
95
+ if (!validEmail(email)) return badEmail()
96
+ if (email === me.user.email) return json({ error: "That's already your email.", code: 'same-email' }, 400)
97
+ if (!(await users.allowed(email, { method: 'email-change', user: publicUser(me.user) }))) return notAllowed()
98
+ if (await users.byEmail(email)) return json({ error: 'Another account uses that email.', code: 'email-taken' }, 409)
99
+ const sent = await codes.start(email, { key: `code-change:${me.user.id}`, kind: 'email-change' })
100
+ if (!sent.ok) return slowDown(sent.retryAfter)
101
+ return json({ ok: true })
102
+ }
103
+
104
+ if (route === 'email/change/verify' && method === 'POST') {
105
+ const me = await app.signedIn(request)
106
+ if (me instanceof Response) return me
107
+ const input = await body()
108
+ const email = normalizeEmail(input.email)
109
+ if (!validEmail(email)) return badEmail()
110
+ const result = await codes.verify(email, input.code, { key: `code-change:${me.user.id}` })
111
+ if (!result.ok) return failed(result)
112
+ if (await users.byEmail(email)) return json({ error: 'Another account uses that email.', code: 'email-taken' }, 409)
113
+ const updated = await users.update(me.user.id, { email })
114
+ await app.emit('userUpdated', { user: publicUser(updated) })
115
+ return json({ user: publicUser(updated) })
116
+ }
117
+ }
118
+
119
+ /** Someone who signed up with a password has now proved their email: save the password. */
120
+ async function finishSignup(found) {
121
+ const pending = await store.get(`signup:${found.email}`)
122
+ if (!pending) return
123
+ await store.delete(`signup:${found.email}`)
124
+ await store.put(`password:${found.id}`, { hash: pending.hash, updatedAt: Date.now() })
125
+ }
126
+ }
127
+
128
+ function linkPage(appName, endpoint) {
129
+ const name = escapeHtml(appName)
130
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign in to ${name}</title>
131
+ <style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#fdf6ec;color:#2b2118;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
132
+ main{max-width:360px;margin:16px;background:#fff;border:2px solid #2b2118;border-radius:16px;padding:28px;box-shadow:4px 4px 0 #2b2118;text-align:center}
133
+ button{font:inherit;font-weight:700;font-size:16px;background:#f59e0b;color:#2b2118;border:2px solid #2b2118;border-radius:10px;padding:12px 18px;width:100%;cursor:pointer}
134
+ p{opacity:.8}.error{color:#c2410c;font-weight:600;opacity:1}</style></head>
135
+ <body><main><h1 style="font-size:22px;margin:0 0 8px">Sign in to ${name}</h1><p>You're one tap away.</p>
136
+ <button id="go">Continue</button><p id="msg" class="error" role="alert"></p></main>
137
+ <script>
138
+ const token = new URLSearchParams(location.search).get('token')
139
+ document.getElementById('go').addEventListener('click', async (e) => {
140
+ e.target.disabled = true
141
+ const res = await fetch(${JSON.stringify(endpoint)}, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) }).catch(() => null)
142
+ const data = res ? await res.json().catch(() => ({})) : { error: "Couldn't reach the server." }
143
+ if (res && res.ok) location.replace(data.redirect || '/')
144
+ else { document.getElementById('msg').textContent = data.error || 'Something went wrong.'; e.target.disabled = false }
145
+ })
146
+ </script></body></html>`
147
+ }