slauthjs 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/session.js CHANGED
@@ -3,47 +3,129 @@ import { base64Url, randomToken, readCookie, serializeCookie, sha256 } from './u
3
3
  const DAY = 24 * 60 * 60
4
4
 
5
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.
6
+ * Sessions: a random token in an HttpOnly cookie (or, for apps that can't keep cookies,
7
+ * sent as `Authorization: Bearer <token>`). Only a SHA-256 of the token is stored, so a
8
+ * leaked database can't be used to sign in. Sessions slide: each day someone uses the
9
+ * app, the 30 days start again.
10
+ *
11
+ * session:<id> { userId, createdAt, refreshedAt, method, userAgent, ip, impersonatorId }
12
+ * sessions:<userId> -> [id, ...] (so people can see and end their other sessions)
13
+ *
14
+ * The id is the hash, which is safe to show: it can't be turned back into the token.
9
15
  *
10
16
  * @param {{ store: import('./index.js').Store, cookie: string, days: number }} options
11
17
  */
12
18
  export function createSessions({ store, cookie, days }) {
13
19
  const ttl = days * DAY
14
- const keyFor = async (token) => `session:${base64Url(await sha256(token))}`
20
+ const idFor = async (token) => base64Url(await sha256(token))
15
21
  const secure = (request) => new URL(request.url).protocol === 'https:'
16
22
 
23
+ function tokenFrom(request) {
24
+ const auth = request.headers.get('authorization')
25
+ const bearer = auth?.startsWith('Bearer ') ? auth.slice(7).trim() : null
26
+ // A JWT (two dots) is for auth.verifyToken, not a session.
27
+ const token = bearer && bearer.split('.').length !== 3 ? bearer : readCookie(request.headers.get('cookie'), cookie)
28
+ return token && token.length <= 128 ? token : null
29
+ }
30
+
31
+ async function index(userId, change) {
32
+ const ids = (await store.get(`sessions:${userId}`)) ?? []
33
+ const next = change(ids)
34
+ if (next.length) await store.put(`sessions:${userId}`, next)
35
+ else await store.delete(`sessions:${userId}`)
36
+ }
37
+
38
+ async function revoke(userId, id) {
39
+ await store.delete(`session:${id}`)
40
+ await index(userId, (ids) => ids.filter((x) => x !== id))
41
+ }
42
+
17
43
  return {
18
- /** Starts a session; returns the Set-Cookie value. */
19
- async create(userId, request) {
44
+ cookieName: cookie,
45
+
46
+ /**
47
+ * Starts a session. Returns the token and the Set-Cookie value that carries it.
48
+ * @param {string} userId
49
+ * @param {Request} request
50
+ * @param {{ method?: string, impersonatorId?: string }} [details]
51
+ */
52
+ async create(userId, request, { method = 'unknown', impersonatorId = null } = {}) {
20
53
  const token = randomToken()
54
+ const id = await idFor(token)
21
55
  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) })
56
+ const userAgent = (request.headers.get('user-agent') ?? '').slice(0, 300) || null
57
+ const ip = request.headers.get('cf-connecting-ip') ?? request.headers.get('x-real-ip') ?? null
58
+ await store.put(`session:${id}`, { userId, createdAt: now, refreshedAt: now, method, userAgent, ip, impersonatorId }, { ttl })
59
+ // At most 50 at once: the oldest is signed out to make room.
60
+ const ids = (await store.get(`sessions:${userId}`)) ?? []
61
+ for (const old of ids.slice(0, Math.max(0, ids.length - 49))) await store.delete(`session:${old}`)
62
+ await store.put(`sessions:${userId}`, [...ids.slice(-49), id])
63
+ return { id, token, cookie: serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) }) }
24
64
  },
25
65
 
26
66
  /** The session behind this request, or null. */
27
67
  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
68
+ const token = tokenFrom(request)
69
+ if (!token) return null
70
+ const id = await idFor(token)
71
+ const session = await store.get(`session:${id}`)
72
+ if (!session) return null
73
+ // A session from slauth 1.x: add it to the index, so revoking and banning reach it.
74
+ if (!('method' in session)) {
75
+ const left = Math.ceil((session.refreshedAt + ttl * 1000 - Date.now()) / 1000)
76
+ if (left <= 0) return null
77
+ Object.assign(session, { method: null, userAgent: null, ip: null, impersonatorId: null })
78
+ await store.put(`session:${id}`, session, { ttl: left })
79
+ const ids = (await store.get(`sessions:${session.userId}`)) ?? []
80
+ if (!ids.includes(id)) await store.put(`sessions:${session.userId}`, [...ids.slice(-49), id])
81
+ }
82
+ return { ...session, id, token }
32
83
  },
33
84
 
34
85
  /** Restarts the 30 days at most once a day; returns a Set-Cookie value when it did. */
35
86
  async refresh(session, request) {
36
87
  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 })
88
+ const { token, id, ...value } = session
89
+ await store.put(`session:${id}`, { ...value, refreshedAt: Date.now() }, { ttl })
39
90
  return serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) })
40
91
  },
41
92
 
42
93
  /** Ends this request's session; returns the Set-Cookie value that clears it. */
43
94
  async destroy(request) {
44
- const token = readCookie(request.headers.get('cookie'), cookie)
45
- if (token && token.length <= 128) await store.delete(await keyFor(token))
95
+ const session = await this.read(request)
96
+ if (session) await revoke(session.userId, session.id)
97
+ return this.clear(request)
98
+ },
99
+
100
+ clear(request) {
46
101
  return serializeCookie(cookie, '', { maxAge: 0, secure: secure(request) })
47
102
  },
103
+
104
+ /** Someone's live sessions, newest first. */
105
+ async list(userId) {
106
+ const ids = (await store.get(`sessions:${userId}`)) ?? []
107
+ const out = []
108
+ for (const id of ids) {
109
+ const s = await store.get(`session:${id}`)
110
+ if (s && s.userId === userId) out.push({ id, createdAt: s.createdAt, lastActiveAt: s.refreshedAt, method: s.method ?? null, userAgent: s.userAgent ?? null, ip: s.ip ?? null, impersonatorId: s.impersonatorId ?? null })
111
+ }
112
+ if (out.length !== ids.length) await index(userId, () => out.map((s) => s.id))
113
+ return out.sort((a, b) => b.createdAt - a.createdAt)
114
+ },
115
+
116
+ /** Ends one of someone's sessions. Returns false when there was no such session. */
117
+ async revoke(userId, id) {
118
+ const s = typeof id === 'string' ? await store.get(`session:${id}`) : null
119
+ if (!s || s.userId !== userId) return false
120
+ await revoke(userId, id)
121
+ return true
122
+ },
123
+
124
+ /** Ends all of someone's sessions, except `keepId` if given. */
125
+ async revokeAll(userId, keepId = null) {
126
+ const ids = (await store.get(`sessions:${userId}`)) ?? []
127
+ for (const id of ids) if (id !== keepId) await store.delete(`session:${id}`)
128
+ await index(userId, () => (keepId && ids.includes(keepId) ? [keepId] : []))
129
+ },
48
130
  }
49
131
  }
package/src/stores/d1.js CHANGED
@@ -40,5 +40,19 @@ export function d1(db, { table = 'slauth_kv' } = {}) {
40
40
  await init()
41
41
  await db.prepare(`DELETE FROM ${table} WHERE key = ?`).bind(key).run()
42
42
  },
43
+ async list(prefix, { limit = 100, cursor } = {}) {
44
+ await init()
45
+ const { results } = await db
46
+ .prepare(`SELECT key, value FROM ${table} WHERE key >= ? AND key < ? AND key > ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key LIMIT ?`)
47
+ .bind(prefix, prefix + '\uffff', cursor ?? '', Date.now(), limit + 1)
48
+ .all()
49
+ return page(results, limit)
50
+ },
43
51
  }
44
52
  }
53
+
54
+ /** Shared by the SQL stores: rows (one more than asked for) → a page and its cursor. */
55
+ export function page(rows, limit) {
56
+ const items = rows.slice(0, limit).map((row) => ({ key: row.key, value: JSON.parse(row.value) }))
57
+ return { items, cursor: rows.length > limit ? items.at(-1).key : null }
58
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Store } from '../index.js'
2
2
  export function memory(): Store
3
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
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>; list?(options: { prefix: string; limit?: number; cursor?: string }): Promise<{ keys: { name: string }[]; list_complete: boolean; cursor?: string }> }, options?: { prefix?: string }): Store
5
+ export function sqlite(db: { prepare(sql: string): { get(...args: unknown[]): any; run(...args: unknown[]): unknown; all?(...args: unknown[]): any[] } }, options?: { table?: string }): Store
package/src/stores/kv.js CHANGED
@@ -14,5 +14,14 @@ export function kv(namespace, { prefix = 'slauth:' } = {}) {
14
14
  put: (key, value, { ttl } = {}) =>
15
15
  namespace.put(prefix + key, JSON.stringify(value), ttl ? { expirationTtl: Math.max(60, Math.ceil(ttl)) } : undefined),
16
16
  delete: (key) => namespace.delete(prefix + key),
17
+ async list(keyPrefix, { limit = 100, cursor } = {}) {
18
+ const page = await namespace.list({ prefix: prefix + keyPrefix, limit, cursor: cursor ?? undefined })
19
+ const items = []
20
+ for (const { name } of page.keys) {
21
+ const value = await namespace.get(name, 'json')
22
+ if (value !== null) items.push({ key: name.slice(prefix.length), value })
23
+ }
24
+ return { items, cursor: page.list_complete ? null : page.cursor }
25
+ },
17
26
  }
18
27
  }
@@ -26,5 +26,10 @@ export function memory() {
26
26
  async delete(key) {
27
27
  rows.delete(key)
28
28
  },
29
+ async list(prefix, { limit = 100, cursor } = {}) {
30
+ const keys = [...rows.keys()].filter((k) => k.startsWith(prefix) && (!cursor || k > cursor) && live(k)).sort()
31
+ const page = keys.slice(0, limit)
32
+ return { items: page.map((key) => ({ key, value: structuredClone(rows.get(key).value) })), cursor: keys.length > limit ? page.at(-1) : null }
33
+ },
29
34
  }
30
35
  }
@@ -1,8 +1,10 @@
1
+ import { page } from './d1.js'
2
+
1
3
  /**
2
4
  * Any synchronous SQLite handle with `prepare(sql).get(...)` / `.run(...)`: bun:sqlite,
3
5
  * better-sqlite3 and node:sqlite all fit. Creates its own table on first use.
4
6
  *
5
- * @param {{ prepare(sql: string): { get(...args: unknown[]): any, run(...args: unknown[]): unknown } }} db
7
+ * @param {{ prepare(sql: string): { get(...args: unknown[]): any, run(...args: unknown[]): unknown, all(...args: unknown[]): any[] } }} db
6
8
  * @param {{ table?: string }} [options]
7
9
  * @returns {import('../index.js').Store}
8
10
  */
@@ -14,6 +16,7 @@ export function sqlite(db, { table = 'slauth_kv' } = {}) {
14
16
  `INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`,
15
17
  )
16
18
  const remove = db.prepare(`DELETE FROM ${table} WHERE key = ?`)
19
+ let scan = null
17
20
 
18
21
  return {
19
22
  async get(key) {
@@ -31,5 +34,9 @@ export function sqlite(db, { table = 'slauth_kv' } = {}) {
31
34
  async delete(key) {
32
35
  remove.run(key)
33
36
  },
37
+ async list(prefix, { limit = 100, cursor } = {}) {
38
+ scan ??= db.prepare(`SELECT key, value FROM ${table} WHERE key >= ? AND key < ? AND key > ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key LIMIT ?`)
39
+ return page(scan.all(prefix, prefix + '\uffff', cursor ?? '', Date.now(), limit + 1), limit)
40
+ },
34
41
  }
35
42
  }
package/src/tokens.js ADDED
@@ -0,0 +1,119 @@
1
+ import { base64Url, fromBase64Url, randomId } from './util.js'
2
+
3
+ const enc = new TextEncoder()
4
+ const ES256 = { name: 'ECDSA', namedCurve: 'P-256' }
5
+
6
+ /**
7
+ * Short-lived JWTs (ES256) for calling your other services, which can check them with
8
+ * the public keys at /auth/jwks.json and no shared secret, like Clerk's getToken().
9
+ *
10
+ * jwk:current -> kid
11
+ * jwk:<kid> { kid, publicJwk, privateJwk (AES-GCM encrypted with a key from `secret`), createdAt }
12
+ *
13
+ * The signing key is made on first use. Changing `secret` makes a new one; tokens only
14
+ * live a few minutes, so nothing much notices.
15
+ *
16
+ * @param {{ store: import('./index.js').Store, secret: string, minutes?: number, issuer?: string, audience?: string, claims?: (user: object) => object | Promise<object> }} options
17
+ */
18
+ export function createTokens({ store, secret, minutes = 5, issuer, audience, claims }) {
19
+ let wrapKey = null
20
+ const wrapping = () =>
21
+ (wrapKey ??= crypto.subtle
22
+ .importKey('raw', enc.encode(secret), 'HKDF', false, ['deriveKey'])
23
+ .then((base) => crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: enc.encode('slauth-jwk'), info: enc.encode('wrap') }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])))
24
+
25
+ let signing = null
26
+
27
+ async function currentKey() {
28
+ // Re-check now and then, so every server ends up signing with the same key.
29
+ if (signing && Date.now() - signing.checkedAt < 60_000) return signing
30
+ const kid = await store.get('jwk:current')
31
+ if (signing && signing.kid === kid) return Object.assign(signing, { checkedAt: Date.now() })
32
+ const doc = kid ? await store.get(`jwk:${kid}`) : null
33
+ if (doc) {
34
+ try {
35
+ const iv = fromBase64Url(doc.privateJwk.iv)
36
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await wrapping(), fromBase64Url(doc.privateJwk.data))
37
+ const key = await crypto.subtle.importKey('jwk', JSON.parse(new TextDecoder().decode(plain)), ES256, false, ['sign'])
38
+ return (signing = { kid, key, checkedAt: Date.now() })
39
+ } catch {
40
+ // Made with another secret: start over below.
41
+ }
42
+ }
43
+ const pair = await crypto.subtle.generateKey(ES256, true, ['sign', 'verify'])
44
+ const newKid = randomId('key', 9)
45
+ const publicJwk = { ...(await crypto.subtle.exportKey('jwk', pair.publicKey)), kid: newKid, alg: 'ES256', use: 'sig' }
46
+ delete publicJwk.key_ops
47
+ delete publicJwk.ext
48
+ const iv = crypto.getRandomValues(new Uint8Array(12))
49
+ const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await wrapping(), enc.encode(JSON.stringify(await crypto.subtle.exportKey('jwk', pair.privateKey))))
50
+ await store.put(`jwk:${newKid}`, { kid: newKid, publicJwk, privateJwk: { iv: base64Url(iv), data: base64Url(data) }, createdAt: Date.now() })
51
+ const previous = kid ? [kid, ...((await store.get('jwk:previous')) ?? [])].slice(0, 2) : []
52
+ await store.put('jwk:previous', previous)
53
+ await store.put('jwk:current', newKid)
54
+ return (signing = { kid: newKid, key: pair.privateKey, checkedAt: Date.now() })
55
+ }
56
+
57
+ async function publicKey(kid) {
58
+ const doc = typeof kid === 'string' ? await store.get(`jwk:${kid}`) : null
59
+ return doc ? crypto.subtle.importKey('jwk', doc.publicJwk, ES256, false, ['verify']) : null
60
+ }
61
+
62
+ return {
63
+ /** A signed token for this user: { token, expiresAt }. */
64
+ async create(user, extra = {}) {
65
+ const { kid, key } = await currentKey()
66
+ const now = Math.floor(Date.now() / 1000)
67
+ const payload = {
68
+ ...(claims ? await claims(user) : {}),
69
+ ...extra,
70
+ sub: user.id,
71
+ email: user.email,
72
+ name: user.name ?? null,
73
+ roles: user.roles ?? [],
74
+ iat: now,
75
+ exp: now + minutes * 60,
76
+ ...(issuer ? { iss: issuer } : {}),
77
+ ...(audience ? { aud: audience } : {}),
78
+ }
79
+ const head = base64Url(enc.encode(JSON.stringify({ alg: 'ES256', typ: 'JWT', kid })))
80
+ const body = base64Url(enc.encode(JSON.stringify(payload)))
81
+ const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key, enc.encode(`${head}.${body}`))
82
+ return { token: `${head}.${body}.${base64Url(signature)}`, expiresAt: payload.exp * 1000 }
83
+ },
84
+
85
+ /** The claims of a token we signed that hasn't expired, or null. */
86
+ async verify(token) {
87
+ try {
88
+ const [head, body, signature] = String(token).split('.')
89
+ if (!signature) return null
90
+ const header = JSON.parse(new TextDecoder().decode(fromBase64Url(head)))
91
+ if (header.alg !== 'ES256') return null
92
+ const key = await publicKey(header.kid)
93
+ if (!key) return null
94
+ const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, fromBase64Url(signature), enc.encode(`${head}.${body}`))
95
+ if (!ok) return null
96
+ const payload = JSON.parse(new TextDecoder().decode(fromBase64Url(body)))
97
+ const now = Math.floor(Date.now() / 1000)
98
+ if (typeof payload.exp !== 'number' || payload.exp < now - 30) return null
99
+ if (issuer && payload.iss !== issuer) return null
100
+ if (audience && payload.aud !== audience) return null
101
+ return payload
102
+ } catch {
103
+ return null
104
+ }
105
+ },
106
+
107
+ /** The public keys, as served at /auth/jwks.json. */
108
+ async jwks() {
109
+ await currentKey()
110
+ const kids = [await store.get('jwk:current'), ...((await store.get('jwk:previous')) ?? [])]
111
+ const keys = []
112
+ for (const kid of kids) {
113
+ const doc = kid ? await store.get(`jwk:${kid}`) : null
114
+ if (doc) keys.push(doc.publicJwk)
115
+ }
116
+ return { keys }
117
+ },
118
+ }
119
+ }
@@ -0,0 +1,142 @@
1
+ import { base64Url, sha256, safeEqual } from './util.js'
2
+
3
+ const STEP = 30
4
+ const RECOVERY_CODES = 10
5
+
6
+ /**
7
+ * Two-factor sign-in with an authenticator app (TOTP, RFC 6238: 6 digits, 30 seconds,
8
+ * SHA-1, which is what every app supports), plus one-time recovery codes.
9
+ *
10
+ * totp:<userId> { secret, enabledAt, lastStep, recovery: [sha256 of each code] }
11
+ *
12
+ * A code is accepted for the 30 seconds before and after its own, and only once.
13
+ *
14
+ * @param {{ store: import('./index.js').Store, issuer: string }} options
15
+ */
16
+ export function createTwoFactor({ store, issuer }) {
17
+ const key = (userId) => `totp:${userId}`
18
+
19
+ return {
20
+ async enabled(userId) {
21
+ return Boolean((await store.get(key(userId)))?.enabledAt)
22
+ },
23
+
24
+ /** Starts setting up: a new secret and the otpauth:// link to show as a QR code. */
25
+ async setup(user) {
26
+ const secret = base32(crypto.getRandomValues(new Uint8Array(20)))
27
+ await store.put(key(user.id), { secret, enabledAt: null, lastStep: 0, recovery: [] })
28
+ const label = encodeURIComponent(`${issuer}:${user.email}`)
29
+ return { secret, uri: `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=${STEP}` }
30
+ },
31
+
32
+ /** Finishes setting up once the app shows a right code. Returns the recovery codes, or null. */
33
+ async enable(userId, code) {
34
+ const doc = await store.get(key(userId))
35
+ if (!doc || doc.enabledAt) return null
36
+ const step = await matchStep(doc.secret, code, doc.lastStep)
37
+ if (step === null) return null
38
+ const recovery = newRecoveryCodes()
39
+ await store.put(key(userId), { ...doc, enabledAt: Date.now(), lastStep: step, recovery: await Promise.all(recovery.map(hashCode)) })
40
+ return recovery
41
+ },
42
+
43
+ /** Checks an app code or a recovery code (each recovery code works once). */
44
+ async check(userId, { code, recoveryCode } = {}) {
45
+ const doc = await store.get(key(userId))
46
+ if (!doc?.enabledAt) return false
47
+ if (recoveryCode) {
48
+ const hash = await hashCode(recoveryCode)
49
+ if (!doc.recovery.some((h) => safeEqual(h, hash))) return false
50
+ await store.put(key(userId), { ...doc, recovery: doc.recovery.filter((h) => h !== hash) })
51
+ return true
52
+ }
53
+ const step = await matchStep(doc.secret, code, doc.lastStep)
54
+ if (step === null) return false
55
+ await store.put(key(userId), { ...doc, lastStep: step })
56
+ return true
57
+ },
58
+
59
+ async recoveryLeft(userId) {
60
+ return (await store.get(key(userId)))?.recovery.length ?? 0
61
+ },
62
+
63
+ async regenerate(userId) {
64
+ const doc = await store.get(key(userId))
65
+ const recovery = newRecoveryCodes()
66
+ await store.put(key(userId), { ...doc, recovery: await Promise.all(recovery.map(hashCode)) })
67
+ return recovery
68
+ },
69
+
70
+ async disable(userId) {
71
+ await store.delete(key(userId))
72
+ },
73
+ }
74
+ }
75
+
76
+ /** The time step a 6-digit code belongs to (now ± 1), or null. Steps at or before `after` are spent. */
77
+ async function matchStep(secret, code, after = 0) {
78
+ const guess = String(code ?? '').replace(/\D/g, '')
79
+ if (guess.length !== 6) return null
80
+ const now = Math.floor(Date.now() / 1000 / STEP)
81
+ for (const step of [now - 1, now, now + 1]) {
82
+ if (step > after && safeEqual(await totp(secret, step), guess)) return step
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** The code for a base32 secret at a time step (exported for tests and your own tools). */
88
+ export async function totp(secret, step = Math.floor(Date.now() / 1000 / STEP)) {
89
+ const counter = new Uint8Array(8)
90
+ new DataView(counter.buffer).setBigUint64(0, BigInt(step))
91
+ const key = await crypto.subtle.importKey('raw', fromBase32(secret), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'])
92
+ const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, counter))
93
+ const at = mac[19] & 0xf
94
+ const value = ((mac[at] & 0x7f) << 24) | (mac[at + 1] << 16) | (mac[at + 2] << 8) | mac[at + 3]
95
+ return String(value % 1_000_000).padStart(6, '0')
96
+ }
97
+
98
+ function newRecoveryCodes() {
99
+ const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'
100
+ return Array.from({ length: RECOVERY_CODES }, () => {
101
+ const bytes = crypto.getRandomValues(new Uint8Array(10))
102
+ const chars = [...bytes].map((b) => alphabet[b % alphabet.length]).join('')
103
+ return `${chars.slice(0, 5)}-${chars.slice(5)}`
104
+ })
105
+ }
106
+
107
+ async function hashCode(code) {
108
+ return base64Url(await sha256(String(code).trim().toLowerCase().replace(/[^a-z0-9]/g, '')))
109
+ }
110
+
111
+ const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
112
+
113
+ function base32(bytes) {
114
+ let bits = 0
115
+ let value = 0
116
+ let out = ''
117
+ for (const byte of bytes) {
118
+ value = (value << 8) | byte
119
+ bits += 8
120
+ while (bits >= 5) {
121
+ out += BASE32[(value >>> (bits - 5)) & 31]
122
+ bits -= 5
123
+ }
124
+ }
125
+ if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]
126
+ return out
127
+ }
128
+
129
+ function fromBase32(text) {
130
+ let bits = 0
131
+ let value = 0
132
+ const out = []
133
+ for (const char of text.replace(/=+$/, '').toUpperCase()) {
134
+ value = (value << 5) | BASE32.indexOf(char)
135
+ bits += 5
136
+ if (bits >= 8) {
137
+ out.push((value >>> (bits - 8)) & 255)
138
+ bits -= 8
139
+ }
140
+ }
141
+ return new Uint8Array(out)
142
+ }
package/src/ui.js ADDED
@@ -0,0 +1,49 @@
1
+ /** Shared by <slauth-sign-in> and <slauth-account>. */
2
+
3
+ export const STYLE = `
4
+ :host { display: block; font-family: var(--slauth-font, inherit); color: var(--slauth-ink, #2b2118); max-width: 360px; }
5
+ .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); }
6
+ h2 { margin: 0 0 4px; font-size: 20px; line-height: 1.2; }
7
+ h3 { margin: 18px 0 8px; font-size: 15px; }
8
+ p { margin: 0 0 14px; font-size: 14px; opacity: .8; }
9
+ label { display: block; font-size: 13px; font-weight: 600; margin: 10px 0 6px; }
10
+ 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; }
11
+ input.code { letter-spacing: .4em; text-align: center; font-size: 22px; font-weight: 700; }
12
+ input:focus, button:focus-visible, a:focus-visible { outline: 3px solid var(--slauth-accent, #f59e0b); outline-offset: 1px; }
13
+ button, a.button { box-sizing: border-box; display: block; text-align: center; text-decoration: none; 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; }
14
+ .primary { background: var(--slauth-accent, #f59e0b); color: var(--slauth-accent-ink, var(--slauth-ink, #2b2118)); border: 2px solid var(--slauth-ink, #2b2118); }
15
+ .primary:active { transform: translate(1px, 1px); }
16
+ .ghost, a.button { background: transparent; color: inherit; border: 2px solid currentColor; }
17
+ .ghost.dashed { border-style: dashed; opacity: .85; }
18
+ .providers { margin-top: 14px; }
19
+ .providers a.button { margin-top: 8px; }
20
+ .providers a.button:first-child { margin-top: 0; }
21
+ .or { display: flex; align-items: center; gap: 10px; margin: 16px 0 4px; font-size: 12px; opacity: .6; }
22
+ .or::before, .or::after { content: ''; flex: 1; border-top: 2px dashed currentColor; }
23
+ .link { background: none; border: 0; padding: 4px; width: auto; display: inline; margin: 0; font-weight: 600; text-decoration: underline; opacity: .75; color: inherit; }
24
+ button:disabled { opacity: .5; cursor: default; }
25
+ .error { color: var(--slauth-error, #c2410c); font-size: 13px; font-weight: 600; min-height: 18px; margin: 8px 0 0; opacity: 1; }
26
+ .row { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
27
+ .codes { font-family: ui-monospace, monospace; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 12px; font-size: 14px; margin: 0 0 12px; }
28
+ .list { list-style: none; padding: 0; margin: 0 0 8px; }
29
+ .list li { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 2px dashed color-mix(in srgb, currentColor 18%, transparent); font-size: 14px; }
30
+ .list li span { overflow: hidden; text-overflow: ellipsis; }
31
+ .muted { opacity: .65; font-size: 12px; }
32
+ `
33
+
34
+ /** What a slauth error code means to a person. */
35
+ export const MESSAGES = {
36
+ 'oauth-cancelled': 'Sign-in was cancelled.',
37
+ 'oauth-failed': "Couldn't sign in with that account. Please try again.",
38
+ 'oauth-state': 'That sign-in took too long. Please try again.',
39
+ 'email-not-verified': "That account's email isn't verified, so it can't be used to sign in. Use an email code instead.",
40
+ 'not-allowed': "That email isn't on the list for this app.",
41
+ 'account-in-use': "That account is already linked to someone else's sign-in.",
42
+ banned: "This account can't sign in.",
43
+ 'signed-out': 'Please sign in first.',
44
+ 'reauth-required': 'Please sign in again to do that.',
45
+ }
46
+
47
+ export function escape(text) {
48
+ return String(text).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c])
49
+ }