slauthjs 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +55 -2
- package/package.json +90 -4
- package/src/access.js +143 -0
- package/src/client.d.ts +34 -0
- package/src/client.js +118 -0
- package/src/core.js +311 -0
- package/src/element.d.ts +6 -0
- package/src/element.js +151 -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 +13 -0
- package/src/email/index.d.ts +4 -0
- package/src/email/message.js +16 -0
- package/src/email/resend.d.ts +1 -0
- package/src/email/resend.js +21 -0
- package/src/email-code.js +61 -0
- package/src/index.d.ts +149 -0
- package/src/index.js +6 -0
- package/src/lock.js +225 -0
- package/src/node.d.ts +3 -0
- package/src/node.js +67 -0
- package/src/passkey/cbor.js +99 -0
- package/src/passkey/webauthn.js +183 -0
- package/src/session.js +49 -0
- package/src/stores/d1.d.ts +1 -0
- package/src/stores/d1.js +44 -0
- package/src/stores/index.d.ts +5 -0
- package/src/stores/kv.d.ts +1 -0
- package/src/stores/kv.js +18 -0
- package/src/stores/memory.d.ts +1 -0
- package/src/stores/memory.js +30 -0
- package/src/stores/sqlite.d.ts +1 -0
- package/src/stores/sqlite.js +35 -0
- package/src/users.js +56 -0
- package/src/util.js +69 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { createAccess } from './access.js'
|
|
2
|
+
import { createEmailCodes } from './email-code.js'
|
|
3
|
+
import { consoleEmail } from './email/console.js'
|
|
4
|
+
import { createLock } from './lock.js'
|
|
5
|
+
import { PasskeyError, readChallenge, verifyAuthentication, verifyRegistration } from './passkey/webauthn.js'
|
|
6
|
+
import { createSessions } from './session.js'
|
|
7
|
+
import { createUsers, normalizeEmail, validEmail } from './users.js'
|
|
8
|
+
import { base64Url, json } from './util.js'
|
|
9
|
+
|
|
10
|
+
const CHALLENGE_SECONDS = 5 * 60
|
|
11
|
+
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
|
|
12
|
+
let warned = false
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* slauth: sign-in for side projects.
|
|
16
|
+
*
|
|
17
|
+
* const auth = slauth({ secret, store })
|
|
18
|
+
* if (url.pathname.startsWith('/auth/')) return auth.handle(request)
|
|
19
|
+
* const user = await auth.user(request)
|
|
20
|
+
*
|
|
21
|
+
* @param {import('./index.js').SlauthConfig} config
|
|
22
|
+
*/
|
|
23
|
+
export function slauth(config) {
|
|
24
|
+
const {
|
|
25
|
+
secret,
|
|
26
|
+
store,
|
|
27
|
+
appName = 'your app',
|
|
28
|
+
basePath = '/auth',
|
|
29
|
+
cookie = 'slauth_session',
|
|
30
|
+
sessionDays = 30,
|
|
31
|
+
allow,
|
|
32
|
+
passkeys = {},
|
|
33
|
+
origins,
|
|
34
|
+
} = config ?? {}
|
|
35
|
+
|
|
36
|
+
if (!store || typeof store.get !== 'function') {
|
|
37
|
+
throw new Error("slauth: pass a store, e.g. store: memory() from 'slauthjs/stores/memory'.")
|
|
38
|
+
}
|
|
39
|
+
if (typeof secret !== 'string' || secret.length < 16) {
|
|
40
|
+
throw new Error('slauth: `secret` must be a random string of at least 16 characters. Make one with: openssl rand -base64 32')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sender = config.email ?? consoleEmail()
|
|
44
|
+
if (!config.email && !warned) {
|
|
45
|
+
warned = true
|
|
46
|
+
console.warn('slauth: no `email` sender set, so sign-in codes are printed to the console.')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const users = createUsers(store, allow)
|
|
50
|
+
const sessions = createSessions({ store, cookie, days: sessionDays })
|
|
51
|
+
const codes = createEmailCodes({ store, secret, sender, appName })
|
|
52
|
+
const access = config.access ? createAccess({ teamDomain: config.access.team, audience: config.access.aud, devUser: config.access.devUser, fetch: config.access.fetch }) : null
|
|
53
|
+
const lock = config.lock
|
|
54
|
+
? createLock(
|
|
55
|
+
{ get: (userId) => store.get(`lock:${userId}`), put: (userId, doc) => store.put(`lock:${userId}`, doc) },
|
|
56
|
+
{ cookie: config.lock === true ? 'slauth_lock' : (config.lock.cookie ?? 'slauth_lock'), defaultPolicy: config.lock === true ? 'never' : (config.lock.default ?? 'never') },
|
|
57
|
+
)
|
|
58
|
+
: null
|
|
59
|
+
|
|
60
|
+
const prefix = basePath.replace(/\/+$/, '')
|
|
61
|
+
|
|
62
|
+
/** The signed-in person, or null. Doesn't look at the app lock; see requireUser. */
|
|
63
|
+
async function user(request) {
|
|
64
|
+
const session = await sessions.read(request)
|
|
65
|
+
if (session) {
|
|
66
|
+
const found = await users.byId(session.userId)
|
|
67
|
+
// Taking someone off the allow list signs them out everywhere.
|
|
68
|
+
if (found && (await users.allowed(found.email))) return publicUser(found)
|
|
69
|
+
}
|
|
70
|
+
if (access) {
|
|
71
|
+
const fromAccess = await access.getUser(request)
|
|
72
|
+
if (fromAccess) {
|
|
73
|
+
const found = await users.signIn(fromAccess.email)
|
|
74
|
+
if (found) return publicUser(found)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The signed-in, unlocked person, or a 401 Response to send back as it is:
|
|
82
|
+
*
|
|
83
|
+
* const user = await auth.requireUser(request)
|
|
84
|
+
* if (user instanceof Response) return user
|
|
85
|
+
*/
|
|
86
|
+
async function requireUser(request) {
|
|
87
|
+
const found = await user(request)
|
|
88
|
+
if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
|
|
89
|
+
if (lock && !(await lock.isUnlocked(lockUser(found), request.headers.get('cookie')))) {
|
|
90
|
+
return json({ error: 'Please enter your PIN.', code: 'locked' }, 401)
|
|
91
|
+
}
|
|
92
|
+
return found
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Every route under basePath. Returns a 404 for anything else, so it's safe to call for any request. */
|
|
96
|
+
async function handle(request) {
|
|
97
|
+
const url = new URL(request.url)
|
|
98
|
+
if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return json({ error: 'Not found.', code: 'not-found' }, 404)
|
|
99
|
+
const route = url.pathname.slice(prefix.length + 1)
|
|
100
|
+
const method = request.method
|
|
101
|
+
|
|
102
|
+
if (UNSAFE.has(method)) {
|
|
103
|
+
const refused = checkSameSite(request, url, origins)
|
|
104
|
+
if (refused) return refused
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
return await routeRequest(route, method, request, url)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error instanceof PasskeyError) return json({ error: error.message, code: 'passkey-failed' }, 400)
|
|
111
|
+
console.error('slauth:', error)
|
|
112
|
+
return json({ error: 'Something went wrong.', code: 'server-error' }, 500)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function routeRequest(route, method, request, url) {
|
|
117
|
+
const body = async () => {
|
|
118
|
+
try {
|
|
119
|
+
return (await request.json()) ?? {}
|
|
120
|
+
} catch {
|
|
121
|
+
return {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (route === 'session' && method === 'GET') {
|
|
126
|
+
const session = await sessions.read(request)
|
|
127
|
+
const found = await user(request)
|
|
128
|
+
const headers = {}
|
|
129
|
+
if (session && found) {
|
|
130
|
+
const refreshed = await sessions.refresh(session, request)
|
|
131
|
+
if (refreshed) headers['Set-Cookie'] = refreshed
|
|
132
|
+
}
|
|
133
|
+
const out = { user: found }
|
|
134
|
+
if (lock && found) out.lock = (await lock.handle('session', 'GET', body, lockUser(found), request.headers.get('cookie'), url.protocol === 'https:')).body
|
|
135
|
+
return json(out, 200, headers)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (route === 'email/start' && method === 'POST') {
|
|
139
|
+
const email = normalizeEmail((await body()).email)
|
|
140
|
+
if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
|
|
141
|
+
if (!(await users.allowed(email))) return json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
|
|
142
|
+
const sent = await codes.start(email)
|
|
143
|
+
if (!sent.ok) return json({ error: `Please wait ${sent.retryAfter}s before asking for another code.`, code: sent.code, retryAfter: sent.retryAfter }, 429)
|
|
144
|
+
return json({ ok: true })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (route === 'email/verify' && method === 'POST') {
|
|
148
|
+
const input = await body()
|
|
149
|
+
const email = normalizeEmail(input.email)
|
|
150
|
+
if (!validEmail(email)) return json({ error: "That doesn't look like an email address.", code: 'bad-email' }, 400)
|
|
151
|
+
const result = await codes.verify(email, input.code)
|
|
152
|
+
if (!result.ok) {
|
|
153
|
+
const messages = { expired: 'That code has expired. Ask for a new one.', 'too-many-tries': 'Too many tries. Ask for a new code.', 'bad-code': 'That code is wrong.' }
|
|
154
|
+
return json({ error: messages[result.code], code: result.code, triesLeft: result.triesLeft }, result.code === 'bad-code' ? 400 : 410)
|
|
155
|
+
}
|
|
156
|
+
const found = await users.signIn(email)
|
|
157
|
+
if (!found) return json({ error: "That email isn't on the list for this app.", code: 'not-allowed' }, 403)
|
|
158
|
+
return json({ user: publicUser(found) }, 200, { 'Set-Cookie': await sessions.create(found.id, request) })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (route === 'signout' && method === 'POST') {
|
|
162
|
+
return json({ ok: true }, 200, { 'Set-Cookie': await sessions.destroy(request) })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (route.startsWith('passkey') || route === 'passkeys') {
|
|
166
|
+
if (passkeys === false) return json({ error: 'Passkeys are turned off.', code: 'not-found' }, 404)
|
|
167
|
+
return passkeyRoute(route, method, request, url, body)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (route.startsWith('lock/') && lock) {
|
|
171
|
+
const found = await user(request)
|
|
172
|
+
if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
|
|
173
|
+
const reply = await lock.handle(route.slice(5), method, body, lockUser(found), request.headers.get('cookie'), url.protocol === 'https:')
|
|
174
|
+
if (reply) return json(reply.body, reply.status, reply.cookie ? { 'Set-Cookie': reply.cookie } : {})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return json({ error: 'Not found.', code: 'not-found' }, 404)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/* ---------- passkeys ---------- */
|
|
181
|
+
|
|
182
|
+
const expect = (url, challenge) => ({
|
|
183
|
+
rpId: passkeys.rpId ?? url.hostname,
|
|
184
|
+
origin: passkeys.origin ?? origins ?? url.origin,
|
|
185
|
+
challenge,
|
|
186
|
+
requireUserVerification: passkeys.userVerification === 'required',
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
async function takeChallenge(clientDataJSON, type) {
|
|
190
|
+
const challenge = readChallenge(clientDataJSON)
|
|
191
|
+
if (!challenge) throw new PasskeyError('The response has no challenge.')
|
|
192
|
+
const stored = await store.get(`challenge:${challenge}`)
|
|
193
|
+
// Single use, whatever happens next.
|
|
194
|
+
await store.delete(`challenge:${challenge}`)
|
|
195
|
+
if (!stored || stored.type !== type) throw new PasskeyError('That passkey request expired. Try again.')
|
|
196
|
+
return { challenge, stored }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function newChallenge(value) {
|
|
200
|
+
const challenge = base64Url(crypto.getRandomValues(new Uint8Array(32)))
|
|
201
|
+
await store.put(`challenge:${challenge}`, value, { ttl: CHALLENGE_SECONDS })
|
|
202
|
+
return challenge
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function passkeyRoute(route, method, request, url, body) {
|
|
206
|
+
if (route === 'passkey/login/options' && method === 'POST') {
|
|
207
|
+
const challenge = await newChallenge({ type: 'login' })
|
|
208
|
+
return json({
|
|
209
|
+
challenge,
|
|
210
|
+
rpId: passkeys.rpId ?? url.hostname,
|
|
211
|
+
timeout: CHALLENGE_SECONDS * 1000,
|
|
212
|
+
userVerification: passkeys.userVerification ?? 'preferred',
|
|
213
|
+
allowCredentials: [],
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (route === 'passkey/login/verify' && method === 'POST') {
|
|
218
|
+
const { credential } = await body()
|
|
219
|
+
await takeChallenge(credential?.response?.clientDataJSON, 'login')
|
|
220
|
+
const stored = typeof credential?.id === 'string' ? await store.get(`passkey:${credential.id}`) : null
|
|
221
|
+
if (!stored) return json({ error: "This passkey isn't registered here.", code: 'passkey-unknown' }, 400)
|
|
222
|
+
if (credential.response.userHandle && credential.response.userHandle !== base64Url(new TextEncoder().encode(stored.userId))) {
|
|
223
|
+
throw new PasskeyError('This passkey belongs to someone else.')
|
|
224
|
+
}
|
|
225
|
+
const { counter } = await verifyAuthentication(credential, stored, expect(url, readChallenge(credential.response.clientDataJSON)))
|
|
226
|
+
await store.put(`passkey:${stored.id}`, { ...stored, counter, lastUsedAt: Date.now() })
|
|
227
|
+
const found = await users.byId(stored.userId)
|
|
228
|
+
if (!found || !(await users.allowed(found.email))) return json({ error: "You're not on the list for this app.", code: 'not-allowed' }, 403)
|
|
229
|
+
return json({ user: publicUser(found) }, 200, { 'Set-Cookie': await sessions.create(found.id, request) })
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Everything below is for someone already signed in.
|
|
233
|
+
const found = await user(request)
|
|
234
|
+
if (!found) return json({ error: 'Please sign in.', code: 'signed-out' }, 401)
|
|
235
|
+
const ids = (await store.get(`passkeys:${found.id}`)) ?? []
|
|
236
|
+
|
|
237
|
+
if (route === 'passkey/register/options' && method === 'POST') {
|
|
238
|
+
const challenge = await newChallenge({ type: 'register', userId: found.id })
|
|
239
|
+
return json({
|
|
240
|
+
challenge,
|
|
241
|
+
rp: { id: passkeys.rpId ?? url.hostname, name: passkeys.rpName ?? appName },
|
|
242
|
+
user: { id: base64Url(new TextEncoder().encode(found.id)), name: found.email, displayName: found.email },
|
|
243
|
+
pubKeyCredParams: [
|
|
244
|
+
{ type: 'public-key', alg: -7 },
|
|
245
|
+
{ type: 'public-key', alg: -257 },
|
|
246
|
+
],
|
|
247
|
+
timeout: CHALLENGE_SECONDS * 1000,
|
|
248
|
+
attestation: 'none',
|
|
249
|
+
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: passkeys.userVerification ?? 'preferred' },
|
|
250
|
+
excludeCredentials: ids.map((id) => ({ type: 'public-key', id })),
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (route === 'passkey/register/verify' && method === 'POST') {
|
|
255
|
+
const { credential, name } = await body()
|
|
256
|
+
const { challenge, stored } = await takeChallenge(credential?.response?.clientDataJSON, 'register')
|
|
257
|
+
if (stored.userId !== found.id) throw new PasskeyError('That passkey request was for someone else.')
|
|
258
|
+
const verified = await verifyRegistration(credential, expect(url, challenge))
|
|
259
|
+
if (await store.get(`passkey:${verified.id}`)) return json({ error: 'That passkey is already registered.', code: 'passkey-exists' }, 409)
|
|
260
|
+
const label = typeof name === 'string' && name.trim() ? name.trim().slice(0, 60) : `Passkey added ${new Date().toISOString().slice(0, 10)}`
|
|
261
|
+
await store.put(`passkey:${verified.id}`, { ...verified, userId: found.id, name: label, createdAt: Date.now(), lastUsedAt: null })
|
|
262
|
+
await store.put(`passkeys:${found.id}`, [...ids, verified.id])
|
|
263
|
+
return json({ ok: true, passkey: { id: verified.id, name: label } })
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (route === 'passkeys' && method === 'GET') {
|
|
267
|
+
const list = []
|
|
268
|
+
for (const id of ids) {
|
|
269
|
+
const pk = await store.get(`passkey:${id}`)
|
|
270
|
+
if (pk) list.push({ id: pk.id, name: pk.name, createdAt: pk.createdAt, lastUsedAt: pk.lastUsedAt })
|
|
271
|
+
}
|
|
272
|
+
return json({ passkeys: list })
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (route === 'passkey/delete' && method === 'POST') {
|
|
276
|
+
const { id } = await body()
|
|
277
|
+
if (!ids.includes(id)) return json({ error: 'No such passkey.', code: 'passkey-unknown' }, 404)
|
|
278
|
+
await store.delete(`passkey:${id}`)
|
|
279
|
+
await store.put(`passkeys:${found.id}`, ids.filter((x) => x !== id))
|
|
280
|
+
return json({ ok: true })
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return json({ error: 'Not found.', code: 'not-found' }, 404)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { handle, user, requireUser, store }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function publicUser(user) {
|
|
290
|
+
return { id: user.id, email: user.email, createdAt: user.createdAt }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** createLock works with { email, userId }. */
|
|
294
|
+
function lockUser(user) {
|
|
295
|
+
return { email: user.email, userId: user.id }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Stops other websites using a visitor's cookies to post here (CSRF): writes must be JSON
|
|
300
|
+
* (which a plain HTML form can't send) and, when the browser says where they came from,
|
|
301
|
+
* come from this site.
|
|
302
|
+
*/
|
|
303
|
+
function checkSameSite(request, url, origins) {
|
|
304
|
+
const type = request.headers.get('content-type') ?? ''
|
|
305
|
+
if (!type.startsWith('application/json')) return json({ error: 'Expected JSON.', code: 'bad-request' }, 415)
|
|
306
|
+
const origin = request.headers.get('origin')
|
|
307
|
+
const allowed = [origins ?? url.origin].flat()
|
|
308
|
+
if (origin && !allowed.includes(origin)) return json({ error: 'Cross-site request refused.', code: 'cross-site' }, 403)
|
|
309
|
+
if (!origin && request.headers.get('sec-fetch-site') === 'cross-site') return json({ error: 'Cross-site request refused.', code: 'cross-site' }, 403)
|
|
310
|
+
return null
|
|
311
|
+
}
|
package/src/element.d.ts
ADDED
package/src/element.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createClient } from './client.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <slauth-sign-in>: a drop-in sign-in box. Email → 6-digit code → signed in, with
|
|
5
|
+
* "Sign in with a passkey" and an offer to add one afterwards. Works in any framework
|
|
6
|
+
* (it's a plain custom element) and is themed with CSS variables:
|
|
7
|
+
*
|
|
8
|
+
* slauth-sign-in {
|
|
9
|
+
* --slauth-accent: #f59e0b; --slauth-ink: #2b2118; --slauth-bg: #fff;
|
|
10
|
+
* --slauth-radius: 14px; --slauth-font: inherit;
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Attributes: base-path (default /auth), app-name, redirect (where to go once signed in),
|
|
14
|
+
* no-passkeys (email codes only).
|
|
15
|
+
* Fires `slauth:signed-in` with `{ detail: { user } }`.
|
|
16
|
+
*
|
|
17
|
+
* import 'slauthjs/element'
|
|
18
|
+
* <slauth-sign-in app-name="Planda" redirect="/"></slauth-sign-in>
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const STYLE = `
|
|
22
|
+
:host { display: block; font-family: var(--slauth-font, inherit); color: var(--slauth-ink, #2b2118); max-width: 360px; }
|
|
23
|
+
.card { background: var(--slauth-bg, #fff); border: 2px solid var(--slauth-ink, #2b2118); border-radius: var(--slauth-radius, 14px); padding: 22px; box-shadow: 4px 4px 0 var(--slauth-ink, #2b2118); }
|
|
24
|
+
h2 { margin: 0 0 4px; font-size: 20px; line-height: 1.2; }
|
|
25
|
+
p { margin: 0 0 14px; font-size: 14px; opacity: .8; }
|
|
26
|
+
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
|
|
27
|
+
input { box-sizing: border-box; width: 100%; font: inherit; font-size: 16px; padding: 10px 12px; border: 2px solid var(--slauth-ink, #2b2118); border-radius: calc(var(--slauth-radius, 14px) - 6px); background: transparent; color: inherit; }
|
|
28
|
+
input.code { letter-spacing: .4em; text-align: center; font-size: 22px; font-weight: 700; }
|
|
29
|
+
input:focus { outline: 3px solid var(--slauth-accent, #f59e0b); outline-offset: 1px; }
|
|
30
|
+
button { font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; border-radius: calc(var(--slauth-radius, 14px) - 6px); padding: 10px 14px; width: 100%; margin-top: 12px; }
|
|
31
|
+
.primary { background: var(--slauth-accent, #f59e0b); color: var(--slauth-ink, #2b2118); border: 2px solid var(--slauth-ink, #2b2118); }
|
|
32
|
+
.primary:active { transform: translate(1px, 1px); }
|
|
33
|
+
.ghost { background: transparent; color: inherit; border: 2px dashed currentColor; opacity: .85; }
|
|
34
|
+
.link { background: none; border: 0; padding: 4px; width: auto; font-weight: 600; text-decoration: underline; opacity: .75; }
|
|
35
|
+
button:disabled { opacity: .5; cursor: default; }
|
|
36
|
+
.error { color: #c2410c; font-size: 13px; font-weight: 600; min-height: 18px; margin: 8px 0 0; }
|
|
37
|
+
.row { display: flex; justify-content: space-between; margin-top: 8px; }
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
class SlauthSignIn extends HTMLElement {
|
|
41
|
+
connectedCallback() {
|
|
42
|
+
this.auth = createClient({ basePath: this.getAttribute('base-path') ?? '/auth' })
|
|
43
|
+
this.appName = this.getAttribute('app-name') ?? ''
|
|
44
|
+
this.state = { step: 'email', email: '', error: '', busy: false }
|
|
45
|
+
this.attachShadow({ mode: 'open' })
|
|
46
|
+
this.render()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set(patch) {
|
|
50
|
+
this.state = { ...this.state, ...patch }
|
|
51
|
+
this.render()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async run(action) {
|
|
55
|
+
this.set({ busy: true, error: '' })
|
|
56
|
+
try {
|
|
57
|
+
await action()
|
|
58
|
+
} catch (error) {
|
|
59
|
+
this.set({ error: error.message ?? 'Something went wrong.' })
|
|
60
|
+
} finally {
|
|
61
|
+
if (this.isConnected) this.set({ busy: false })
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
done(user) {
|
|
66
|
+
// Back to the start, ready for the next time it's shown (e.g. after signing out).
|
|
67
|
+
this.state = { step: 'email', email: '', error: '', busy: false }
|
|
68
|
+
this.render()
|
|
69
|
+
this.dispatchEvent(new CustomEvent('slauth:signed-in', { detail: { user }, bubbles: true, composed: true }))
|
|
70
|
+
const to = this.getAttribute('redirect')
|
|
71
|
+
if (to) window.location.assign(to)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
render() {
|
|
75
|
+
const { step, email, error, busy } = this.state
|
|
76
|
+
const passkeys = !this.hasAttribute('no-passkeys') && this.auth.passkey.supported()
|
|
77
|
+
const name = this.appName ? ` to ${escape(this.appName)}` : ''
|
|
78
|
+
const views = {
|
|
79
|
+
email: `
|
|
80
|
+
<h2>Sign in${name}</h2>
|
|
81
|
+
<p>We'll email you a 6-digit code. No password needed.</p>
|
|
82
|
+
<form data-on="email">
|
|
83
|
+
<label for="email">Email</label>
|
|
84
|
+
<input id="email" type="email" autocomplete="email webauthn" required value="${escape(email)}" />
|
|
85
|
+
<button class="primary" ${busy ? 'disabled' : ''}>Email me a code</button>
|
|
86
|
+
</form>
|
|
87
|
+
${passkeys ? `<button class="ghost" data-on="passkey" ${busy ? 'disabled' : ''}>Sign in with a passkey</button>` : ''}`,
|
|
88
|
+
code: `
|
|
89
|
+
<h2>Check your email</h2>
|
|
90
|
+
<p>We sent a code to <strong>${escape(email)}</strong>.</p>
|
|
91
|
+
<form data-on="code">
|
|
92
|
+
<label for="code">Code</label>
|
|
93
|
+
<input id="code" class="code" inputmode="numeric" autocomplete="one-time-code" maxlength="6" required />
|
|
94
|
+
<button class="primary" ${busy ? 'disabled' : ''}>Sign in</button>
|
|
95
|
+
</form>
|
|
96
|
+
<div class="row"><button class="link" data-on="back">Use another email</button><button class="link" data-on="resend">Send again</button></div>`,
|
|
97
|
+
offer: `
|
|
98
|
+
<h2>You're in 🦥</h2>
|
|
99
|
+
<p>Add a passkey and next time it's just Face ID, Touch ID or your PIN.</p>
|
|
100
|
+
<button class="primary" data-on="add" ${busy ? 'disabled' : ''}>Add a passkey</button>
|
|
101
|
+
<button class="link" data-on="skip">Not now</button>`,
|
|
102
|
+
}
|
|
103
|
+
this.shadowRoot.innerHTML = `<style>${STYLE}</style><div class="card" part="card">${views[step]}<p class="error" role="alert">${escape(error)}</p></div>`
|
|
104
|
+
|
|
105
|
+
const on = (key, handler) => {
|
|
106
|
+
const el = this.shadowRoot.querySelector(`[data-on="${key}"]`)
|
|
107
|
+
el?.addEventListener(el.tagName === 'FORM' ? 'submit' : 'click', (event) => {
|
|
108
|
+
event.preventDefault()
|
|
109
|
+
handler()
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
// Read inputs before run(): it re-renders the form, which would empty them.
|
|
113
|
+
on('email', () => {
|
|
114
|
+
const value = this.shadowRoot.getElementById('email').value.trim()
|
|
115
|
+
this.set({ email: value })
|
|
116
|
+
return this.run(async () => {
|
|
117
|
+
await this.auth.email.start(value)
|
|
118
|
+
this.set({ step: 'code' })
|
|
119
|
+
this.shadowRoot.getElementById('code')?.focus()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
on('code', () => {
|
|
123
|
+
const code = this.shadowRoot.getElementById('code').value
|
|
124
|
+
return this.run(async () => {
|
|
125
|
+
const { user } = await this.auth.email.verify(email, code)
|
|
126
|
+
this.user = user
|
|
127
|
+
if (passkeys) this.set({ step: 'offer' })
|
|
128
|
+
else this.done(user)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
on('passkey', () => this.run(async () => this.done((await this.auth.passkey.signIn()).user)))
|
|
132
|
+
on('back', () => this.set({ step: 'email', error: '' }))
|
|
133
|
+
on('resend', () => this.run(() => this.auth.email.start(email)))
|
|
134
|
+
on('add', () =>
|
|
135
|
+
this.run(async () => {
|
|
136
|
+
await this.auth.passkey.register()
|
|
137
|
+
this.done(this.user)
|
|
138
|
+
}),
|
|
139
|
+
)
|
|
140
|
+
on('skip', () => this.done(this.user))
|
|
141
|
+
this.shadowRoot.querySelector('input')?.focus({ preventScroll: true })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function escape(text) {
|
|
146
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof customElements !== 'undefined' && !customElements.get('slauth-sign-in')) {
|
|
150
|
+
customElements.define('slauth-sign-in', SlauthSignIn)
|
|
151
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cloudflareEmail } from './index.js'
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends codes with Cloudflare Email Service, through a `send_email` binding in your
|
|
3
|
+
* wrangler config:
|
|
4
|
+
*
|
|
5
|
+
* "send_email": [{ "name": "EMAIL" }]
|
|
6
|
+
*
|
|
7
|
+
* @param {{ send(message: { to: string, from: string, subject: string, text: string, html: string }): Promise<unknown> }} binding e.g. env.EMAIL
|
|
8
|
+
* @param {{ from: string }} options a sender on a domain set up for Email Service
|
|
9
|
+
* @returns {import('../index.js').EmailSender}
|
|
10
|
+
*/
|
|
11
|
+
export function cloudflareEmail(binding, { from }) {
|
|
12
|
+
if (!binding?.send) throw new Error('slauth: cloudflareEmail() needs a send_email binding, e.g. env.EMAIL.')
|
|
13
|
+
if (!from) throw new Error('slauth: cloudflareEmail() needs a from address.')
|
|
14
|
+
return {
|
|
15
|
+
async send({ to, subject, text, html }) {
|
|
16
|
+
await binding.send({ to, from, subject, text, html })
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { consoleEmail } from './index.js'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prints sign-in codes to the console instead of emailing them. The default while
|
|
3
|
+
* you build, so there's nothing to set up to try slauth.
|
|
4
|
+
*
|
|
5
|
+
* @returns {import('../index.js').EmailSender}
|
|
6
|
+
*/
|
|
7
|
+
export function consoleEmail() {
|
|
8
|
+
return {
|
|
9
|
+
async send({ to, code, appName }) {
|
|
10
|
+
console.log(`\n 🦥 slauth: ${appName} sign-in code for ${to} is ${code}\n`)
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { EmailSender } from '../index.js'
|
|
2
|
+
export function consoleEmail(): EmailSender
|
|
3
|
+
export function resend(options: { apiKey: string; from: string; fetch?: typeof fetch }): EmailSender
|
|
4
|
+
export function cloudflareEmail(binding: { send(message: { to: string; from: string; subject: string; text: string; html: string }): Promise<unknown> }, options: { from: string }): EmailSender
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The sign-in email every sender uses, unless you pass your own `message`. */
|
|
2
|
+
export function codeMessage({ code, appName, minutes }) {
|
|
3
|
+
const subject = `${code} is your ${appName} sign-in code`
|
|
4
|
+
const text = `Your ${appName} sign-in code is ${code}\n\nIt works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.`
|
|
5
|
+
const html = `<!doctype html><html><body style="margin:0;padding:32px 16px;background:#fdf6ec;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#2b2118">
|
|
6
|
+
<div style="max-width:420px;margin:0 auto;background:#fff;border:2px solid #2b2118;border-radius:16px;padding:28px">
|
|
7
|
+
<p style="margin:0 0 8px;font-size:15px">Your <strong>${escapeHtml(appName)}</strong> sign-in code:</p>
|
|
8
|
+
<p style="margin:0 0 16px;font-size:34px;font-weight:800;letter-spacing:6px">${code}</p>
|
|
9
|
+
<p style="margin:0;font-size:13px;color:#6b5d50">It works for ${minutes} minutes. If you didn't ask for it, you can ignore this email.</p>
|
|
10
|
+
</div></body></html>`
|
|
11
|
+
return { subject, text, html }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function escapeHtml(text) {
|
|
15
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { resend } from './index.js'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends codes with Resend (resend.com; the free tier is plenty for a side project).
|
|
3
|
+
*
|
|
4
|
+
* @param {{ apiKey: string, from: string, fetch?: typeof fetch }} options
|
|
5
|
+
* from a sender on a domain you verified in Resend, e.g. "My App <hi@myapp.dev>"
|
|
6
|
+
* @returns {import('../index.js').EmailSender}
|
|
7
|
+
*/
|
|
8
|
+
export function resend({ apiKey, from, fetch: doFetch = (...args) => fetch(...args) }) {
|
|
9
|
+
if (!apiKey) throw new Error('slauth: resend() needs an apiKey.')
|
|
10
|
+
if (!from) throw new Error('slauth: resend() needs a from address on a domain you verified in Resend.')
|
|
11
|
+
return {
|
|
12
|
+
async send({ to, subject, text, html }) {
|
|
13
|
+
const res = await doFetch('https://api.resend.com/emails', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ from, to: [to], subject, text, html }),
|
|
17
|
+
})
|
|
18
|
+
if (!res.ok) throw new Error(`slauth: Resend said ${res.status}: ${(await res.text()).slice(0, 200)}`)
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { codeMessage } from './email/message.js'
|
|
2
|
+
import { hmac, safeEqual } from './util.js'
|
|
3
|
+
|
|
4
|
+
const CODE_MINUTES = 10
|
|
5
|
+
const MAX_TRIES = 5
|
|
6
|
+
const RESEND_SECONDS = 60
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sign in with a 6-digit code sent by email.
|
|
10
|
+
*
|
|
11
|
+
* code:<email> { hash, expiresAt, tries, sentAt } (lives 10 minutes)
|
|
12
|
+
*
|
|
13
|
+
* Only an HMAC of the code is stored. Each code allows 5 tries, and a new code can be
|
|
14
|
+
* asked for once a minute per address.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ store: import('./index.js').Store, secret: string, sender: import('./index.js').EmailSender, appName: string }} options
|
|
17
|
+
*/
|
|
18
|
+
export function createEmailCodes({ store, secret, sender, appName }) {
|
|
19
|
+
const hashOf = (email, code) => hmac(secret, `code:${email}:${code}`)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
/** @returns {Promise<{ ok: true } | { ok: false, code: 'slow-down', retryAfter: number }>} */
|
|
23
|
+
async start(email) {
|
|
24
|
+
const existing = await store.get(`code:${email}`)
|
|
25
|
+
const wait = existing ? Math.ceil((existing.sentAt + RESEND_SECONDS * 1000 - Date.now()) / 1000) : 0
|
|
26
|
+
if (wait > 0) return { ok: false, code: 'slow-down', retryAfter: wait }
|
|
27
|
+
|
|
28
|
+
const code = randomCode()
|
|
29
|
+
const now = Date.now()
|
|
30
|
+
await store.put(`code:${email}`, { hash: await hashOf(email, code), expiresAt: now + CODE_MINUTES * 60_000, tries: 0, sentAt: now }, { ttl: CODE_MINUTES * 60 })
|
|
31
|
+
await sender.send({ to: email, code, appName, ...codeMessage({ code, appName, minutes: CODE_MINUTES }) })
|
|
32
|
+
return { ok: true }
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/** @returns {Promise<{ ok: true } | { ok: false, code: 'expired' | 'too-many-tries' | 'bad-code', triesLeft?: number }>} */
|
|
36
|
+
async verify(email, code) {
|
|
37
|
+
const entry = await store.get(`code:${email}`)
|
|
38
|
+
if (!entry || entry.expiresAt <= Date.now()) return { ok: false, code: 'expired' }
|
|
39
|
+
if (entry.tries >= MAX_TRIES) return { ok: false, code: 'too-many-tries' }
|
|
40
|
+
|
|
41
|
+
const guess = String(code ?? '').replace(/\D/g, '')
|
|
42
|
+
if (guess.length === 6 && safeEqual(await hashOf(email, guess), entry.hash)) {
|
|
43
|
+
await store.delete(`code:${email}`)
|
|
44
|
+
return { ok: true }
|
|
45
|
+
}
|
|
46
|
+
const tries = entry.tries + 1
|
|
47
|
+
const ttl = Math.max(1, Math.ceil((entry.expiresAt - Date.now()) / 1000))
|
|
48
|
+
await store.put(`code:${email}`, { ...entry, tries }, { ttl })
|
|
49
|
+
return tries >= MAX_TRIES ? { ok: false, code: 'too-many-tries' } : { ok: false, code: 'bad-code', triesLeft: MAX_TRIES - tries }
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Six uniformly random digits (rejection sampling, so no digit is likelier than another). */
|
|
55
|
+
function randomCode() {
|
|
56
|
+
const buf = new Uint32Array(1)
|
|
57
|
+
for (;;) {
|
|
58
|
+
crypto.getRandomValues(buf)
|
|
59
|
+
if (buf[0] < 4_294_000_000) return String(buf[0] % 1_000_000).padStart(6, '0')
|
|
60
|
+
}
|
|
61
|
+
}
|