@jskit-ai/auth-provider-local-core 0.1.1
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/package.descriptor.mjs +102 -0
- package/package.json +18 -0
- package/src/server/lib/fileBackend.js +496 -0
- package/src/server/lib/index.js +2 -0
- package/src/server/lib/passwords.js +44 -0
- package/src/server/lib/service.js +689 -0
- package/src/server/lib/tokens.js +57 -0
- package/src/server/providers/AuthLocalServiceProvider.js +194 -0
- package/src/server/providers/AuthProviderServiceProvider.js +9 -0
- package/test/providerRuntime.test.js +505 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export default Object.freeze({
|
|
2
|
+
"packageVersion": 1,
|
|
3
|
+
"packageId": "@jskit-ai/auth-provider-local-core",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"kind": "runtime",
|
|
6
|
+
"description": "Local auth provider with a file backend default and no database requirement.",
|
|
7
|
+
"dependsOn": [
|
|
8
|
+
"@jskit-ai/auth-core",
|
|
9
|
+
"@jskit-ai/value-app-config-shared"
|
|
10
|
+
],
|
|
11
|
+
"capabilities": {
|
|
12
|
+
"provides": [
|
|
13
|
+
"auth.provider.local",
|
|
14
|
+
"auth.provider"
|
|
15
|
+
],
|
|
16
|
+
"requires": [
|
|
17
|
+
"auth.access"
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"runtime": {
|
|
21
|
+
"server": {
|
|
22
|
+
"providerEntrypoint": "src/server/providers/AuthLocalServiceProvider.js",
|
|
23
|
+
"providers": [
|
|
24
|
+
{
|
|
25
|
+
"entrypoint": "src/server/providers/AuthLocalServiceProvider.js",
|
|
26
|
+
"export": "AuthLocalServiceProvider"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"entrypoint": "src/server/providers/AuthProviderServiceProvider.js",
|
|
30
|
+
"export": "AuthProviderServiceProvider"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"metadata": {
|
|
36
|
+
"apiSummary": {
|
|
37
|
+
"surfaces": [
|
|
38
|
+
{
|
|
39
|
+
"subpath": "./server/providers/AuthLocalServiceProvider",
|
|
40
|
+
"summary": "Exports the local auth provider service provider."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"subpath": "./server/lib/index",
|
|
44
|
+
"summary": "Exports local auth service and file backend helpers."
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"containerTokens": {
|
|
48
|
+
"server": [
|
|
49
|
+
"authService",
|
|
50
|
+
"auth.local.backend"
|
|
51
|
+
],
|
|
52
|
+
"client": []
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"mutations": {
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"runtime": {
|
|
59
|
+
"@jskit-ai/auth-core": "0.1.100",
|
|
60
|
+
"@jskit-ai/kernel": "0.1.102",
|
|
61
|
+
"nodemailer": "^7.0.10",
|
|
62
|
+
"dotenv": "^16.4.5"
|
|
63
|
+
},
|
|
64
|
+
"dev": {}
|
|
65
|
+
},
|
|
66
|
+
"packageJson": {
|
|
67
|
+
"scripts": {}
|
|
68
|
+
},
|
|
69
|
+
"procfile": {},
|
|
70
|
+
"files": [],
|
|
71
|
+
"text": [
|
|
72
|
+
{
|
|
73
|
+
"file": ".env",
|
|
74
|
+
"op": "upsert-env",
|
|
75
|
+
"key": "AUTH_PROVIDER",
|
|
76
|
+
"value": "local",
|
|
77
|
+
"reason": "Select local auth as the auth provider.",
|
|
78
|
+
"category": "runtime-config",
|
|
79
|
+
"id": "auth-provider"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"file": ".env",
|
|
83
|
+
"op": "upsert-env",
|
|
84
|
+
"key": "AUTH_LOCAL_BACKEND",
|
|
85
|
+
"value": "file",
|
|
86
|
+
"reason": "Use the built-in local file auth backend.",
|
|
87
|
+
"category": "runtime-config",
|
|
88
|
+
"id": "auth-local-backend"
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"file": ".gitignore",
|
|
92
|
+
"op": "append-text",
|
|
93
|
+
"position": "bottom",
|
|
94
|
+
"skipIfContains": ".jskit/auth/",
|
|
95
|
+
"value": "\n.jskit/auth/\n",
|
|
96
|
+
"reason": "Ignore local auth runtime state.",
|
|
97
|
+
"category": "runtime-state",
|
|
98
|
+
"id": "auth-local-store-gitignore"
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jskit-ai/auth-provider-local-core",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "node --test"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./server/providers/AuthLocalServiceProvider": "./src/server/providers/AuthLocalServiceProvider.js",
|
|
10
|
+
"./server/providers/AuthProviderServiceProvider": "./src/server/providers/AuthProviderServiceProvider.js",
|
|
11
|
+
"./server/lib/index": "./src/server/lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@jskit-ai/auth-core": "0.1.100",
|
|
15
|
+
"@jskit-ai/kernel": "0.1.102",
|
|
16
|
+
"nodemailer": "^7.0.10"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const USER_FILE = "users.passwd";
|
|
5
|
+
const SESSION_FILE = "sessions.passwd";
|
|
6
|
+
const RECOVERY_FILE = "recovery.passwd";
|
|
7
|
+
const LOCK_FILE = "store.lock";
|
|
8
|
+
const JOURNAL_FILE = "transaction.journal";
|
|
9
|
+
const LOCK_STALE_MS = 30_000;
|
|
10
|
+
|
|
11
|
+
function nowIso() {
|
|
12
|
+
return new Date().toISOString();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function encodeField(value) {
|
|
16
|
+
return Buffer.from(String(value ?? ""), "utf8").toString("base64url");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function decodeField(value) {
|
|
20
|
+
return Buffer.from(String(value || ""), "base64url").toString("utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function splitLine(line, expectedType) {
|
|
24
|
+
const parts = String(line || "").split(":");
|
|
25
|
+
if (parts[0] !== expectedType || parts[1] !== "v1") {
|
|
26
|
+
throw new Error(`Invalid ${expectedType} record.`);
|
|
27
|
+
}
|
|
28
|
+
return parts;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseUsers(content) {
|
|
32
|
+
const users = [];
|
|
33
|
+
for (const line of String(content || "").split("\n").filter(Boolean)) {
|
|
34
|
+
const parts = splitLine(line, "user");
|
|
35
|
+
if (parts.length !== 12) {
|
|
36
|
+
throw new Error("Invalid user record field count.");
|
|
37
|
+
}
|
|
38
|
+
users.push({
|
|
39
|
+
id: parts[2],
|
|
40
|
+
email: decodeField(parts[3]),
|
|
41
|
+
displayName: decodeField(parts[4]),
|
|
42
|
+
password: {
|
|
43
|
+
algorithm: parts[5],
|
|
44
|
+
version: parts[6],
|
|
45
|
+
salt: parts[7],
|
|
46
|
+
hash: parts[8]
|
|
47
|
+
},
|
|
48
|
+
createdAt: decodeField(parts[9]),
|
|
49
|
+
updatedAt: decodeField(parts[10]),
|
|
50
|
+
disabled: parts[11] === "1"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return users;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function serializeUsers(users) {
|
|
57
|
+
return [...users]
|
|
58
|
+
.sort((left, right) => String(left.id).localeCompare(String(right.id)))
|
|
59
|
+
.map((user) =>
|
|
60
|
+
[
|
|
61
|
+
"user",
|
|
62
|
+
"v1",
|
|
63
|
+
user.id,
|
|
64
|
+
encodeField(user.email),
|
|
65
|
+
encodeField(user.displayName),
|
|
66
|
+
user.password.algorithm,
|
|
67
|
+
user.password.version,
|
|
68
|
+
user.password.salt,
|
|
69
|
+
user.password.hash,
|
|
70
|
+
encodeField(user.createdAt),
|
|
71
|
+
encodeField(user.updatedAt),
|
|
72
|
+
user.disabled ? "1" : "0"
|
|
73
|
+
].join(":")
|
|
74
|
+
)
|
|
75
|
+
.join("\n") + (users.length > 0 ? "\n" : "");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function parseSessions(content) {
|
|
79
|
+
const sessions = [];
|
|
80
|
+
for (const line of String(content || "").split("\n").filter(Boolean)) {
|
|
81
|
+
const parts = splitLine(line, "session");
|
|
82
|
+
if (parts.length !== 9) {
|
|
83
|
+
throw new Error("Invalid session record field count.");
|
|
84
|
+
}
|
|
85
|
+
sessions.push({
|
|
86
|
+
id: parts[2],
|
|
87
|
+
userId: parts[3],
|
|
88
|
+
tokenHash: parts[4],
|
|
89
|
+
purpose: parts[5] || "normal",
|
|
90
|
+
createdAt: decodeField(parts[6]),
|
|
91
|
+
expiresAt: decodeField(parts[7]),
|
|
92
|
+
revokedAt: decodeField(parts[8] || "")
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return sessions;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function serializeSessions(sessions) {
|
|
99
|
+
return [...sessions]
|
|
100
|
+
.sort((left, right) => String(left.id).localeCompare(String(right.id)))
|
|
101
|
+
.map((session) =>
|
|
102
|
+
[
|
|
103
|
+
"session",
|
|
104
|
+
"v1",
|
|
105
|
+
session.id,
|
|
106
|
+
session.userId,
|
|
107
|
+
session.tokenHash,
|
|
108
|
+
session.purpose || "normal",
|
|
109
|
+
encodeField(session.createdAt),
|
|
110
|
+
encodeField(session.expiresAt),
|
|
111
|
+
encodeField(session.revokedAt || "")
|
|
112
|
+
].join(":")
|
|
113
|
+
)
|
|
114
|
+
.join("\n") + (sessions.length > 0 ? "\n" : "");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseRecovery(content) {
|
|
118
|
+
const records = [];
|
|
119
|
+
for (const line of String(content || "").split("\n").filter(Boolean)) {
|
|
120
|
+
const parts = splitLine(line, "recovery");
|
|
121
|
+
if (parts.length !== 8) {
|
|
122
|
+
throw new Error("Invalid recovery record field count.");
|
|
123
|
+
}
|
|
124
|
+
records.push({
|
|
125
|
+
id: parts[2],
|
|
126
|
+
userId: parts[3],
|
|
127
|
+
tokenHash: parts[4],
|
|
128
|
+
createdAt: decodeField(parts[5]),
|
|
129
|
+
expiresAt: decodeField(parts[6]),
|
|
130
|
+
usedAt: decodeField(parts[7] || "")
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return records;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function serializeRecovery(records) {
|
|
137
|
+
return [...records]
|
|
138
|
+
.sort((left, right) => String(left.id).localeCompare(String(right.id)))
|
|
139
|
+
.map((record) =>
|
|
140
|
+
[
|
|
141
|
+
"recovery",
|
|
142
|
+
"v1",
|
|
143
|
+
record.id,
|
|
144
|
+
record.userId,
|
|
145
|
+
record.tokenHash,
|
|
146
|
+
encodeField(record.createdAt),
|
|
147
|
+
encodeField(record.expiresAt),
|
|
148
|
+
encodeField(record.usedAt || "")
|
|
149
|
+
].join(":")
|
|
150
|
+
)
|
|
151
|
+
.join("\n") + (records.length > 0 ? "\n" : "");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function readText(filePath) {
|
|
155
|
+
try {
|
|
156
|
+
return await fs.readFile(filePath, "utf8");
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (error && error.code === "ENOENT") {
|
|
159
|
+
return "";
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function writeAtomic(filePath, content) {
|
|
166
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
167
|
+
const handle = await fs.open(tmpPath, "w", 0o600);
|
|
168
|
+
try {
|
|
169
|
+
await handle.writeFile(content, "utf8");
|
|
170
|
+
await handle.sync();
|
|
171
|
+
} finally {
|
|
172
|
+
await handle.close();
|
|
173
|
+
}
|
|
174
|
+
await fs.rename(tmpPath, filePath);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function encodeJournalContent(content) {
|
|
178
|
+
return Buffer.from(String(content || ""), "utf8").toString("base64url");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function decodeJournalContent(content) {
|
|
182
|
+
return Buffer.from(String(content || ""), "base64url").toString("utf8");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildJournal(entries) {
|
|
186
|
+
return `${JSON.stringify({
|
|
187
|
+
version: 1,
|
|
188
|
+
files: entries.map((entry) => ({
|
|
189
|
+
name: entry.name,
|
|
190
|
+
content: encodeJournalContent(entry.content)
|
|
191
|
+
}))
|
|
192
|
+
})}\n`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function parseJournal(content) {
|
|
196
|
+
const parsed = JSON.parse(String(content || "{}"));
|
|
197
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.files)) {
|
|
198
|
+
throw new Error("Invalid local auth transaction journal.");
|
|
199
|
+
}
|
|
200
|
+
return parsed.files.map((entry) => {
|
|
201
|
+
const name = String(entry?.name || "").trim();
|
|
202
|
+
if (![USER_FILE, SESSION_FILE, RECOVERY_FILE].includes(name)) {
|
|
203
|
+
throw new Error("Invalid local auth transaction journal file.");
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
name,
|
|
207
|
+
content: decodeJournalContent(entry?.content)
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function writeCommitJournal(storeDir, entries) {
|
|
213
|
+
if (entries.length < 1) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
await writeAtomic(path.join(storeDir, JOURNAL_FILE), buildJournal(entries));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function replayCommitJournal(storeDir) {
|
|
220
|
+
const journalPath = path.join(storeDir, JOURNAL_FILE);
|
|
221
|
+
let journalContent;
|
|
222
|
+
try {
|
|
223
|
+
journalContent = await fs.readFile(journalPath, "utf8");
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (error?.code === "ENOENT") {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const entries = parseJournal(journalContent);
|
|
232
|
+
for (const entry of entries) {
|
|
233
|
+
await writeAtomic(path.join(storeDir, entry.name), entry.content);
|
|
234
|
+
}
|
|
235
|
+
await fs.rm(journalPath, { force: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function parseLockPid(content) {
|
|
239
|
+
const pid = Number(String(content || "").split(":")[0]);
|
|
240
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isProcessAlive(pid) {
|
|
244
|
+
if (!pid) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
process.kill(pid, 0);
|
|
249
|
+
return true;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return error?.code === "EPERM";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function removeStaleLock(lockPath) {
|
|
256
|
+
let stat;
|
|
257
|
+
try {
|
|
258
|
+
stat = await fs.stat(lockPath);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error?.code === "ENOENT") {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const isOld = Date.now() - Number(stat.mtimeMs || 0) > LOCK_STALE_MS;
|
|
267
|
+
if (!isOld) {
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const content = await readText(lockPath);
|
|
272
|
+
const pid = parseLockPid(content);
|
|
273
|
+
if (pid && isProcessAlive(pid)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await fs.rm(lockPath, { force: true });
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function acquireLock(storeDir, timeoutMs = 2000) {
|
|
282
|
+
const lockPath = path.join(storeDir, LOCK_FILE);
|
|
283
|
+
const startedAt = Date.now();
|
|
284
|
+
while (true) {
|
|
285
|
+
try {
|
|
286
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
287
|
+
await handle.writeFile(`${process.pid}:${new Date().toISOString()}\n`);
|
|
288
|
+
return async () => {
|
|
289
|
+
await handle.close();
|
|
290
|
+
await fs.rm(lockPath, { force: true });
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!error || error.code !== "EEXIST") {
|
|
294
|
+
throw new Error(`Could not acquire local auth store lock at ${lockPath}.`);
|
|
295
|
+
}
|
|
296
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
297
|
+
if (await removeStaleLock(lockPath)) {
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
throw new Error(`Could not acquire local auth store lock at ${lockPath}.`);
|
|
301
|
+
}
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function clone(value) {
|
|
308
|
+
return value == null ? value : JSON.parse(JSON.stringify(value));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function createTx(state, touched) {
|
|
312
|
+
function mark(name) {
|
|
313
|
+
touched.add(name);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return Object.freeze({
|
|
317
|
+
users: Object.freeze({
|
|
318
|
+
async create(input) {
|
|
319
|
+
if (state.users.some((user) => user.id === input.id || user.email === input.email)) {
|
|
320
|
+
throw new Error("Local auth user already exists.");
|
|
321
|
+
}
|
|
322
|
+
const user = {
|
|
323
|
+
...clone(input),
|
|
324
|
+
createdAt: input.createdAt || nowIso(),
|
|
325
|
+
updatedAt: input.updatedAt || nowIso(),
|
|
326
|
+
disabled: input.disabled === true
|
|
327
|
+
};
|
|
328
|
+
state.users.push(user);
|
|
329
|
+
mark("users");
|
|
330
|
+
return clone(user);
|
|
331
|
+
},
|
|
332
|
+
async findById(userId) {
|
|
333
|
+
return clone(state.users.find((user) => user.id === userId) || null);
|
|
334
|
+
},
|
|
335
|
+
async findByEmail(email) {
|
|
336
|
+
return clone(state.users.find((user) => user.email === email) || null);
|
|
337
|
+
},
|
|
338
|
+
async updatePassword(userId, passwordRecord) {
|
|
339
|
+
const user = state.users.find((entry) => entry.id === userId);
|
|
340
|
+
if (!user) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
user.password = clone(passwordRecord);
|
|
344
|
+
user.updatedAt = nowIso();
|
|
345
|
+
mark("users");
|
|
346
|
+
return clone(user);
|
|
347
|
+
},
|
|
348
|
+
async updateProfile(userId, updates = {}) {
|
|
349
|
+
const user = state.users.find((entry) => entry.id === userId);
|
|
350
|
+
if (!user) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
if (typeof updates.displayName === "string") {
|
|
354
|
+
user.displayName = updates.displayName;
|
|
355
|
+
}
|
|
356
|
+
user.updatedAt = nowIso();
|
|
357
|
+
mark("users");
|
|
358
|
+
return clone(user);
|
|
359
|
+
}
|
|
360
|
+
}),
|
|
361
|
+
sessions: Object.freeze({
|
|
362
|
+
async create(input) {
|
|
363
|
+
const session = {
|
|
364
|
+
...clone(input),
|
|
365
|
+
purpose: input.purpose || "normal",
|
|
366
|
+
createdAt: input.createdAt || nowIso(),
|
|
367
|
+
revokedAt: input.revokedAt || ""
|
|
368
|
+
};
|
|
369
|
+
state.sessions.push(session);
|
|
370
|
+
mark("sessions");
|
|
371
|
+
return clone(session);
|
|
372
|
+
},
|
|
373
|
+
async findById(sessionId) {
|
|
374
|
+
return clone(state.sessions.find((session) => session.id === sessionId) || null);
|
|
375
|
+
},
|
|
376
|
+
async findByTokenHash(tokenHash) {
|
|
377
|
+
return clone(state.sessions.find((session) => session.tokenHash === tokenHash) || null);
|
|
378
|
+
},
|
|
379
|
+
async revoke(sessionId) {
|
|
380
|
+
const session = state.sessions.find((entry) => entry.id === sessionId);
|
|
381
|
+
if (!session || session.revokedAt) {
|
|
382
|
+
return clone(session || null);
|
|
383
|
+
}
|
|
384
|
+
session.revokedAt = nowIso();
|
|
385
|
+
mark("sessions");
|
|
386
|
+
return clone(session);
|
|
387
|
+
},
|
|
388
|
+
async revokeForUser(userId, options = {}) {
|
|
389
|
+
const exceptSessionId = String(options.exceptSessionId || "");
|
|
390
|
+
let count = 0;
|
|
391
|
+
for (const session of state.sessions) {
|
|
392
|
+
if (session.userId === userId && session.id !== exceptSessionId && !session.revokedAt) {
|
|
393
|
+
session.revokedAt = nowIso();
|
|
394
|
+
count += 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (count > 0) {
|
|
398
|
+
mark("sessions");
|
|
399
|
+
}
|
|
400
|
+
return count;
|
|
401
|
+
}
|
|
402
|
+
}),
|
|
403
|
+
recovery: Object.freeze({
|
|
404
|
+
async create(input) {
|
|
405
|
+
const record = {
|
|
406
|
+
...clone(input),
|
|
407
|
+
createdAt: input.createdAt || nowIso(),
|
|
408
|
+
usedAt: input.usedAt || ""
|
|
409
|
+
};
|
|
410
|
+
state.recovery.push(record);
|
|
411
|
+
mark("recovery");
|
|
412
|
+
return clone(record);
|
|
413
|
+
},
|
|
414
|
+
async findByTokenHash(tokenHash) {
|
|
415
|
+
return clone(state.recovery.find((record) => record.tokenHash === tokenHash) || null);
|
|
416
|
+
},
|
|
417
|
+
async consume(tokenId) {
|
|
418
|
+
const record = state.recovery.find((entry) => entry.id === tokenId);
|
|
419
|
+
if (!record || record.usedAt) {
|
|
420
|
+
return clone(record || null);
|
|
421
|
+
}
|
|
422
|
+
record.usedAt = nowIso();
|
|
423
|
+
mark("recovery");
|
|
424
|
+
return clone(record);
|
|
425
|
+
},
|
|
426
|
+
async consumeForUser(userId) {
|
|
427
|
+
let count = 0;
|
|
428
|
+
for (const record of state.recovery) {
|
|
429
|
+
if (record.userId === userId && !record.usedAt) {
|
|
430
|
+
record.usedAt = nowIso();
|
|
431
|
+
count += 1;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (count > 0) {
|
|
435
|
+
mark("recovery");
|
|
436
|
+
}
|
|
437
|
+
return count;
|
|
438
|
+
}
|
|
439
|
+
})
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function createLocalFileBackend({ storeDir }) {
|
|
444
|
+
const resolvedStoreDir = path.resolve(String(storeDir || ".jskit/auth"));
|
|
445
|
+
|
|
446
|
+
async function withTransaction(callback) {
|
|
447
|
+
await fs.mkdir(resolvedStoreDir, { recursive: true, mode: 0o700 });
|
|
448
|
+
const release = await acquireLock(resolvedStoreDir);
|
|
449
|
+
try {
|
|
450
|
+
await replayCommitJournal(resolvedStoreDir);
|
|
451
|
+
const state = {
|
|
452
|
+
users: parseUsers(await readText(path.join(resolvedStoreDir, USER_FILE))),
|
|
453
|
+
sessions: parseSessions(await readText(path.join(resolvedStoreDir, SESSION_FILE))),
|
|
454
|
+
recovery: parseRecovery(await readText(path.join(resolvedStoreDir, RECOVERY_FILE)))
|
|
455
|
+
};
|
|
456
|
+
const touched = new Set();
|
|
457
|
+
const result = await callback(createTx(state, touched));
|
|
458
|
+
const commitEntries = [];
|
|
459
|
+
if (touched.has("users")) {
|
|
460
|
+
commitEntries.push({
|
|
461
|
+
name: USER_FILE,
|
|
462
|
+
content: serializeUsers(state.users)
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
if (touched.has("sessions")) {
|
|
466
|
+
commitEntries.push({
|
|
467
|
+
name: SESSION_FILE,
|
|
468
|
+
content: serializeSessions(state.sessions)
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
if (touched.has("recovery")) {
|
|
472
|
+
commitEntries.push({
|
|
473
|
+
name: RECOVERY_FILE,
|
|
474
|
+
content: serializeRecovery(state.recovery)
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
if (commitEntries.length > 0) {
|
|
478
|
+
await writeCommitJournal(resolvedStoreDir, commitEntries);
|
|
479
|
+
for (const entry of commitEntries) {
|
|
480
|
+
await writeAtomic(path.join(resolvedStoreDir, entry.name), entry.content);
|
|
481
|
+
}
|
|
482
|
+
await fs.rm(path.join(resolvedStoreDir, JOURNAL_FILE), { force: true });
|
|
483
|
+
}
|
|
484
|
+
return result;
|
|
485
|
+
} finally {
|
|
486
|
+
await release();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return Object.freeze({
|
|
491
|
+
storeDir: resolvedStoreDir,
|
|
492
|
+
withTransaction
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export { createLocalFileBackend };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { randomBytes, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const scrypt = promisify(scryptCallback);
|
|
5
|
+
const SCRYPT_ALGORITHM = "scrypt";
|
|
6
|
+
const SCRYPT_VERSION = "v1";
|
|
7
|
+
const SCRYPT_KEY_LENGTH = 64;
|
|
8
|
+
|
|
9
|
+
function base64url(buffer) {
|
|
10
|
+
return Buffer.from(buffer).toString("base64url");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fromBase64url(value) {
|
|
14
|
+
return Buffer.from(String(value || ""), "base64url");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function hashPassword(password) {
|
|
18
|
+
const salt = randomBytes(16);
|
|
19
|
+
const hash = await scrypt(String(password || ""), salt, SCRYPT_KEY_LENGTH);
|
|
20
|
+
return {
|
|
21
|
+
algorithm: SCRYPT_ALGORITHM,
|
|
22
|
+
version: SCRYPT_VERSION,
|
|
23
|
+
salt: base64url(salt),
|
|
24
|
+
hash: base64url(hash)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function verifyPassword(password, record) {
|
|
29
|
+
if (
|
|
30
|
+
!record ||
|
|
31
|
+
record.algorithm !== SCRYPT_ALGORITHM ||
|
|
32
|
+
record.version !== SCRYPT_VERSION ||
|
|
33
|
+
!record.salt ||
|
|
34
|
+
!record.hash
|
|
35
|
+
) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const expected = fromBase64url(record.hash);
|
|
40
|
+
const actual = await scrypt(String(password || ""), fromBase64url(record.salt), expected.length);
|
|
41
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { hashPassword, verifyPassword };
|