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
package/src/session.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
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 (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.
|
|
15
|
+
*
|
|
16
|
+
* @param {{ store: import('./index.js').Store, cookie: string, days: number }} options
|
|
17
|
+
*/
|
|
18
|
+
export function createSessions({ store, cookie, days }) {
|
|
19
|
+
const ttl = days * DAY
|
|
20
|
+
const idFor = async (token) => base64Url(await sha256(token))
|
|
21
|
+
const secure = (request) => new URL(request.url).protocol === 'https:'
|
|
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
|
+
|
|
43
|
+
return {
|
|
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 } = {}) {
|
|
53
|
+
const token = randomToken()
|
|
54
|
+
const id = await idFor(token)
|
|
55
|
+
const now = Date.now()
|
|
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) }) }
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/** The session behind this request, or null. */
|
|
67
|
+
async read(request) {
|
|
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 }
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/** Restarts the 30 days at most once a day; returns a Set-Cookie value when it did. */
|
|
86
|
+
async refresh(session, request) {
|
|
87
|
+
if (Date.now() - session.refreshedAt < DAY * 1000) return null
|
|
88
|
+
const { token, id, ...value } = session
|
|
89
|
+
await store.put(`session:${id}`, { ...value, refreshedAt: Date.now() }, { ttl })
|
|
90
|
+
return serializeCookie(cookie, token, { maxAge: ttl, secure: secure(request) })
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/** Ends this request's session; returns the Set-Cookie value that clears it. */
|
|
94
|
+
async destroy(request) {
|
|
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) {
|
|
101
|
+
return serializeCookie(cookie, '', { maxAge: 0, secure: secure(request) })
|
|
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
|
+
},
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { d1 } from './index.js'
|
package/src/stores/d1.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
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
|
+
},
|
|
51
|
+
}
|
|
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
|
+
}
|
|
@@ -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>; 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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { kv } from './index.js'
|
package/src/stores/kv.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
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
|
+
},
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { memory } from './index.js'
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
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
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { sqlite } from './index.js'
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { page } from './d1.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Any synchronous SQLite handle with `prepare(sql).get(...)` / `.run(...)`: bun:sqlite,
|
|
5
|
+
* better-sqlite3 and node:sqlite all fit. Creates its own table on first use.
|
|
6
|
+
*
|
|
7
|
+
* @param {{ prepare(sql: string): { get(...args: unknown[]): any, run(...args: unknown[]): unknown, all(...args: unknown[]): any[] } }} db
|
|
8
|
+
* @param {{ table?: string }} [options]
|
|
9
|
+
* @returns {import('../index.js').Store}
|
|
10
|
+
*/
|
|
11
|
+
export function sqlite(db, { table = 'slauth_kv' } = {}) {
|
|
12
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new Error(`slauth: "${table}" isn't a valid table name.`)
|
|
13
|
+
db.prepare(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER)`).run()
|
|
14
|
+
const select = db.prepare(`SELECT value, expires_at FROM ${table} WHERE key = ?`)
|
|
15
|
+
const upsert = db.prepare(
|
|
16
|
+
`INSERT INTO ${table} (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at`,
|
|
17
|
+
)
|
|
18
|
+
const remove = db.prepare(`DELETE FROM ${table} WHERE key = ?`)
|
|
19
|
+
let scan = null
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
async get(key) {
|
|
23
|
+
const row = select.get(key)
|
|
24
|
+
if (!row) return null
|
|
25
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
26
|
+
remove.run(key)
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
return JSON.parse(row.value)
|
|
30
|
+
},
|
|
31
|
+
async put(key, value, { ttl } = {}) {
|
|
32
|
+
upsert.run(key, JSON.stringify(value), ttl ? Date.now() + ttl * 1000 : null)
|
|
33
|
+
},
|
|
34
|
+
async delete(key) {
|
|
35
|
+
remove.run(key)
|
|
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
|
+
},
|
|
41
|
+
}
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c])
|
|
49
|
+
}
|