mikser-io-auth 0.5.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/lib/grants.js ADDED
@@ -0,0 +1,162 @@
1
+ import { registerSchema, useDatabase } from 'mikser-io'
2
+ import { opaqueToken } from './pkce.js'
3
+
4
+ // Authorization codes and refresh tokens — the only server-side state this
5
+ // package keeps. Identity stays in files (ADR-0012); this is session
6
+ // bookkeeping, which is exactly what the engine's sqlite substrate is for
7
+ // (ADR-0009).
8
+ //
9
+ // Table prefix follows the cross-repo convention: `mikser-io-auth` →
10
+ // `mikser_auth_*`.
11
+ //
12
+ // One consequence worth knowing: the engine wipes this database when its
13
+ // schema stamp changes, so upgrading mikser signs everyone out. Codes live
14
+ // 60 seconds so they are irrelevant; refresh tokens are the real cost, and
15
+ // re-authenticating after an engine upgrade is a fair price for not
16
+ // inventing a second persistence story.
17
+ registerSchema('auth', `
18
+ CREATE TABLE IF NOT EXISTS mikser_auth_codes (
19
+ code TEXT PRIMARY KEY,
20
+ client_id TEXT NOT NULL,
21
+ subject TEXT NOT NULL,
22
+ redirect_uri TEXT NOT NULL,
23
+ code_challenge TEXT NOT NULL,
24
+ scope TEXT,
25
+ expires_at INTEGER NOT NULL,
26
+ used_at INTEGER
27
+ );
28
+ CREATE INDEX IF NOT EXISTS idx_mikser_auth_codes_expiry
29
+ ON mikser_auth_codes (expires_at);
30
+
31
+ CREATE TABLE IF NOT EXISTS mikser_auth_refresh (
32
+ token TEXT PRIMARY KEY,
33
+ client_id TEXT NOT NULL,
34
+ subject TEXT NOT NULL,
35
+ expires_at INTEGER NOT NULL,
36
+ revoked_at INTEGER
37
+ );
38
+ CREATE INDEX IF NOT EXISTS idx_mikser_auth_refresh_subject
39
+ ON mikser_auth_refresh (subject);
40
+
41
+ -- Self-registered clients (RFC 7591). Config-declared clients are NOT
42
+ -- here: they live in config, are not prunable, and always win a lookup.
43
+ -- Keeping the two apart is what makes pruning safe — an operator's
44
+ -- client that has not been used yet is not garbage.
45
+ CREATE TABLE IF NOT EXISTS mikser_auth_clients (
46
+ client_id TEXT PRIMARY KEY,
47
+ name TEXT NOT NULL,
48
+ redirect_uris TEXT NOT NULL,
49
+ created_at INTEGER NOT NULL,
50
+ last_used_at INTEGER
51
+ );
52
+ `)
53
+
54
+ const db = () => useDatabase().handle
55
+
56
+ export function createCode({ clientId, subject, redirectUri, codeChallenge, scope, ttlSec = 60 }) {
57
+ const code = opaqueToken()
58
+ db().prepare(`
59
+ INSERT INTO mikser_auth_codes
60
+ (code, client_id, subject, redirect_uri, code_challenge, scope, expires_at)
61
+ VALUES (?, ?, ?, ?, ?, ?, ?)
62
+ `).run(code, clientId, subject, redirectUri, codeChallenge, scope ?? '', Date.now() + ttlSec * 1000)
63
+ return code
64
+ }
65
+
66
+ export function getCode(code) {
67
+ return db().prepare('SELECT * FROM mikser_auth_codes WHERE code = ?').get(code)
68
+ }
69
+
70
+ // Single-use, enforced by the UPDATE's own WHERE rather than by a read
71
+ // followed by a write: two simultaneous redemptions of the same code both
72
+ // pass a prior SELECT, and only one can win this.
73
+ export function redeemCode(code) {
74
+ const result = db()
75
+ .prepare('UPDATE mikser_auth_codes SET used_at = ? WHERE code = ? AND used_at IS NULL')
76
+ .run(Date.now(), code)
77
+ return result.changes === 1
78
+ }
79
+
80
+ export function createRefreshToken({ clientId, subject, ttlSec }) {
81
+ const token = opaqueToken()
82
+ db().prepare(`
83
+ INSERT INTO mikser_auth_refresh (token, client_id, subject, expires_at)
84
+ VALUES (?, ?, ?, ?)
85
+ `).run(token, clientId, subject, Date.now() + ttlSec * 1000)
86
+ return token
87
+ }
88
+
89
+ export function getRefreshToken(token) {
90
+ return db().prepare('SELECT * FROM mikser_auth_refresh WHERE token = ?').get(token)
91
+ }
92
+
93
+ // Same race-safe shape as redeemCode. Rotation revokes BEFORE minting the
94
+ // replacement, so losing the race means creating nothing at all rather than
95
+ // leaving a valid token nobody holds.
96
+ export function revokeRefreshToken(token) {
97
+ const result = db()
98
+ .prepare('UPDATE mikser_auth_refresh SET revoked_at = ? WHERE token = ? AND revoked_at IS NULL')
99
+ .run(Date.now(), token)
100
+ return result.changes === 1
101
+ }
102
+
103
+ // Everything a subject holds, for a sign-out-everywhere. Also what an
104
+ // operator gets implicitly by deleting the user from users.htpasswd —
105
+ // except that revoking here is immediate, where an htpasswd edit only
106
+ // stops the NEXT login and leaves live access tokens valid until they
107
+ // expire.
108
+ export function revokeAllForSubject(subject) {
109
+ return db()
110
+ .prepare('UPDATE mikser_auth_refresh SET revoked_at = ? WHERE subject = ? AND revoked_at IS NULL')
111
+ .run(Date.now(), subject).changes
112
+ }
113
+
114
+ export function sweepExpired() {
115
+ const now = Date.now()
116
+ const codes = db().prepare('DELETE FROM mikser_auth_codes WHERE expires_at < ?').run(now - 60_000).changes
117
+ const refresh = db().prepare('DELETE FROM mikser_auth_refresh WHERE expires_at < ?').run(now).changes
118
+ return { codes, refresh }
119
+ }
120
+
121
+ // ── self-registered clients ─────────────────────────────────────────────
122
+
123
+ export function countDynamicClients() {
124
+ return db().prepare('SELECT COUNT(*) AS n FROM mikser_auth_clients').get().n
125
+ }
126
+
127
+ export function insertDynamicClient({ clientId, name, redirectUris }) {
128
+ const createdAt = Date.now()
129
+ db().prepare(`
130
+ INSERT INTO mikser_auth_clients (client_id, name, redirect_uris, created_at)
131
+ VALUES (?, ?, ?, ?)
132
+ `).run(clientId, name, JSON.stringify(redirectUris), createdAt)
133
+ return { clientId, name, redirectUris, createdAt }
134
+ }
135
+
136
+ export function getDynamicClient(clientId) {
137
+ const row = db().prepare('SELECT * FROM mikser_auth_clients WHERE client_id = ?').get(clientId)
138
+ if (!row) return null
139
+ return {
140
+ clientId: row.client_id,
141
+ name: row.name,
142
+ redirectUris: JSON.parse(row.redirect_uris),
143
+ createdAt: row.created_at,
144
+ lastUsedAt: row.last_used_at,
145
+ dynamic: true,
146
+ }
147
+ }
148
+
149
+ export function touchClient(clientId) {
150
+ db().prepare('UPDATE mikser_auth_clients SET last_used_at = ? WHERE client_id = ?')
151
+ .run(Date.now(), clientId)
152
+ }
153
+
154
+ // DCR has no "get or create" — every registration mints a NEW client_id, so
155
+ // a reinstall, a cleared cache or a second machine each leave another row
156
+ // behind. Prune the ones nobody ever signed in with.
157
+ export function pruneUnusedClients({ olderThanMs }) {
158
+ return db().prepare(`
159
+ DELETE FROM mikser_auth_clients
160
+ WHERE last_used_at IS NULL AND created_at < ?
161
+ `).run(Date.now() - olderThanMs).changes
162
+ }
@@ -0,0 +1,253 @@
1
+ import { createHash, timingSafeEqual, randomBytes } from 'node:crypto'
2
+ import { readFile, stat } from 'node:fs/promises'
3
+ import bcrypt from 'bcryptjs'
4
+
5
+ // Apache-format identity, per ADR-0012.
6
+ //
7
+ // users.htpasswd alice:$2y$10$…
8
+ // groups.htgroup editors: alice bob
9
+ //
10
+ // Read-only, always. Mikser provisions these files; it never writes them.
11
+ // The moment a running server writes an htpasswd file it has a locking
12
+ // problem and a database it won't admit to.
13
+
14
+ // One `user:hash` per line. `#` comments and blank lines are skipped, and a
15
+ // hash containing `:` (crypt output never does, but be exact) survives
16
+ // because only the FIRST colon separates.
17
+ export function parseHtpasswd(text) {
18
+ const users = new Map()
19
+ for (const raw of text.split('\n')) {
20
+ const line = raw.trim()
21
+ if (!line || line.startsWith('#')) continue
22
+ const i = line.indexOf(':')
23
+ if (i < 1) continue
24
+ users.set(line.slice(0, i), line.slice(i + 1))
25
+ }
26
+ return users
27
+ }
28
+
29
+ // `groupname: user1 user2`. Apache allows a group to be repeated across
30
+ // lines, so members accumulate rather than replace.
31
+ export function parseHtgroup(text) {
32
+ const groups = new Map()
33
+ for (const raw of text.split('\n')) {
34
+ const line = raw.trim()
35
+ if (!line || line.startsWith('#')) continue
36
+ const i = line.indexOf(':')
37
+ if (i < 1) continue
38
+ const name = line.slice(0, i).trim()
39
+ const members = line.slice(i + 1).trim().split(/\s+/).filter(Boolean)
40
+ const set = groups.get(name) ?? new Set()
41
+ for (const m of members) set.add(m)
42
+ groups.set(name, set)
43
+ }
44
+ return groups
45
+ }
46
+
47
+ // Apache's own MD5 variant ($apr1$). Supported because `htpasswd` emits it
48
+ // by default on some platforms and an operator shouldn't have to care —
49
+ // but it is MD5-based and weak. Prefer bcrypt (`htpasswd -B`).
50
+ function apr1(password, salt) {
51
+ const pw = Buffer.from(password, 'utf8')
52
+ const sa = Buffer.from(salt, 'utf8')
53
+
54
+ // The inner digest, folded into the outer one password-length bytes at
55
+ // a time, then one bit per bit of the password length: NUL for a 1-bit,
56
+ // the password's first byte for a 0-bit. That last step is the part
57
+ // every reimplementation gets backwards.
58
+ const inner = createHash('md5').update(pw).update(sa).update(pw).digest()
59
+
60
+ const ctx = createHash('md5').update(pw).update('$apr1$').update(sa)
61
+ for (let i = pw.length; i > 0; i -= 16) ctx.update(inner.subarray(0, Math.min(16, i)))
62
+ for (let i = pw.length; i !== 0; i >>= 1) {
63
+ ctx.update(i & 1 ? Buffer.from([0]) : pw.subarray(0, 1))
64
+ }
65
+ let final = ctx.digest()
66
+
67
+ // 1000 rounds of deliberate slowness — by 2026 standards, not nearly
68
+ // enough. This format is here for compatibility, not because it's good.
69
+ for (let i = 0; i < 1000; i++) {
70
+ const round = createHash('md5')
71
+ round.update(i & 1 ? pw : final)
72
+ if (i % 3) round.update(sa)
73
+ if (i % 7) round.update(pw)
74
+ round.update(i & 1 ? final : pw)
75
+ final = round.digest()
76
+ }
77
+
78
+ // Apache's own base64 alphabet, and its own byte order.
79
+ const ITOA = './0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
80
+ let out = ''
81
+ for (const [a, b, c] of [[0, 6, 12], [1, 7, 13], [2, 8, 14], [3, 9, 15], [4, 10, 5]]) {
82
+ let v = (final[a] << 16) | (final[b] << 8) | final[c]
83
+ for (let i = 0; i < 4; i++) { out += ITOA[v & 0x3f]; v >>= 6 }
84
+ }
85
+ let v = final[11]
86
+ for (let i = 0; i < 2; i++) { out += ITOA[v & 0x3f]; v >>= 6 }
87
+ return `$apr1$${salt}$${out}`
88
+ }
89
+
90
+ function safeEqual(a, b) {
91
+ const ba = Buffer.from(a, 'utf8')
92
+ const bb = Buffer.from(b, 'utf8')
93
+ return ba.length === bb.length && timingSafeEqual(ba, bb)
94
+ }
95
+
96
+ // Verify a plaintext password against one htpasswd hash.
97
+ //
98
+ // bcrypt ($2a/$2b/$2y) is the only format here worth trusting; the others
99
+ // exist because `htpasswd` can produce them and an operator's existing file
100
+ // should keep working. $2y is PHP's prefix for a hash that is byte-identical
101
+ // to $2a — bcryptjs doesn't know it, so it's rewritten before comparison.
102
+ export function verifyPassword(hash, password) {
103
+ if (!hash || typeof password !== 'string') return false
104
+
105
+ if (/^\$2[aby]\$/.test(hash)) {
106
+ try {
107
+ return bcrypt.compareSync(password, hash.replace(/^\$2y\$/, '$2a$'))
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ if (hash.startsWith('$apr1$')) {
114
+ const salt = hash.split('$')[2] ?? ''
115
+ return safeEqual(hash, apr1(password, salt))
116
+ }
117
+
118
+ if (hash.startsWith('{SHA}')) {
119
+ const digest = createHash('sha1').update(password, 'utf8').digest('base64')
120
+ return safeEqual(hash, `{SHA}${digest}`)
121
+ }
122
+
123
+ if (hash.startsWith('$2') || hash.startsWith('$1$') || hash.length === 13) {
124
+ // MD5-crypt and DES-crypt. Deliberately unsupported rather than
125
+ // half-supported: both are broken, and silently rejecting is safer
126
+ // than a subtly wrong implementation that accepts something.
127
+ return false
128
+ }
129
+
130
+ // A plaintext htpasswd file (Windows/Netware Apache) — compared in
131
+ // constant time, still a terrible idea, still someone's existing file.
132
+ return safeEqual(hash, password)
133
+ }
134
+
135
+ // The store. Files are re-read when their mtime moves, so an operator who
136
+ // edits users.htpasswd sees it take effect on the next request rather than
137
+ // on the next deploy — which is the whole reason identity lives in files.
138
+ export function createIdentityStore({ usersFile, groupsFile, groups = {}, scopes = {}, logger } = {}) {
139
+ let cache = { users: new Map(), members: new Map(), stamp: null }
140
+
141
+ async function mtime(file) {
142
+ if (!file) return null
143
+ try {
144
+ return (await stat(file)).mtimeMs
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
150
+ async function load() {
151
+ const stamp = `${await mtime(usersFile)}:${await mtime(groupsFile)}`
152
+ if (stamp === cache.stamp) return cache
153
+
154
+ let users = new Map()
155
+ try {
156
+ users = parseHtpasswd(await readFile(usersFile, 'utf8'))
157
+ } catch (err) {
158
+ if (err.code !== 'ENOENT') throw err
159
+ logger?.warn?.('auth: no users file at %s — every login will be refused', usersFile)
160
+ }
161
+
162
+ let members = new Map()
163
+ if (groupsFile) {
164
+ try {
165
+ members = parseHtgroup(await readFile(groupsFile, 'utf8'))
166
+ } catch (err) {
167
+ if (err.code !== 'ENOENT') throw err
168
+ }
169
+ }
170
+
171
+ cache = { users, members, stamp }
172
+ logger?.debug?.('auth: loaded %d user(s), %d group(s)', users.size, members.size)
173
+ return cache
174
+ }
175
+
176
+ // Is this deployment using capabilities at all?
177
+ const capabilitiesConfigured = Object.keys(groups).length > 0
178
+
179
+ // Which capabilities does this user hold, via group membership?
180
+ //
181
+ // With NO capability map configured the answer is null — "not
182
+ // capability-scoped" — which is exactly what a bare static token
183
+ // reports, and means the endpoint's own `operations` list is the only
184
+ // limit. Returning [] instead would grant nothing to anybody, so the
185
+ // simplest possible setup (a users file and nothing else) would
186
+ // authenticate people and then refuse them everything.
187
+ //
188
+ // Once a map EXISTS, a user whose groups grant nothing gets [] — an
189
+ // explicit "no verbs", because at that point the operator is using
190
+ // capabilities and silence means denial rather than absence.
191
+ function capabilitiesFor(username, members) {
192
+ if (!capabilitiesConfigured) return null
193
+ const caps = new Set()
194
+ for (const [group, users] of members) {
195
+ if (!users.has(username)) continue
196
+ for (const cap of groups[group] ?? []) caps.add(cap)
197
+ }
198
+ return [...caps]
199
+ }
200
+
201
+ // Which ROWS may this user see? A per-group sift filter, combined with
202
+ // $or across the groups they belong to — capabilities union, and so does
203
+ // reach: being in two groups shows you the union of what each group
204
+ // sees, not the intersection (which would make every extra group make a
205
+ // user LESS able, and is never what an operator means).
206
+ //
207
+ // No matching group with a scope → null, meaning "unscoped": the
208
+ // endpoint's own query is the only limit. That is the pre-existing
209
+ // behaviour for every credential that carries no scope.
210
+ function scopeFor(username, members) {
211
+ const filters = []
212
+ for (const [group, users] of members) {
213
+ if (!users.has(username)) continue
214
+ const filter = scopes[group]
215
+ if (filter) filters.push(filter)
216
+ }
217
+ if (!filters.length) return null
218
+ return filters.length === 1 ? filters[0] : { $or: filters }
219
+ }
220
+
221
+ return {
222
+ async authenticate(username, password) {
223
+ const { users, members } = await load()
224
+ const hash = users.get(username)
225
+ if (!hash) {
226
+ // Spend the time anyway: returning early on an unknown user
227
+ // makes username enumeration a timing measurement.
228
+ verifyPassword('$2a$10$' + randomBytes(16).toString('base64url').slice(0, 53), password)
229
+ return null
230
+ }
231
+ if (!verifyPassword(hash, password)) return null
232
+ return {
233
+ subject: username,
234
+ groups: [...members].filter(([, u]) => u.has(username)).map(([g]) => g),
235
+ capabilities: capabilitiesFor(username, members),
236
+ scope: scopeFor(username, members),
237
+ }
238
+ },
239
+ async groupsOf(username) {
240
+ const { members } = await load()
241
+ return [...members].filter(([, u]) => u.has(username)).map(([g]) => g)
242
+ },
243
+ async capabilitiesOf(username) {
244
+ const { members } = await load()
245
+ return capabilitiesFor(username, members)
246
+ },
247
+ async scopeOf(username) {
248
+ const { members } = await load()
249
+ return scopeFor(username, members)
250
+ },
251
+ async reload() { cache = { ...cache, stamp: null }; return load() },
252
+ }
253
+ }
package/lib/keys.js ADDED
@@ -0,0 +1,64 @@
1
+ import { readFile, writeFile, chmod, mkdir } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { generateKeyPair, exportJWK, importJWK, calculateJwkThumbprint } from 'jose'
4
+
5
+ // The signing key lives in a file in the working folder, alongside the
6
+ // identity it signs for (ADR-0012). ES256 — the most broadly interoperable
7
+ // asymmetric alg for OAuth clients, and small enough that the key file
8
+ // stays readable.
9
+ //
10
+ // A key file rather than a generated-on-boot key because tokens have to
11
+ // survive a restart: regenerating on boot silently invalidates every token
12
+ // the moment the process cycles, which looks exactly like an intermittent
13
+ // auth bug and is miserable to diagnose.
14
+ export const ALG = 'ES256'
15
+
16
+ export async function loadOrCreateKey({ keyFile, logger }) {
17
+ let stored
18
+ try {
19
+ stored = JSON.parse(await readFile(keyFile, 'utf8'))
20
+ } catch (err) {
21
+ if (err.code !== 'ENOENT') {
22
+ throw new Error(`auth: key file ${keyFile} is unreadable or not JSON — ${err.message}`)
23
+ }
24
+ }
25
+
26
+ if (stored) {
27
+ const privateKey = await importJWK(stored.privateJwk, ALG)
28
+ const publicKey = await importJWK(stored.publicJwk, ALG)
29
+ return { privateKey, publicKey, publicJwk: stored.publicJwk, kid: stored.kid }
30
+ }
31
+
32
+ const { publicKey, privateKey } = await generateKeyPair(ALG, { extractable: true })
33
+ const privateJwk = await exportJWK(privateKey)
34
+ const publicJwk = await exportJWK(publicKey)
35
+ const kid = await calculateJwkThumbprint(publicJwk)
36
+ publicJwk.kid = kid
37
+ publicJwk.alg = ALG
38
+ publicJwk.use = 'sig'
39
+
40
+ await mkdir(path.dirname(keyFile), { recursive: true })
41
+ await writeFile(keyFile, JSON.stringify({ kid, alg: ALG, privateJwk, publicJwk }, null, 2), { mode: 0o600 })
42
+ // writeFile's mode is only applied on create; be explicit for the case
43
+ // where an empty file already existed with looser permissions.
44
+ await chmod(keyFile, 0o600).catch(() => {})
45
+
46
+ logger?.warn?.(
47
+ 'auth: generated a new signing key at %s (kid=%s). Back it up and keep it out of version control — ' +
48
+ 'losing it invalidates every issued token; leaking it lets anyone mint one.',
49
+ keyFile, kid)
50
+
51
+ return { privateKey, publicKey, publicJwk, kid }
52
+ }
53
+
54
+ // The JWKS document an OAuth client fetches to verify our tokens. Public
55
+ // half only — if a private component ever appears here, that is the whole
56
+ // system compromised, so it is asserted rather than trusted.
57
+ export function jwks({ publicJwk }) {
58
+ for (const secret of ['d', 'p', 'q', 'dp', 'dq', 'qi', 'k']) {
59
+ if (secret in publicJwk) {
60
+ throw new Error(`auth: refusing to publish a JWKS containing the private component "${secret}"`)
61
+ }
62
+ }
63
+ return { keys: [publicJwk] }
64
+ }
@@ -0,0 +1,116 @@
1
+ const escapeHtml = (value) => String(value)
2
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
3
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;')
4
+
5
+ // The sign-in page.
6
+ //
7
+ // Deliberately identical to WhiteBox's — same layout, same type scale, same
8
+ // tokens, same pending state — because mikser and WhiteBox are the same
9
+ // company's products and a person who administers both should not have to
10
+ // wonder whether they are on the right one. The mark is the only difference.
11
+ //
12
+ // Two things the page must say, and WhiteBox learned the second the hard way:
13
+ //
14
+ // appName — WHICH deployment. A page showing only a logo could be any
15
+ // mikser, or a convincing copy of one.
16
+ // client — WHO gets your access. Without it, signing in to your own site
17
+ // and handing an agent your permissions looked identical.
18
+ export function loginPage({ params, client, appName, logoUrl, error }) {
19
+ const hidden = Object.entries(params)
20
+ .filter(([, v]) => v != null)
21
+ .map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
22
+ .join('\n')
23
+
24
+ const subtitle = client
25
+ ? `<p class="sub">to give <strong>${escapeHtml(client.name || client.clientId)}</strong> access</p>`
26
+ : ''
27
+
28
+ const notice = error ? `<p class="err">${escapeHtml(error)}</p>` : ''
29
+
30
+ return `<!doctype html>
31
+ <html lang="en"><head><meta charset="utf-8">
32
+ <meta name="viewport" content="width=device-width,initial-scale=1">
33
+ <title>Sign in</title>
34
+ <style>
35
+ /* Values measured off WhiteBox's running console login, not copied from its
36
+ stylesheet fallbacks — its PrimeVue theme overrides --accent, --text and
37
+ --radius at runtime, so the source would have produced a page that looked
38
+ adjacent to it rather than identical.
39
+ Light only, also deliberately: WhiteBox's login stays light under
40
+ prefers-color-scheme: dark, so a dark block here would create exactly the
41
+ mismatch it looks like it prevents. */
42
+ :root{
43
+ --bg:#f1f5f9; --panel:#fff; --border:#e2e8f0; --border-2:#cbd5e1;
44
+ --text:#334155; --text-strong:#0f172a; --accent:#09090b;
45
+ --radius:6px; --shadow:0 6px 18px rgba(15,23,42,.10);
46
+ }
47
+ *{box-sizing:border-box}
48
+ body{
49
+ margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg);
50
+ color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
51
+ }
52
+ form{
53
+ width:320px; padding:32px; display:flex; flex-direction:column; align-items:center; gap:10px;
54
+ background:var(--panel); border:1px solid var(--border);
55
+ border-radius:var(--radius); box-shadow:var(--shadow);
56
+ }
57
+ img{width:36px;height:36px;margin-bottom:2px}
58
+ h1{font-size:17px;font-weight:700;margin:0 0 6px;color:var(--text-strong)}
59
+ .sub{margin:-4px 0 4px;font-size:13px;text-align:center;opacity:.8}
60
+ .sub strong{color:var(--text-strong);font-weight:600}
61
+ .err{
62
+ width:100%; margin:0 0 2px; padding:8px 10px; border-radius:8px; font-size:13px;
63
+ background:#fef2f2; border:1px solid #fecaca; color:#b91c1c; text-align:center;
64
+ }
65
+ /* 8px on the fields against the card's 6px is WhiteBox's own combination, not a slip. */
66
+ input{
67
+ width:100%; padding:9px 10px; border:1px solid var(--border-2); border-radius:8px;
68
+ font-size:14px; background:var(--panel); color:var(--text);
69
+ }
70
+ input:focus{outline:2px solid color-mix(in srgb,var(--accent) 25%,transparent);border-color:var(--accent)}
71
+ button{
72
+ width:100%; margin-top:6px; padding:9px; border:none; border-radius:8px;
73
+ background:var(--accent); color:#fff; font-size:14px; font-weight:500; cursor:pointer;
74
+ }
75
+ button:hover{opacity:.92}
76
+ button[disabled]{opacity:.65;cursor:default}
77
+ </style>
78
+ </head><body>
79
+ <form method="post">
80
+ ${hidden}
81
+ <img src="${escapeHtml(logoUrl)}" alt="" onerror="this.remove()">
82
+ <h1>Sign in${appName ? ` to ${escapeHtml(appName)}` : ''}</h1>
83
+ ${subtitle}
84
+ ${notice}
85
+ <!-- autocomplete hints are load-bearing: without them a password manager
86
+ guesses, and a saved credential for a different app on the same host
87
+ gets filled instead. -->
88
+ <input type="text" name="username" placeholder="Username" autocomplete="username" required autofocus>
89
+ <input type="password" name="password" placeholder="Password" autocomplete="current-password" required>
90
+ <button type="submit">Sign in</button>
91
+ </form>
92
+ <!-- Feedback while the POST is in flight, which is longer than it looks:
93
+ bcrypt is deliberately slow, that being the point of a KDF, plus a round
94
+ trip and a redirect. Without this the button does not move, the page
95
+ looks inert, and the honest reading is "nothing happened" — so people
96
+ click again.
97
+ Progressive enhancement: with JS blocked the form still submits exactly
98
+ as before, it just has no pending state. -->
99
+ <script>
100
+ (function () {
101
+ var form = document.querySelector('form')
102
+ var button = form.querySelector('button')
103
+ form.addEventListener('submit', function () {
104
+ // Runs AFTER the form data is collected, so disabling here cannot drop a
105
+ // field. The button carries no name or value, so it has nothing of its
106
+ // own to lose either.
107
+ button.disabled = true
108
+ button.textContent = 'Signing in…'
109
+ form.setAttribute('aria-busy', 'true')
110
+ })
111
+ })()
112
+ </script>
113
+ </body></html>`
114
+ }
115
+
116
+ export { escapeHtml }
package/lib/pkce.js ADDED
@@ -0,0 +1,23 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
2
+
3
+ // PKCE (RFC 7636), S256 only. `plain` exists in the spec for clients that
4
+ // cannot compute SHA-256, which describes nothing that will ever talk to a
5
+ // mikser build server — and accepting it would let anyone who captured the
6
+ // authorization request replay the code without ever holding the verifier.
7
+
8
+ const base64url = (buf) => buf.toString('base64url')
9
+
10
+ export function challengeFromVerifier(verifier) {
11
+ return base64url(createHash('sha256').update(verifier).digest())
12
+ }
13
+
14
+ export function verifyPkce(verifier, challenge) {
15
+ if (!verifier || !challenge) return false
16
+ const a = Buffer.from(challengeFromVerifier(verifier), 'utf8')
17
+ const b = Buffer.from(challenge, 'utf8')
18
+ return a.length === b.length && timingSafeEqual(a, b)
19
+ }
20
+
21
+ // Opaque, unguessable, and URL-safe: authorization codes and refresh tokens
22
+ // are bearer strings, so entropy is the only thing protecting them.
23
+ export const opaqueToken = () => base64url(randomBytes(32))