dsh-plugin-auth 0.1.2
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 +205 -0
- package/bin/dsh-auth.js +179 -0
- package/cordis.patch.yml +26 -0
- package/package.json +32 -0
- package/src/gate.js +127 -0
- package/src/index.js +267 -0
- package/src/lockout.js +79 -0
- package/src/login-page.js +180 -0
- package/src/passwords.js +112 -0
- package/src/paths.js +41 -0
- package/src/policy.js +103 -0
- package/src/sessions.js +130 -0
- package/src/users.js +129 -0
package/src/policy.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Auth policy: enterprise defaults merged with an optional
|
|
4
|
+
* $DSH_HOME/auth/config.json override object. Pure module — it performs no I/O
|
|
5
|
+
* (the caller reads the file and passes the parsed object) and imports no peer
|
|
6
|
+
* packages, so it runs under `node --test` standalone.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} ScryptParams
|
|
11
|
+
* @property {number} N CPU/memory cost (power of two).
|
|
12
|
+
* @property {number} r Block size.
|
|
13
|
+
* @property {number} p Parallelization.
|
|
14
|
+
* @property {number} keylen Derived key length in bytes.
|
|
15
|
+
* @property {number} maxmem scrypt memory ceiling in bytes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} AuthPolicy
|
|
20
|
+
* @property {number} sessionAbsoluteTtlMs Hard session lifetime cap.
|
|
21
|
+
* @property {number} sessionIdleTtlMs Sliding idle window.
|
|
22
|
+
* @property {number} sweepIntervalMs Background expired-session sweep cadence.
|
|
23
|
+
* @property {number} lockoutThreshold Consecutive failures before lockout.
|
|
24
|
+
* @property {number} lockoutBaseMs First lockout duration.
|
|
25
|
+
* @property {number} lockoutMaxMs Cap for exponential backoff.
|
|
26
|
+
* @property {number} lockoutWindowMs Idle time after which the counter resets.
|
|
27
|
+
* @property {number} minPasswordLength Minimum acceptable password length.
|
|
28
|
+
* @property {boolean} secure true behind TLS: sets Secure + __Host- cookie.
|
|
29
|
+
* @property {'Strict'|'Lax'} sameSite Cookie SameSite attribute.
|
|
30
|
+
* @property {string} cookiePath Cookie Path attribute.
|
|
31
|
+
* @property {string[]} trustedOrigins Extra allowed Origin values for POST.
|
|
32
|
+
* @property {ScryptParams} scrypt Password hashing cost parameters.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** @type {AuthPolicy} */
|
|
36
|
+
export const DEFAULT_POLICY = {
|
|
37
|
+
sessionAbsoluteTtlMs: 12 * 60 * 60 * 1000,
|
|
38
|
+
sessionIdleTtlMs: 2 * 60 * 60 * 1000,
|
|
39
|
+
sweepIntervalMs: 5 * 60 * 1000,
|
|
40
|
+
lockoutThreshold: 5,
|
|
41
|
+
lockoutBaseMs: 30 * 1000,
|
|
42
|
+
lockoutMaxMs: 15 * 60 * 1000,
|
|
43
|
+
lockoutWindowMs: 15 * 60 * 1000,
|
|
44
|
+
minPasswordLength: 12,
|
|
45
|
+
secure: false,
|
|
46
|
+
sameSite: 'Strict',
|
|
47
|
+
cookiePath: '/',
|
|
48
|
+
trustedOrigins: [],
|
|
49
|
+
scrypt: { N: 16384, r: 8, p: 1, keylen: 64, maxmem: 64 * 1024 * 1024 },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Merge a partial override object onto the defaults with light validation.
|
|
54
|
+
* Unknown keys are ignored; out-of-range values fall back to the default so a
|
|
55
|
+
* malformed config file can never weaken the gate silently past sane bounds.
|
|
56
|
+
* @param {Partial<AuthPolicy>} [overrides]
|
|
57
|
+
* @returns {AuthPolicy}
|
|
58
|
+
*/
|
|
59
|
+
export function resolvePolicy(overrides = {}) {
|
|
60
|
+
const src = overrides && typeof overrides === 'object' ? overrides : {}
|
|
61
|
+
/** @type {AuthPolicy} */
|
|
62
|
+
const out = { ...DEFAULT_POLICY, scrypt: { ...DEFAULT_POLICY.scrypt }, trustedOrigins: [...DEFAULT_POLICY.trustedOrigins] }
|
|
63
|
+
/** @type {(v: unknown, d: number, opts?: {min?: number}) => number} */
|
|
64
|
+
const num = (v, d, { min = 1 } = {}) => (typeof v === 'number' && Number.isFinite(v) && v >= min ? v : d)
|
|
65
|
+
|
|
66
|
+
out.sessionAbsoluteTtlMs = num(src.sessionAbsoluteTtlMs, out.sessionAbsoluteTtlMs)
|
|
67
|
+
out.sessionIdleTtlMs = num(src.sessionIdleTtlMs, out.sessionIdleTtlMs)
|
|
68
|
+
out.sweepIntervalMs = num(src.sweepIntervalMs, out.sweepIntervalMs)
|
|
69
|
+
out.lockoutThreshold = num(src.lockoutThreshold, out.lockoutThreshold)
|
|
70
|
+
out.lockoutBaseMs = num(src.lockoutBaseMs, out.lockoutBaseMs)
|
|
71
|
+
out.lockoutMaxMs = num(src.lockoutMaxMs, out.lockoutMaxMs)
|
|
72
|
+
out.lockoutWindowMs = num(src.lockoutWindowMs, out.lockoutWindowMs)
|
|
73
|
+
out.minPasswordLength = num(src.minPasswordLength, out.minPasswordLength, { min: 8 })
|
|
74
|
+
out.secure = typeof src.secure === 'boolean' ? src.secure : out.secure
|
|
75
|
+
out.sameSite = src.sameSite === 'Lax' ? 'Lax' : 'Strict'
|
|
76
|
+
out.cookiePath = typeof src.cookiePath === 'string' && src.cookiePath.startsWith('/') ? src.cookiePath : out.cookiePath
|
|
77
|
+
out.trustedOrigins = Array.isArray(src.trustedOrigins)
|
|
78
|
+
? src.trustedOrigins.filter((o) => typeof o === 'string')
|
|
79
|
+
: out.trustedOrigins
|
|
80
|
+
|
|
81
|
+
if (src.scrypt && typeof src.scrypt === 'object') {
|
|
82
|
+
out.scrypt.N = num(src.scrypt.N, out.scrypt.N, { min: 2 })
|
|
83
|
+
out.scrypt.r = num(src.scrypt.r, out.scrypt.r)
|
|
84
|
+
out.scrypt.p = num(src.scrypt.p, out.scrypt.p)
|
|
85
|
+
out.scrypt.keylen = num(src.scrypt.keylen, out.scrypt.keylen, { min: 16 })
|
|
86
|
+
out.scrypt.maxmem = num(src.scrypt.maxmem, out.scrypt.maxmem, { min: 1024 * 1024 })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A __Host- prefixed cookie is only valid with Secure and Path=/; keep the
|
|
90
|
+
// policy self-consistent so cookieName() can key off `secure` alone.
|
|
91
|
+
if (out.secure) out.cookiePath = '/'
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Session cookie name. The __Host- prefix (browser-enforced: requires Secure,
|
|
97
|
+
* Path=/, and no Domain) is only used when running behind TLS.
|
|
98
|
+
* @param {Pick<AuthPolicy,'secure'>} policy
|
|
99
|
+
* @returns {string}
|
|
100
|
+
*/
|
|
101
|
+
export function cookieName(policy) {
|
|
102
|
+
return policy.secure ? '__Host-dsh_auth' : 'dsh_auth'
|
|
103
|
+
}
|
package/src/sessions.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* In-memory session store: 256-bit crypto-random tokens, an absolute TTL plus a
|
|
4
|
+
* sliding idle window, login-time token rotation (anti-fixation), explicit
|
|
5
|
+
* revoke, and a sweep. The clock is injectable so expiry is testable without
|
|
6
|
+
* real time. Pure module — sessions live only in this process, so a restart
|
|
7
|
+
* requires every user to log in again (an accepted in-scope tradeoff).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { randomBytes } from 'node:crypto'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} SessionRecord
|
|
14
|
+
* @property {string} username
|
|
15
|
+
* @property {number} createdAt
|
|
16
|
+
* @property {number} lastSeenAt
|
|
17
|
+
* @property {string} [ip]
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} opts
|
|
22
|
+
* @param {import('./policy.js').AuthPolicy} opts.policy
|
|
23
|
+
* @param {() => number} [opts.now] Injectable clock (defaults to Date.now).
|
|
24
|
+
*/
|
|
25
|
+
export function createSessionStore({ policy, now = Date.now } = /** @type {any} */ ({})) {
|
|
26
|
+
if (!policy) throw new Error('createSessionStore: policy required')
|
|
27
|
+
/** @type {Map<string, SessionRecord>} */
|
|
28
|
+
const store = new Map()
|
|
29
|
+
|
|
30
|
+
function newToken() {
|
|
31
|
+
let token
|
|
32
|
+
// 256 bits of entropy; the loop guards the astronomically unlikely clash.
|
|
33
|
+
do {
|
|
34
|
+
token = randomBytes(32).toString('base64url')
|
|
35
|
+
} while (store.has(token))
|
|
36
|
+
return token
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {SessionRecord} rec @param {number} t */
|
|
40
|
+
function fresh(rec, t) {
|
|
41
|
+
return t - rec.createdAt < policy.sessionAbsoluteTtlMs && t - rec.lastSeenAt < policy.sessionIdleTtlMs
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} username
|
|
46
|
+
* @param {{ip?: string}} [meta]
|
|
47
|
+
* @returns {string} the new session token.
|
|
48
|
+
*/
|
|
49
|
+
function create(username, meta = {}) {
|
|
50
|
+
const t = now()
|
|
51
|
+
const token = newToken()
|
|
52
|
+
store.set(token, { username, createdAt: t, lastSeenAt: t, ip: meta.ip })
|
|
53
|
+
return token
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate a token and, when valid, touch its idle timestamp (sliding window).
|
|
58
|
+
* Stale sessions are deleted eagerly. Returns a copy so callers cannot mutate
|
|
59
|
+
* the stored record.
|
|
60
|
+
* @param {string | undefined} token
|
|
61
|
+
* @returns {SessionRecord | undefined}
|
|
62
|
+
*/
|
|
63
|
+
function validate(token) {
|
|
64
|
+
if (typeof token !== 'string' || token.length === 0) return undefined
|
|
65
|
+
const rec = store.get(token)
|
|
66
|
+
if (!rec) return undefined
|
|
67
|
+
const t = now()
|
|
68
|
+
if (!fresh(rec, t)) {
|
|
69
|
+
store.delete(token)
|
|
70
|
+
return undefined
|
|
71
|
+
}
|
|
72
|
+
rec.lastSeenAt = t
|
|
73
|
+
return { ...rec }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Login rotation: mint a fresh token and drop the presented one, so a token
|
|
78
|
+
* observed pre-login cannot be reused post-login (session fixation defense).
|
|
79
|
+
* @param {string | undefined} oldToken
|
|
80
|
+
* @param {string} username
|
|
81
|
+
* @param {{ip?: string}} [meta]
|
|
82
|
+
* @returns {string}
|
|
83
|
+
*/
|
|
84
|
+
function rotate(oldToken, username, meta = {}) {
|
|
85
|
+
if (oldToken) store.delete(oldToken)
|
|
86
|
+
return create(username, meta)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string} token @returns {boolean} */
|
|
90
|
+
function revoke(token) {
|
|
91
|
+
return store.delete(token)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Drop every session belonging to a user (e.g. after disable / password change). */
|
|
95
|
+
function revokeUser(username) {
|
|
96
|
+
let n = 0
|
|
97
|
+
for (const [tok, rec] of store) {
|
|
98
|
+
if (rec.username === username) {
|
|
99
|
+
store.delete(tok)
|
|
100
|
+
n++
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return n
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Remove all stale sessions; returns the count reaped. */
|
|
107
|
+
function sweep() {
|
|
108
|
+
const t = now()
|
|
109
|
+
let n = 0
|
|
110
|
+
for (const [tok, rec] of store) {
|
|
111
|
+
if (!fresh(rec, t)) {
|
|
112
|
+
store.delete(tok)
|
|
113
|
+
n++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return n
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
create,
|
|
121
|
+
validate,
|
|
122
|
+
rotate,
|
|
123
|
+
revoke,
|
|
124
|
+
revokeUser,
|
|
125
|
+
sweep,
|
|
126
|
+
get size() {
|
|
127
|
+
return store.size
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/users.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* User credential store backed by a JSON file (default $DSH_HOME/auth/users.json).
|
|
4
|
+
* Writes are atomic (temp file + rename) and mode 0600. Pure of peer imports:
|
|
5
|
+
* the caller supplies the path, so tests point it at a temp file. The file is
|
|
6
|
+
* managed offline by the `dsh-auth` CLI, never through the Web settings UI.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFile, writeFile, rename, mkdir, chmod, unlink } from 'node:fs/promises'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
const FILE_VERSION = 1
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} UserRecord
|
|
17
|
+
* @property {string} username
|
|
18
|
+
* @property {boolean} [disabled]
|
|
19
|
+
* @property {import('./passwords.js').PasswordRecord} password
|
|
20
|
+
* @property {number} [createdAt]
|
|
21
|
+
* @property {number} [updatedAt]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{path: string}} opts Absolute path to the users JSON file.
|
|
26
|
+
*/
|
|
27
|
+
export function createUserStore({ path } = /** @type {any} */ ({})) {
|
|
28
|
+
if (!path) throw new Error('createUserStore: path required')
|
|
29
|
+
|
|
30
|
+
/** @returns {Promise<{version: number, users: Record<string, UserRecord>}>} */
|
|
31
|
+
async function load() {
|
|
32
|
+
let raw
|
|
33
|
+
try {
|
|
34
|
+
raw = await readFile(path, 'utf8')
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err && /** @type {any} */ (err).code === 'ENOENT') return { version: FILE_VERSION, users: {} }
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
let data
|
|
40
|
+
try {
|
|
41
|
+
data = JSON.parse(raw)
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(`auth: users file is not valid JSON: ${path}`)
|
|
44
|
+
}
|
|
45
|
+
if (!data || typeof data !== 'object' || typeof data.users !== 'object' || data.users === null) {
|
|
46
|
+
throw new Error(`auth: users file has an unexpected shape: ${path}`)
|
|
47
|
+
}
|
|
48
|
+
return { version: data.version ?? FILE_VERSION, users: data.users }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {{users: Record<string, UserRecord>}} db */
|
|
52
|
+
async function save(db) {
|
|
53
|
+
await mkdir(dirname(path), { recursive: true })
|
|
54
|
+
const tmp = join(dirname(path), `.users.${process.pid}.${randomBytes(6).toString('hex')}.tmp`)
|
|
55
|
+
const body = JSON.stringify({ version: FILE_VERSION, users: db.users }, null, 2)
|
|
56
|
+
await writeFile(tmp, body, { encoding: 'utf8', mode: 0o600 })
|
|
57
|
+
try {
|
|
58
|
+
await chmod(tmp, 0o600)
|
|
59
|
+
} catch {
|
|
60
|
+
/* best effort where full POSIX modes are unavailable (e.g. some Windows FS) */
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
await rename(tmp, path)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
try {
|
|
66
|
+
await unlink(tmp)
|
|
67
|
+
} catch {
|
|
68
|
+
/* ignore cleanup failure */
|
|
69
|
+
}
|
|
70
|
+
throw err
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await chmod(path, 0o600)
|
|
74
|
+
} catch {
|
|
75
|
+
/* best effort */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
load,
|
|
81
|
+
/** @param {string} username @returns {Promise<UserRecord | undefined>} */
|
|
82
|
+
async getUser(username) {
|
|
83
|
+
const db = await load()
|
|
84
|
+
return db.users[username]
|
|
85
|
+
},
|
|
86
|
+
/** @returns {Promise<UserRecord[]>} */
|
|
87
|
+
async listUsers() {
|
|
88
|
+
const db = await load()
|
|
89
|
+
return Object.values(db.users)
|
|
90
|
+
},
|
|
91
|
+
/**
|
|
92
|
+
* Create or update a user, merging over any existing record.
|
|
93
|
+
* @param {string} username
|
|
94
|
+
* @param {Partial<UserRecord>} record
|
|
95
|
+
* @returns {Promise<UserRecord>}
|
|
96
|
+
*/
|
|
97
|
+
async upsertUser(username, record) {
|
|
98
|
+
const db = await load()
|
|
99
|
+
const now = Date.now()
|
|
100
|
+
const prev = db.users[username]
|
|
101
|
+
db.users[username] = {
|
|
102
|
+
...prev,
|
|
103
|
+
...record,
|
|
104
|
+
username,
|
|
105
|
+
createdAt: prev?.createdAt ?? now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
}
|
|
108
|
+
await save(db)
|
|
109
|
+
return db.users[username]
|
|
110
|
+
},
|
|
111
|
+
/** @param {string} username @returns {Promise<boolean>} */
|
|
112
|
+
async removeUser(username) {
|
|
113
|
+
const db = await load()
|
|
114
|
+
if (!(username in db.users)) return false
|
|
115
|
+
delete db.users[username]
|
|
116
|
+
await save(db)
|
|
117
|
+
return true
|
|
118
|
+
},
|
|
119
|
+
/** @param {string} username @param {boolean} disabled @returns {Promise<boolean>} */
|
|
120
|
+
async setDisabled(username, disabled) {
|
|
121
|
+
const db = await load()
|
|
122
|
+
if (!(username in db.users)) return false
|
|
123
|
+
db.users[username].disabled = Boolean(disabled)
|
|
124
|
+
db.users[username].updatedAt = Date.now()
|
|
125
|
+
await save(db)
|
|
126
|
+
return true
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|