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
|
@@ -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/session.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { base64Url, randomToken, readCookie, serializeCookie, sha256 } from './util.js'
|
|
2
|
+
|
|
3
|
+
const DAY = 24 * 60 * 60
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Sessions: a random token in an HttpOnly cookie. Only a SHA-256 of the token is
|
|
7
|
+
* stored, so a leaked database can't be used to sign in. Sessions slide: each day
|
|
8
|
+
* someone uses the app, the 30 days start again.
|
|
9
|
+
*
|
|
10
|
+
* @param {{ store: import('./index.js').Store, cookie: string, days: number }} options
|
|
11
|
+
*/
|
|
12
|
+
export function createSessions({ store, cookie, days }) {
|
|
13
|
+
const ttl = days * DAY
|
|
14
|
+
const keyFor = async (token) => `session:${base64Url(await sha256(token))}`
|
|
15
|
+
const secure = (request) => new URL(request.url).protocol === 'https:'
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
/** Starts a session; returns the Set-Cookie value. */
|
|
19
|
+
async create(userId, request) {
|
|
20
|
+
const token = randomToken()
|
|
21
|
+
const now = Date.now()
|
|
22
|
+
await store.put(await keyFor(token), { userId, createdAt: now, refreshedAt: now }, { ttl })
|
|
23
|
+
return serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) })
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
/** The session behind this request, or null. */
|
|
27
|
+
async read(request) {
|
|
28
|
+
const token = readCookie(request.headers.get('cookie'), cookie)
|
|
29
|
+
if (!token || token.length > 128) return null
|
|
30
|
+
const session = await store.get(await keyFor(token))
|
|
31
|
+
return session ? { ...session, token } : null
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
/** Restarts the 30 days at most once a day; returns a Set-Cookie value when it did. */
|
|
35
|
+
async refresh(session, request) {
|
|
36
|
+
if (Date.now() - session.refreshedAt < DAY * 1000) return null
|
|
37
|
+
const { token, ...value } = session
|
|
38
|
+
await store.put(await keyFor(token), { ...value, refreshedAt: Date.now() }, { ttl })
|
|
39
|
+
return serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) })
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** Ends this request's session; returns the Set-Cookie value that clears it. */
|
|
43
|
+
async destroy(request) {
|
|
44
|
+
const token = readCookie(request.headers.get('cookie'), cookie)
|
|
45
|
+
if (token && token.length <= 128) await store.delete(await keyFor(token))
|
|
46
|
+
return serializeCookie(cookie, '', { maxAge: 0, secure: secure(request) })
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { d1 } from './index.js'
|
package/src/stores/d1.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare D1. Creates its own table (`slauth_kv`) on first use, so there are no
|
|
3
|
+
* migrations to run; it sits happily next to your app's own tables.
|
|
4
|
+
*
|
|
5
|
+
* @param {D1Database} db the D1 binding, e.g. env.DB
|
|
6
|
+
* @param {{ table?: string }} [options]
|
|
7
|
+
* @returns {import('../index.js').Store}
|
|
8
|
+
*/
|
|
9
|
+
export function d1(db, { table = 'slauth_kv' } = {}) {
|
|
10
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`slauth: "${table}" isn't a valid table name.`)
|
|
11
|
+
let ready = null
|
|
12
|
+
const init = () =>
|
|
13
|
+
(ready ??= db
|
|
14
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)`)
|
|
15
|
+
.run()
|
|
16
|
+
.catch((err) => {
|
|
17
|
+
ready = null
|
|
18
|
+
throw err
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async get(key) {
|
|
23
|
+
await init()
|
|
24
|
+
const row = await db.prepare(`SELECT value, expires_at FROM ${table} WHERE key = ?`).bind(key).first()
|
|
25
|
+
if (!row) return null
|
|
26
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
27
|
+
await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run()
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
return JSON.parse(row.value)
|
|
31
|
+
},
|
|
32
|
+
async put(key, value, { ttl } = {}) {
|
|
33
|
+
await init()
|
|
34
|
+
await db
|
|
35
|
+
.prepare(`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`)
|
|
36
|
+
.bind(key, JSON.stringify(value), ttl ? Date.now() + ttl * 1000 : null)
|
|
37
|
+
.run()
|
|
38
|
+
},
|
|
39
|
+
async delete(key) {
|
|
40
|
+
await init()
|
|
41
|
+
await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run()
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Store } from '../index.js'
|
|
2
|
+
export function memory(): Store
|
|
3
|
+
export function d1(db: { prepare(sql: string): any }, options?: { table?: string }): Store
|
|
4
|
+
export function kv(namespace: { get(key: string, type: 'json'): Promise<any>; put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>; delete(key: string): Promise<void> }, options?: { prefix?: string }): Store
|
|
5
|
+
export function sqlite(db: { prepare(sql: string): { get(...args: unknown[]): any; run(...args: unknown[]): unknown } }, options?: { table?: string }): Store
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { kv } from './index.js'
|
package/src/stores/kv.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Workers KV. Fine for small projects; note KV is eventually consistent
|
|
3
|
+
* (a sign-in can take a moment to be seen in other regions) and its free tier allows
|
|
4
|
+
* 1,000 writes a day. D1 is the better default on Workers.
|
|
5
|
+
*
|
|
6
|
+
* @param {KVNamespace} namespace the KV binding, e.g. env.AUTH
|
|
7
|
+
* @param {{ prefix?: string }} [options]
|
|
8
|
+
* @returns {import('../index.js').Store}
|
|
9
|
+
*/
|
|
10
|
+
export function kv(namespace, { prefix = 'slauth:' } = {}) {
|
|
11
|
+
return {
|
|
12
|
+
get: (key) => namespace.get(prefix + key, 'json'),
|
|
13
|
+
// KV can't expire anything sooner than 60 seconds.
|
|
14
|
+
put: (key, value, { ttl } = {}) =>
|
|
15
|
+
namespace.put(prefix + key, JSON.stringify(value), ttl ? { expirationTtl: Math.max(60, Math.ceil(ttl)) } : undefined),
|
|
16
|
+
delete: (key) => namespace.delete(prefix + key),
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { memory } from './index.js'
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps everything in memory. Lovely for tests and trying things out; everything is
|
|
3
|
+
* forgotten when the process restarts, so use a real store before you ship.
|
|
4
|
+
*
|
|
5
|
+
* @returns {import('../index.js').Store}
|
|
6
|
+
*/
|
|
7
|
+
export function memory() {
|
|
8
|
+
const rows = new Map()
|
|
9
|
+
const live = (key) => {
|
|
10
|
+
const row = rows.get(key)
|
|
11
|
+
if (!row) return null
|
|
12
|
+
if (row.expiresAt !== null && row.expiresAt <= Date.now()) {
|
|
13
|
+
rows.delete(key)
|
|
14
|
+
return null
|
|
15
|
+
}
|
|
16
|
+
return row
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
async get(key) {
|
|
20
|
+
const row = live(key)
|
|
21
|
+
return row ? structuredClone(row.value) : null
|
|
22
|
+
},
|
|
23
|
+
async put(key, value, { ttl } = {}) {
|
|
24
|
+
rows.set(key, { value: structuredClone(value), expiresAt: ttl ? Date.now() + ttl * 1000 : null })
|
|
25
|
+
},
|
|
26
|
+
async delete(key) {
|
|
27
|
+
rows.delete(key)
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { sqlite } from './index.js'
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Any synchronous SQLite handle with `prepare(sql).get(...)` / `.run(...)`: bun:sqlite,
|
|
3
|
+
* better-sqlite3 and node:sqlite all fit. Creates its own table on first use.
|
|
4
|
+
*
|
|
5
|
+
* @param {{ prepare(sql: string): { get(...args: unknown[]): any, run(...args: unknown[]): unknown } }} db
|
|
6
|
+
* @param {{ table?: string }} [options]
|
|
7
|
+
* @returns {import('../index.js').Store}
|
|
8
|
+
*/
|
|
9
|
+
export function sqlite(db, { table = 'slauth_kv' } = {}) {
|
|
10
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`slauth: "${table}" isn't a valid table name.`)
|
|
11
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)`).run()
|
|
12
|
+
const select = db.prepare(`SELECT value, expires_at FROM ${table} WHERE key = ?`)
|
|
13
|
+
const upsert = db.prepare(
|
|
14
|
+
`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`,
|
|
15
|
+
)
|
|
16
|
+
const remove = db.prepare(`DELETE FROM ${table} WHERE key = ?`)
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
async get(key) {
|
|
20
|
+
const row = select.get(key)
|
|
21
|
+
if (!row) return null
|
|
22
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
23
|
+
remove.run(key)
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
return JSON.parse(row.value)
|
|
27
|
+
},
|
|
28
|
+
async put(key, value, { ttl } = {}) {
|
|
29
|
+
upsert.run(key, JSON.stringify(value), ttl ? Date.now() + ttl * 1000 : null)
|
|
30
|
+
},
|
|
31
|
+
async delete(key) {
|
|
32
|
+
remove.run(key)
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/users.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { base64Url } from './util.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* People. A user is just `{ id, email, createdAt }`; keep anything else about them in
|
|
5
|
+
* your own tables, keyed by `user.id`.
|
|
6
|
+
*
|
|
7
|
+
* user:<id> the user
|
|
8
|
+
* email:<email> -> id
|
|
9
|
+
*
|
|
10
|
+
* @param {import('./index.js').Store} store
|
|
11
|
+
* @param {undefined | string[] | ((email: string) => boolean | Promise<boolean>)} allow
|
|
12
|
+
*/
|
|
13
|
+
export function createUsers(store, allow) {
|
|
14
|
+
const list = Array.isArray(allow) ? allow.map((e) => e.trim().toLowerCase()) : null
|
|
15
|
+
|
|
16
|
+
async function allowed(email) {
|
|
17
|
+
if (typeof allow === 'function') return Boolean(await allow(email))
|
|
18
|
+
if (!list) return true
|
|
19
|
+
// "@family.com" lets in anyone at that domain.
|
|
20
|
+
return list.includes(email) || list.some((entry) => entry.startsWith('@') && email.endsWith(entry))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
allowed,
|
|
25
|
+
|
|
26
|
+
async byId(id) {
|
|
27
|
+
return store.get(`user:${id}`)
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async byEmail(email) {
|
|
31
|
+
const id = await store.get(`email:${normalizeEmail(email)}`)
|
|
32
|
+
return id ? store.get(`user:${id}`) : null
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/** The user with this email, created on first sign-in. Null when they aren't allowed in. */
|
|
36
|
+
async signIn(rawEmail) {
|
|
37
|
+
const email = normalizeEmail(rawEmail)
|
|
38
|
+
if (!(await allowed(email))) return null
|
|
39
|
+
const existing = await this.byEmail(email)
|
|
40
|
+
if (existing) return existing
|
|
41
|
+
const user = { id: `usr_${base64Url(crypto.getRandomValues(new Uint8Array(15)))}`, email, createdAt: Date.now() }
|
|
42
|
+
await store.put(`user:${user.id}`, user)
|
|
43
|
+
await store.put(`email:${email}`, user.id)
|
|
44
|
+
return user
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeEmail(email) {
|
|
50
|
+
return String(email ?? '').trim().toLowerCase()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Deliberately loose: one @, something either side, a dot in the domain, no spaces. */
|
|
54
|
+
export function validEmail(email) {
|
|
55
|
+
return typeof email === 'string' && email.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
|
56
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Small helpers shared across slauth. Web Crypto only, so they run anywhere. */
|
|
2
|
+
|
|
3
|
+
export function readCookie(header, name) {
|
|
4
|
+
for (const part of (header ?? '').split(';')) {
|
|
5
|
+
const [key, ...rest] = part.trim().split('=')
|
|
6
|
+
if (key === name) return rest.join('=')
|
|
7
|
+
}
|
|
8
|
+
return null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function randomToken() {
|
|
12
|
+
return base64Url(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function base64Url(buffer) {
|
|
16
|
+
let binary = ''
|
|
17
|
+
for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
|
|
18
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Compares without stopping at the first difference, so timing gives nothing away. */
|
|
22
|
+
export function safeEqual(a, b) {
|
|
23
|
+
if (a.length !== b.length) return false
|
|
24
|
+
let diff = 0
|
|
25
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
26
|
+
return diff === 0
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function fromBase64Url(text) {
|
|
30
|
+
const base64 = String(text).replace(/-/g, '+').replace(/_/g, '/')
|
|
31
|
+
const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4))
|
|
32
|
+
return Uint8Array.from(binary, (c) => c.charCodeAt(0))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const encoder = new TextEncoder()
|
|
36
|
+
|
|
37
|
+
export async function sha256(data) {
|
|
38
|
+
return new Uint8Array(await crypto.subtle.digest('SHA-256', typeof data === 'string' ? encoder.encode(data) : data))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function hmac(secret, message) {
|
|
42
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
43
|
+
return base64Url(await crypto.subtle.sign('HMAC', key, encoder.encode(message)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A Set-Cookie value. maxAge null makes a browser-session cookie; 0 deletes it. */
|
|
47
|
+
export function serializeCookie(name, value, { maxAge = null, secure = true, sameSite = 'Lax', path = '/' } = {}) {
|
|
48
|
+
const parts = [`${name}=${value}`, `Path=${path}`, 'HttpOnly', `SameSite=${sameSite}`]
|
|
49
|
+
if (maxAge !== null) parts.push(`Max-Age=${maxAge}`)
|
|
50
|
+
if (secure) parts.push('Secure')
|
|
51
|
+
return parts.join('; ')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function json(body, status = 200, headers = {}) {
|
|
55
|
+
return new Response(JSON.stringify(body), {
|
|
56
|
+
status,
|
|
57
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function concat(...parts) {
|
|
62
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
|
|
63
|
+
let at = 0
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
out.set(part, at)
|
|
66
|
+
at += part.length
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|