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/README.md +249 -0
- package/assets/logo.svg +10 -0
- package/index.js +232 -0
- package/lib/clients.js +117 -0
- package/lib/grants.js +162 -0
- package/lib/htpasswd.js +253 -0
- package/lib/keys.js +64 -0
- package/lib/login-page.js +116 -0
- package/lib/pkce.js +23 -0
- package/lib/routes.js +314 -0
- package/lib/tokens.js +46 -0
- package/lib/verifiers.js +100 -0
- package/package.json +39 -0
- package/test/authorize.test.js +451 -0
- package/test/clients.test.js +83 -0
- package/test/identity.test.js +238 -0
- package/test/seam.test.js +164 -0
- package/test/verifiers.test.js +202 -0
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
|
+
}
|
package/lib/htpasswd.js
ADDED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
3
|
+
.replace(/"/g, '"').replace(/'/g, ''')
|
|
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))
|