javascript-solid-server 0.0.20 → 0.0.22
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/.claude/settings.local.json +8 -1
- package/package.json +2 -1
- package/src/auth/nostr.js +197 -0
- package/src/auth/token.js +13 -1
- package/src/handlers/container.js +62 -46
- package/src/idp/accounts.js +62 -12
- package/src/idp/index.js +11 -0
- package/src/idp/interactions.js +109 -10
- package/src/idp/views.js +62 -2
- package/src/wac/checker.js +3 -3
- package/src/wac/parser.js +25 -8
- package/test/idp.test.js +0 -12
- package/test/wac.test.js +27 -8
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/_username_index.json +3 -0
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +1 -1
- package/test-data-idp-accounts/.idp/accounts/ba3591b1-4653-4c64-9661-57dc355e5acc.json +10 -0
- package/test-data-idp-accounts/.idp/keys/jwks.json +8 -8
- package/test-nostr-acl.js +144 -0
- package/test-nostr-auth.js +114 -0
- package/test-data-idp-accounts/.idp/accounts/00f5d7c4-1da9-4e68-92c9-2b931f7c5750.json +0 -9
package/src/idp/interactions.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Interaction handlers for login and
|
|
2
|
+
* Interaction handlers for login, consent, and registration flows
|
|
3
3
|
* Handles the user-facing parts of the authentication flow
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { authenticate, findById } from './accounts.js';
|
|
7
|
-
import { loginPage, consentPage, errorPage } from './views.js';
|
|
6
|
+
import { authenticate, findById, createAccount } from './accounts.js';
|
|
7
|
+
import { loginPage, consentPage, errorPage, registerPage } from './views.js';
|
|
8
|
+
import * as storage from '../storage/filesystem.js';
|
|
9
|
+
import { createPodStructure } from '../handlers/container.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Handle GET /idp/interaction/:uid
|
|
@@ -81,11 +83,11 @@ export async function handleLogin(request, reply, provider) {
|
|
|
81
83
|
}
|
|
82
84
|
// If it's already an object, use as-is
|
|
83
85
|
|
|
84
|
-
// Support
|
|
85
|
-
const
|
|
86
|
+
// Support username, email, or legacy 'email' field for backwards compatibility
|
|
87
|
+
const identifier = parsedBody.username || parsedBody.email;
|
|
86
88
|
const password = parsedBody.password;
|
|
87
89
|
|
|
88
|
-
request.log.info({
|
|
90
|
+
request.log.info({ identifier, hasPassword: !!password, bodyType: typeof request.body, keys: Object.keys(parsedBody) }, 'Login attempt');
|
|
89
91
|
|
|
90
92
|
try {
|
|
91
93
|
const interaction = await provider.Interaction.find(uid);
|
|
@@ -94,16 +96,16 @@ export async function handleLogin(request, reply, provider) {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
// Validate input
|
|
97
|
-
if (!
|
|
98
|
-
interaction.lastError = '
|
|
99
|
+
if (!identifier || !password) {
|
|
100
|
+
interaction.lastError = 'Username and password are required';
|
|
99
101
|
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
100
102
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
// Authenticate
|
|
104
|
-
const account = await authenticate(
|
|
106
|
+
const account = await authenticate(identifier, password);
|
|
105
107
|
if (!account) {
|
|
106
|
-
interaction.lastError = 'Invalid
|
|
108
|
+
interaction.lastError = 'Invalid username or password';
|
|
107
109
|
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
108
110
|
return reply.redirect(`/idp/interaction/${uid}`);
|
|
109
111
|
}
|
|
@@ -291,3 +293,100 @@ export async function handleAbort(request, reply, provider) {
|
|
|
291
293
|
return reply.code(500).type('text/html').send(errorPage('Error', err.message));
|
|
292
294
|
}
|
|
293
295
|
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Handle GET /idp/register
|
|
299
|
+
* Shows registration page
|
|
300
|
+
*/
|
|
301
|
+
export async function handleRegisterGet(request, reply) {
|
|
302
|
+
const uid = request.query.uid || null;
|
|
303
|
+
return reply.type('text/html').send(registerPage(uid));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Handle POST /idp/register
|
|
308
|
+
* Creates account and pod
|
|
309
|
+
*/
|
|
310
|
+
export async function handleRegisterPost(request, reply, issuer) {
|
|
311
|
+
const uid = request.query.uid || null;
|
|
312
|
+
|
|
313
|
+
// Parse body
|
|
314
|
+
let parsedBody = request.body || {};
|
|
315
|
+
const contentType = request.headers['content-type'] || '';
|
|
316
|
+
|
|
317
|
+
if (Buffer.isBuffer(parsedBody)) {
|
|
318
|
+
const bodyStr = parsedBody.toString();
|
|
319
|
+
if (contentType.includes('application/json')) {
|
|
320
|
+
try {
|
|
321
|
+
parsedBody = JSON.parse(bodyStr);
|
|
322
|
+
} catch (e) {
|
|
323
|
+
parsedBody = {};
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
const params = new URLSearchParams(bodyStr);
|
|
327
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
328
|
+
}
|
|
329
|
+
} else if (typeof parsedBody === 'string') {
|
|
330
|
+
const params = new URLSearchParams(parsedBody);
|
|
331
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const { username, password, confirmPassword } = parsedBody;
|
|
335
|
+
|
|
336
|
+
// Validate input
|
|
337
|
+
if (!username || !password) {
|
|
338
|
+
return reply.type('text/html').send(registerPage(uid, 'Username and password are required'));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Validate username format
|
|
342
|
+
const usernameRegex = /^[a-z0-9]+$/;
|
|
343
|
+
if (!usernameRegex.test(username)) {
|
|
344
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must contain only lowercase letters and numbers'));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (username.length < 3) {
|
|
348
|
+
return reply.type('text/html').send(registerPage(uid, 'Username must be at least 3 characters'));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
if (password !== confirmPassword) {
|
|
353
|
+
return reply.type('text/html').send(registerPage(uid, 'Passwords do not match'));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
// Build URLs
|
|
358
|
+
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
|
359
|
+
const podUri = `${baseUrl}/${username}/`;
|
|
360
|
+
const webId = `${podUri}#me`;
|
|
361
|
+
|
|
362
|
+
// Check if pod already exists
|
|
363
|
+
const podPath = `${username}/`;
|
|
364
|
+
const podExists = await storage.exists(podPath);
|
|
365
|
+
if (podExists) {
|
|
366
|
+
return reply.type('text/html').send(registerPage(uid, 'Username is already taken'));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Create pod structure
|
|
370
|
+
await createPodStructure(username, webId, baseUrl);
|
|
371
|
+
|
|
372
|
+
// Create account
|
|
373
|
+
await createAccount({
|
|
374
|
+
username,
|
|
375
|
+
password,
|
|
376
|
+
webId,
|
|
377
|
+
podName: username,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
request.log.info({ username, webId }, 'Account and pod created');
|
|
381
|
+
|
|
382
|
+
// Redirect to login
|
|
383
|
+
if (uid) {
|
|
384
|
+
return reply.redirect(`/idp/interaction/${uid}`);
|
|
385
|
+
} else {
|
|
386
|
+
return reply.type('text/html').send(registerPage(null, null, `Account created! You can now sign in as "${username}".`));
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
request.log.error(err, 'Registration error');
|
|
390
|
+
return reply.type('text/html').send(registerPage(uid, err.message));
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/idp/views.js
CHANGED
|
@@ -52,6 +52,7 @@ const styles = `
|
|
|
52
52
|
color: #333;
|
|
53
53
|
margin-bottom: 6px;
|
|
54
54
|
}
|
|
55
|
+
input[type="text"],
|
|
55
56
|
input[type="email"],
|
|
56
57
|
input[type="password"] {
|
|
57
58
|
width: 100%;
|
|
@@ -160,6 +161,8 @@ const scopeDescriptions = {
|
|
|
160
161
|
* Login page HTML
|
|
161
162
|
*/
|
|
162
163
|
export function loginPage(uid, clientId, error = null) {
|
|
164
|
+
const appName = clientId || 'An application';
|
|
165
|
+
|
|
163
166
|
return `
|
|
164
167
|
<!DOCTYPE html>
|
|
165
168
|
<html lang="en">
|
|
@@ -175,11 +178,16 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
175
178
|
<h1>Sign In</h1>
|
|
176
179
|
<p class="subtitle">Sign in to your Solid Pod</p>
|
|
177
180
|
|
|
181
|
+
<div class="client-info">
|
|
182
|
+
<div class="client-name">${escapeHtml(appName)}</div>
|
|
183
|
+
<div class="client-uri">is requesting access to your pod</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
178
186
|
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
179
187
|
|
|
180
188
|
<form method="POST" action="/idp/interaction/${uid}/login">
|
|
181
|
-
<label for="
|
|
182
|
-
<input type="
|
|
189
|
+
<label for="username">Username</label>
|
|
190
|
+
<input type="text" id="username" name="username" required autofocus placeholder="Your username">
|
|
183
191
|
|
|
184
192
|
<label for="password">Password</label>
|
|
185
193
|
<input type="password" id="password" name="password" required placeholder="Your password">
|
|
@@ -190,6 +198,10 @@ export function loginPage(uid, clientId, error = null) {
|
|
|
190
198
|
<form method="POST" action="/idp/interaction/${uid}/abort">
|
|
191
199
|
<button type="submit" class="btn btn-secondary">Cancel</button>
|
|
192
200
|
</form>
|
|
201
|
+
|
|
202
|
+
<p style="text-align: center; margin-top: 24px; color: #666; font-size: 14px;">
|
|
203
|
+
Don't have an account? <a href="/idp/register?uid=${uid}" style="color: #0066cc;">Register</a>
|
|
204
|
+
</p>
|
|
193
205
|
</div>
|
|
194
206
|
</body>
|
|
195
207
|
</html>
|
|
@@ -281,6 +293,54 @@ export function errorPage(title, message) {
|
|
|
281
293
|
`;
|
|
282
294
|
}
|
|
283
295
|
|
|
296
|
+
/**
|
|
297
|
+
* Registration page HTML
|
|
298
|
+
*/
|
|
299
|
+
export function registerPage(uid = null, error = null, success = null) {
|
|
300
|
+
return `
|
|
301
|
+
<!DOCTYPE html>
|
|
302
|
+
<html lang="en">
|
|
303
|
+
<head>
|
|
304
|
+
<meta charset="UTF-8">
|
|
305
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
306
|
+
<title>Register - Solid IdP</title>
|
|
307
|
+
<style>${styles}</style>
|
|
308
|
+
</head>
|
|
309
|
+
<body>
|
|
310
|
+
<div class="container">
|
|
311
|
+
<div class="logo">${solidLogo}</div>
|
|
312
|
+
<h1>Create Account</h1>
|
|
313
|
+
<p class="subtitle">Register for a new Solid Pod</p>
|
|
314
|
+
|
|
315
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
316
|
+
${success ? `<div class="error" style="background: #efe; border-color: #cfc; color: #060;">${escapeHtml(success)}</div>` : ''}
|
|
317
|
+
|
|
318
|
+
<form method="POST" action="/idp/register${uid ? `?uid=${uid}` : ''}">
|
|
319
|
+
<label for="username">Username</label>
|
|
320
|
+
<input type="text" id="username" name="username" required autofocus
|
|
321
|
+
placeholder="Choose a username" pattern="[a-z0-9]+"
|
|
322
|
+
title="Lowercase letters and numbers only">
|
|
323
|
+
|
|
324
|
+
<label for="password">Password</label>
|
|
325
|
+
<input type="password" id="password" name="password" required
|
|
326
|
+
placeholder="Choose a password">
|
|
327
|
+
|
|
328
|
+
<label for="confirmPassword">Confirm Password</label>
|
|
329
|
+
<input type="password" id="confirmPassword" name="confirmPassword" required
|
|
330
|
+
placeholder="Confirm your password">
|
|
331
|
+
|
|
332
|
+
<button type="submit" class="btn btn-primary">Create Account</button>
|
|
333
|
+
</form>
|
|
334
|
+
|
|
335
|
+
<p style="text-align: center; margin-top: 24px; color: #666; font-size: 14px;">
|
|
336
|
+
Already have an account? <a href="${uid ? `/idp/interaction/${uid}` : '/idp/auth'}" style="color: #0066cc;">Sign In</a>
|
|
337
|
+
</p>
|
|
338
|
+
</div>
|
|
339
|
+
</body>
|
|
340
|
+
</html>
|
|
341
|
+
`;
|
|
342
|
+
}
|
|
343
|
+
|
|
284
344
|
/**
|
|
285
345
|
* Escape HTML to prevent XSS
|
|
286
346
|
*/
|
package/src/wac/checker.js
CHANGED
|
@@ -64,7 +64,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
|
64
64
|
const content = await storage.read(resourceAclPath);
|
|
65
65
|
if (content) {
|
|
66
66
|
const aclUrl = getAclUrl(resourceUrl, isContainer);
|
|
67
|
-
const authorizations = parseAcl(content.toString(), aclUrl);
|
|
67
|
+
const authorizations = await parseAcl(content.toString(), aclUrl);
|
|
68
68
|
return { authorizations, isDefault: false, targetUrl: resourceUrl };
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -80,7 +80,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
|
80
80
|
const content = await storage.read(parentAclPath);
|
|
81
81
|
if (content) {
|
|
82
82
|
const parentUrl = resourceUrl.substring(0, resourceUrl.lastIndexOf(currentPath)) + parentPath;
|
|
83
|
-
const authorizations = parseAcl(content.toString(), parentAclPath);
|
|
83
|
+
const authorizations = await parseAcl(content.toString(), parentAclPath);
|
|
84
84
|
return { authorizations, isDefault: true, targetUrl: parentUrl };
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -93,7 +93,7 @@ async function findApplicableAcl(resourceUrl, resourcePath, isContainer) {
|
|
|
93
93
|
const content = await storage.read('/.acl');
|
|
94
94
|
if (content) {
|
|
95
95
|
const rootUrl = resourceUrl.substring(0, resourceUrl.indexOf('/', 8) + 1);
|
|
96
|
-
const authorizations = parseAcl(content.toString(), '/.acl');
|
|
96
|
+
const authorizations = await parseAcl(content.toString(), '/.acl');
|
|
97
97
|
return { authorizations, isDefault: true, targetUrl: rootUrl };
|
|
98
98
|
}
|
|
99
99
|
}
|
package/src/wac/parser.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WAC (Web Access Control) Parser
|
|
3
|
-
* Parses JSON-LD
|
|
3
|
+
* Parses ACL files (JSON-LD or Turtle) into authorization rules
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { turtleToJsonLd } from '../rdf/turtle.js';
|
|
7
|
+
|
|
6
8
|
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
|
7
9
|
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
|
8
10
|
|
|
@@ -21,16 +23,31 @@ export const AgentClass = {
|
|
|
21
23
|
};
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
|
-
* Parse
|
|
25
|
-
* @param {string|object} content - JSON-LD
|
|
26
|
+
* Parse an ACL document (JSON-LD or Turtle)
|
|
27
|
+
* @param {string|object} content - ACL content (JSON-LD string/object or Turtle string)
|
|
26
28
|
* @param {string} aclUrl - URL of the ACL document
|
|
27
|
-
* @returns {Array<Authorization
|
|
29
|
+
* @returns {Promise<Array<Authorization>>} List of authorization rules
|
|
28
30
|
*/
|
|
29
|
-
export function parseAcl(content, aclUrl) {
|
|
31
|
+
export async function parseAcl(content, aclUrl) {
|
|
30
32
|
let doc;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
// If already an object, use it directly
|
|
35
|
+
if (typeof content === 'object' && content !== null) {
|
|
36
|
+
doc = content;
|
|
37
|
+
} else if (typeof content === 'string') {
|
|
38
|
+
// Try JSON-LD first
|
|
39
|
+
try {
|
|
40
|
+
doc = JSON.parse(content);
|
|
41
|
+
} catch {
|
|
42
|
+
// Not JSON, try Turtle
|
|
43
|
+
try {
|
|
44
|
+
doc = await turtleToJsonLd(content, aclUrl);
|
|
45
|
+
} catch (turtleError) {
|
|
46
|
+
// Neither JSON-LD nor valid Turtle
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
34
51
|
return [];
|
|
35
52
|
}
|
|
36
53
|
|
package/test/idp.test.js
CHANGED
|
@@ -96,18 +96,6 @@ describe('Identity Provider', () => {
|
|
|
96
96
|
assert.ok(body.error.includes('Password'));
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
-
it('should require minimum password length', async () => {
|
|
100
|
-
const res = await fetch(`${BASE_URL}/.pods`, {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: { 'Content-Type': 'application/json' },
|
|
103
|
-
body: JSON.stringify({ name: 'shortpass', email: 'test@example.com', password: 'short' }),
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
assert.strictEqual(res.status, 400);
|
|
107
|
-
const body = await res.json();
|
|
108
|
-
assert.ok(body.error.includes('8'));
|
|
109
|
-
});
|
|
110
|
-
|
|
111
99
|
it('should create pod with account', async () => {
|
|
112
100
|
const uniqueId = Date.now();
|
|
113
101
|
const res = await fetch(`${BASE_URL}/.pods`, {
|
package/test/wac.test.js
CHANGED
|
@@ -18,7 +18,7 @@ import { checkAccess, getRequiredMode } from '../src/wac/checker.js';
|
|
|
18
18
|
|
|
19
19
|
describe('WAC Parser', () => {
|
|
20
20
|
describe('parseAcl', () => {
|
|
21
|
-
it('should parse a simple ACL', () => {
|
|
21
|
+
it('should parse a simple ACL', async () => {
|
|
22
22
|
const acl = {
|
|
23
23
|
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
24
24
|
'@graph': [{
|
|
@@ -30,7 +30,7 @@ describe('WAC Parser', () => {
|
|
|
30
30
|
}]
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
|
|
33
|
+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
|
|
34
34
|
|
|
35
35
|
assert.strictEqual(auths.length, 1);
|
|
36
36
|
assert.ok(auths[0].agents.includes('https://alice.example/#me'));
|
|
@@ -38,7 +38,7 @@ describe('WAC Parser', () => {
|
|
|
38
38
|
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it('should parse public access', () => {
|
|
41
|
+
it('should parse public access', async () => {
|
|
42
42
|
const acl = {
|
|
43
43
|
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#', 'foaf': 'http://xmlns.com/foaf/0.1/' },
|
|
44
44
|
'@graph': [{
|
|
@@ -50,14 +50,14 @@ describe('WAC Parser', () => {
|
|
|
50
50
|
}]
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
|
|
53
|
+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/public/.acl');
|
|
54
54
|
|
|
55
55
|
assert.strictEqual(auths.length, 1);
|
|
56
56
|
assert.ok(auths[0].agentClasses.includes('foaf:Agent'));
|
|
57
57
|
assert.ok(auths[0].modes.includes(AccessMode.READ));
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
it('should parse default authorizations for containers', () => {
|
|
60
|
+
it('should parse default authorizations for containers', async () => {
|
|
61
61
|
const acl = {
|
|
62
62
|
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
|
|
63
63
|
'@graph': [{
|
|
@@ -69,16 +69,35 @@ describe('WAC Parser', () => {
|
|
|
69
69
|
}]
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
-
const auths = parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
|
|
72
|
+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/folder/.acl');
|
|
73
73
|
|
|
74
74
|
assert.strictEqual(auths.length, 1);
|
|
75
75
|
assert.ok(auths[0].default.includes('https://alice.example/folder/'));
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
it('should handle invalid JSON gracefully', () => {
|
|
79
|
-
const auths = parseAcl('not valid json', 'https://example.com/.acl');
|
|
78
|
+
it('should handle invalid JSON gracefully', async () => {
|
|
79
|
+
const auths = await parseAcl('not valid json', 'https://example.com/.acl');
|
|
80
80
|
assert.strictEqual(auths.length, 0);
|
|
81
81
|
});
|
|
82
|
+
|
|
83
|
+
it('should parse Turtle ACL format', async () => {
|
|
84
|
+
const turtleAcl = `
|
|
85
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
86
|
+
|
|
87
|
+
<#owner>
|
|
88
|
+
a acl:Authorization;
|
|
89
|
+
acl:agent <did:nostr:abc123>;
|
|
90
|
+
acl:accessTo <https://example.com/resource>;
|
|
91
|
+
acl:mode acl:Read, acl:Write.
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const auths = await parseAcl(turtleAcl, 'https://example.com/.acl');
|
|
95
|
+
|
|
96
|
+
assert.strictEqual(auths.length, 1);
|
|
97
|
+
assert.ok(auths[0].agents.includes('did:nostr:abc123'));
|
|
98
|
+
assert.ok(auths[0].modes.includes(AccessMode.READ));
|
|
99
|
+
assert.ok(auths[0].modes.includes(AccessMode.WRITE));
|
|
100
|
+
});
|
|
82
101
|
});
|
|
83
102
|
|
|
84
103
|
describe('generateOwnerAcl', () => {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "ba3591b1-4653-4c64-9661-57dc355e5acc",
|
|
3
|
+
"username": "credtest",
|
|
4
|
+
"email": "credtest@example.com",
|
|
5
|
+
"passwordHash": "$2b$10$tFYM8KuMVTFRpVMqZOYR4OKNreNLgCBqzZVTNAhpdBFUmGH1MFNBu",
|
|
6
|
+
"webId": "http://localhost:3101/credtest/#me",
|
|
7
|
+
"podName": "credtest",
|
|
8
|
+
"createdAt": "2025-12-28T14:20:02.176Z",
|
|
9
|
+
"lastLogin": "2025-12-28T14:20:02.579Z"
|
|
10
|
+
}
|
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
"keys": [
|
|
4
4
|
{
|
|
5
5
|
"kty": "EC",
|
|
6
|
-
"x": "
|
|
7
|
-
"y": "
|
|
6
|
+
"x": "Aa7l5-YrS54RU8xPfEphUTRwNBzSm6lxm84aqKjfrSg",
|
|
7
|
+
"y": "tWi_lhjqQhd43KdK5YqDg7ZzRSUZo3L0ytbiBTdPOWs",
|
|
8
8
|
"crv": "P-256",
|
|
9
|
-
"d": "
|
|
10
|
-
"kid": "
|
|
9
|
+
"d": "x6NqVSfA241O10u9Qp4m0dQZsTNYw-Hku3r0eu47VZE",
|
|
10
|
+
"kid": "ed46f7df-3010-43da-9032-e0acaee4d3e1",
|
|
11
11
|
"use": "sig",
|
|
12
12
|
"alg": "ES256",
|
|
13
|
-
"iat":
|
|
13
|
+
"iat": 1766931602
|
|
14
14
|
}
|
|
15
15
|
]
|
|
16
16
|
},
|
|
17
17
|
"cookieKeys": [
|
|
18
|
-
"
|
|
19
|
-
"
|
|
18
|
+
"Vb3JNLAlJHCOu5u73eUA_rzlc9aJ0_WCQCu9RWV5WL4",
|
|
19
|
+
"5xCVtYihgadSlvy1QRD_DcU4_9mI_Ggn0DrngzPdiyM"
|
|
20
20
|
],
|
|
21
|
-
"createdAt": "2025-12-
|
|
21
|
+
"createdAt": "2025-12-28T14:20:02.080Z"
|
|
22
22
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test script for did:nostr in ACL files
|
|
3
|
+
*
|
|
4
|
+
* Tests:
|
|
5
|
+
* 1. Create a container with restricted access
|
|
6
|
+
* 2. Set ACL with did:nostr agent
|
|
7
|
+
* 3. Verify Nostr auth grants access
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools';
|
|
11
|
+
import { getToken } from 'nostr-tools/nip98';
|
|
12
|
+
|
|
13
|
+
const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
|
|
14
|
+
|
|
15
|
+
async function main() {
|
|
16
|
+
console.log('=== did:nostr ACL Authorization Test ===\n');
|
|
17
|
+
|
|
18
|
+
// Generate a keypair for testing
|
|
19
|
+
const sk = generateSecretKey();
|
|
20
|
+
const pk = getPublicKey(sk);
|
|
21
|
+
const didNostr = `did:nostr:${pk}`;
|
|
22
|
+
|
|
23
|
+
console.log('1. Generated keypair');
|
|
24
|
+
console.log(` Pubkey: ${pk.slice(0, 16)}...`);
|
|
25
|
+
console.log(` DID: ${didNostr.slice(0, 24)}...\n`);
|
|
26
|
+
|
|
27
|
+
// Create a unique test container
|
|
28
|
+
const testPath = `/demo/nostr-acl-test-${Date.now()}/`;
|
|
29
|
+
const containerUrl = `${BASE_URL}${testPath}`;
|
|
30
|
+
|
|
31
|
+
console.log(`2. Creating test container: ${testPath}`);
|
|
32
|
+
|
|
33
|
+
// Create container (unauthenticated - should work on public parent)
|
|
34
|
+
const createRes = await fetch(containerUrl, {
|
|
35
|
+
method: 'PUT',
|
|
36
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
37
|
+
body: ''
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!createRes.ok && createRes.status !== 201) {
|
|
41
|
+
console.log(` Failed to create container: ${createRes.status}`);
|
|
42
|
+
// Try anyway
|
|
43
|
+
} else {
|
|
44
|
+
console.log(` Created: ${createRes.status}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Create ACL with did:nostr agent (Turtle format)
|
|
48
|
+
const aclUrl = `${containerUrl}.acl`;
|
|
49
|
+
const aclContent = `
|
|
50
|
+
@prefix acl: <http://www.w3.org/ns/auth/acl#>.
|
|
51
|
+
|
|
52
|
+
<#nostrAccess>
|
|
53
|
+
a acl:Authorization;
|
|
54
|
+
acl:agent <${didNostr}>;
|
|
55
|
+
acl:accessTo <${containerUrl}>;
|
|
56
|
+
acl:default <${containerUrl}>;
|
|
57
|
+
acl:mode acl:Read, acl:Write, acl:Control.
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
console.log('3. Creating ACL with did:nostr agent');
|
|
61
|
+
console.log(` ACL URL: ${aclUrl}`);
|
|
62
|
+
console.log(` Agent: ${didNostr.slice(0, 40)}...`);
|
|
63
|
+
|
|
64
|
+
const aclRes = await fetch(aclUrl, {
|
|
65
|
+
method: 'PUT',
|
|
66
|
+
headers: { 'Content-Type': 'text/turtle' },
|
|
67
|
+
body: aclContent
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
console.log(` ACL created: ${aclRes.status}\n`);
|
|
71
|
+
|
|
72
|
+
// Verify ACL was saved correctly
|
|
73
|
+
console.log('4. Verifying ACL content');
|
|
74
|
+
const aclCheck = await fetch(aclUrl, {
|
|
75
|
+
headers: { 'Accept': 'text/turtle' }
|
|
76
|
+
});
|
|
77
|
+
const savedAcl = await aclCheck.text();
|
|
78
|
+
console.log(` ACL response: ${aclCheck.status}`);
|
|
79
|
+
console.log(` Contains did:nostr: ${savedAcl.includes('did:nostr:')}\n`);
|
|
80
|
+
|
|
81
|
+
// Test 1: Access WITHOUT auth (should be denied)
|
|
82
|
+
console.log('5. Testing access WITHOUT auth (should be 401/403)...');
|
|
83
|
+
const noAuthRes = await fetch(containerUrl);
|
|
84
|
+
console.log(` Status: ${noAuthRes.status} ${noAuthRes.status === 401 || noAuthRes.status === 403 ? '✓' : '✗'}\n`);
|
|
85
|
+
|
|
86
|
+
// Test 2: Access WITH correct Nostr auth
|
|
87
|
+
console.log('6. Testing access WITH correct Nostr auth...');
|
|
88
|
+
const token = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, sk));
|
|
89
|
+
|
|
90
|
+
const authRes = await fetch(containerUrl, {
|
|
91
|
+
headers: {
|
|
92
|
+
'Authorization': `Nostr ${token}`,
|
|
93
|
+
'Accept': 'text/turtle'
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
console.log(` Status: ${authRes.status}`);
|
|
98
|
+
|
|
99
|
+
if (authRes.status === 200) {
|
|
100
|
+
console.log(' ✓ ACCESS GRANTED - did:nostr ACL working!\n');
|
|
101
|
+
} else {
|
|
102
|
+
console.log(' ✗ Access denied');
|
|
103
|
+
const body = await authRes.text();
|
|
104
|
+
console.log(` Body: ${body.slice(0, 200)}\n`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Test 3: Access with DIFFERENT Nostr key (should be denied)
|
|
108
|
+
console.log('7. Testing with DIFFERENT Nostr key (should be denied)...');
|
|
109
|
+
const wrongSk = generateSecretKey();
|
|
110
|
+
const wrongToken = await getToken(containerUrl, 'GET', (event) => finalizeEvent(event, wrongSk));
|
|
111
|
+
|
|
112
|
+
const wrongAuthRes = await fetch(containerUrl, {
|
|
113
|
+
headers: {
|
|
114
|
+
'Authorization': `Nostr ${wrongToken}`,
|
|
115
|
+
'Accept': 'text/turtle'
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
console.log(` Status: ${wrongAuthRes.status} ${wrongAuthRes.status === 403 ? '✓' : '✗'}\n`);
|
|
120
|
+
|
|
121
|
+
// Clean up
|
|
122
|
+
console.log('8. Cleaning up test container...');
|
|
123
|
+
const deleteToken = await getToken(containerUrl, 'DELETE', (event) => finalizeEvent(event, sk));
|
|
124
|
+
const deleteRes = await fetch(containerUrl, {
|
|
125
|
+
method: 'DELETE',
|
|
126
|
+
headers: { 'Authorization': `Nostr ${deleteToken}` }
|
|
127
|
+
});
|
|
128
|
+
console.log(` Delete: ${deleteRes.status}\n`);
|
|
129
|
+
|
|
130
|
+
// Summary
|
|
131
|
+
console.log('=== Test Summary ===');
|
|
132
|
+
console.log(`No auth: ${noAuthRes.status === 401 || noAuthRes.status === 403 ? 'PASS' : 'FAIL'} (${noAuthRes.status})`);
|
|
133
|
+
console.log(`Correct key: ${authRes.status === 200 ? 'PASS' : 'FAIL'} (${authRes.status})`);
|
|
134
|
+
console.log(`Wrong key: ${wrongAuthRes.status === 403 ? 'PASS' : 'FAIL'} (${wrongAuthRes.status})`);
|
|
135
|
+
|
|
136
|
+
const allPassed = (noAuthRes.status === 401 || noAuthRes.status === 403) &&
|
|
137
|
+
authRes.status === 200 &&
|
|
138
|
+
wrongAuthRes.status === 403;
|
|
139
|
+
|
|
140
|
+
console.log(`\nOverall: ${allPassed ? 'ALL TESTS PASSED ✓' : 'SOME TESTS FAILED ✗'}`);
|
|
141
|
+
process.exit(allPassed ? 0 : 1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
main().catch(console.error);
|