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
|
@@ -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
|
+
}
|
package/src/password.js
ADDED
|
@@ -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
|
package/src/providers.js
ADDED
|
@@ -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
|
+
}
|