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
|
@@ -65,7 +65,14 @@
|
|
|
65
65
|
"Bash(pm2 start:*)",
|
|
66
66
|
"Bash(DATA_ROOT=/home/melvin/jss/data pm2 start:*)",
|
|
67
67
|
"Bash(pm2 save:*)",
|
|
68
|
-
"Bash(gh issue create:*)"
|
|
68
|
+
"Bash(gh issue create:*)",
|
|
69
|
+
"Bash(gh issue view:*)",
|
|
70
|
+
"Bash(gh issue edit:*)",
|
|
71
|
+
"WebFetch(domain:nostrcg.github.io)",
|
|
72
|
+
"WebFetch(domain:melvincarvalho.github.io)",
|
|
73
|
+
"WebFetch(domain:dev.to)",
|
|
74
|
+
"WebFetch(domain:solidproject.org)",
|
|
75
|
+
"WebFetch(domain:www.w3.org)"
|
|
69
76
|
]
|
|
70
77
|
}
|
|
71
78
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "javascript-solid-server",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
4
4
|
"description": "A minimal, fast Solid server",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"fs-extra": "^11.2.0",
|
|
32
32
|
"jose": "^6.1.3",
|
|
33
33
|
"n3": "^1.26.0",
|
|
34
|
+
"nostr-tools": "^2.19.4",
|
|
34
35
|
"oidc-provider": "^9.6.0"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nostr NIP-98 Authentication
|
|
3
|
+
*
|
|
4
|
+
* Implements HTTP authentication using Schnorr signatures as defined in:
|
|
5
|
+
* - NIP-98: https://nips.nostr.com/98
|
|
6
|
+
* - JIP-0001: https://github.com/JavaScriptSolidServer/jips/blob/main/jip-0001.md
|
|
7
|
+
*
|
|
8
|
+
* Authorization header format: "Nostr <base64-encoded-event>"
|
|
9
|
+
*
|
|
10
|
+
* The authenticated identity is returned as a did:nostr URI:
|
|
11
|
+
* did:nostr:<64-char-hex-pubkey>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { verifyEvent } from 'nostr-tools';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
|
|
17
|
+
// NIP-98 event kind (references RFC 7235)
|
|
18
|
+
const HTTP_AUTH_KIND = 27235;
|
|
19
|
+
|
|
20
|
+
// Timestamp tolerance in seconds
|
|
21
|
+
const TIMESTAMP_TOLERANCE = 60;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if request has Nostr authentication
|
|
25
|
+
* @param {object} request - Fastify request object
|
|
26
|
+
* @returns {boolean}
|
|
27
|
+
*/
|
|
28
|
+
export function hasNostrAuth(request) {
|
|
29
|
+
const authHeader = request.headers.authorization;
|
|
30
|
+
return authHeader && authHeader.startsWith('Nostr ');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extract token from Nostr authorization header
|
|
35
|
+
* @param {string} authHeader - Authorization header value
|
|
36
|
+
* @returns {string|null}
|
|
37
|
+
*/
|
|
38
|
+
export function extractNostrToken(authHeader) {
|
|
39
|
+
if (!authHeader || !authHeader.startsWith('Nostr ')) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return authHeader.slice(6).trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decode NIP-98 event from base64 token
|
|
47
|
+
* @param {string} token - Base64 encoded event
|
|
48
|
+
* @returns {object|null} Decoded event or null
|
|
49
|
+
*/
|
|
50
|
+
function decodeEvent(token) {
|
|
51
|
+
try {
|
|
52
|
+
const decoded = Buffer.from(token, 'base64').toString('utf8');
|
|
53
|
+
return JSON.parse(decoded);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get tag value from event
|
|
61
|
+
* @param {object} event - Nostr event
|
|
62
|
+
* @param {string} tagName - Tag name (e.g., 'u', 'method')
|
|
63
|
+
* @returns {string|null} Tag value or null
|
|
64
|
+
*/
|
|
65
|
+
function getTagValue(event, tagName) {
|
|
66
|
+
if (!event.tags || !Array.isArray(event.tags)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const tag = event.tags.find(t => Array.isArray(t) && t[0] === tagName);
|
|
70
|
+
return tag ? tag[1] : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert Nostr pubkey to did:nostr URI
|
|
75
|
+
* @param {string} pubkey - 64-char hex public key
|
|
76
|
+
* @returns {string} did:nostr URI
|
|
77
|
+
*/
|
|
78
|
+
export function pubkeyToDidNostr(pubkey) {
|
|
79
|
+
return `did:nostr:${pubkey.toLowerCase()}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Verify NIP-98 authentication and return agent identity
|
|
84
|
+
* @param {object} request - Fastify request object
|
|
85
|
+
* @returns {Promise<{webId: string|null, error: string|null}>}
|
|
86
|
+
*/
|
|
87
|
+
export async function verifyNostrAuth(request) {
|
|
88
|
+
const token = extractNostrToken(request.headers.authorization);
|
|
89
|
+
|
|
90
|
+
if (!token) {
|
|
91
|
+
return { webId: null, error: 'Missing Nostr token' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Decode the event
|
|
95
|
+
const event = decodeEvent(token);
|
|
96
|
+
if (!event) {
|
|
97
|
+
return { webId: null, error: 'Invalid token format: could not decode base64 JSON' };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Validate event kind (must be 27235)
|
|
101
|
+
if (event.kind !== HTTP_AUTH_KIND) {
|
|
102
|
+
return { webId: null, error: `Invalid event kind: expected ${HTTP_AUTH_KIND}, got ${event.kind}` };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Validate timestamp (within ±60 seconds)
|
|
106
|
+
const now = Math.floor(Date.now() / 1000);
|
|
107
|
+
const eventTime = event.created_at;
|
|
108
|
+
if (!eventTime || Math.abs(now - eventTime) > TIMESTAMP_TOLERANCE) {
|
|
109
|
+
return { webId: null, error: 'Event timestamp outside acceptable window (±60s)' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Build full URL for validation
|
|
113
|
+
const protocol = request.protocol || 'http';
|
|
114
|
+
const host = request.headers.host || request.hostname;
|
|
115
|
+
const fullUrl = `${protocol}://${host}${request.url}`;
|
|
116
|
+
|
|
117
|
+
// Validate URL tag matches request URL
|
|
118
|
+
const eventUrl = getTagValue(event, 'u');
|
|
119
|
+
if (!eventUrl) {
|
|
120
|
+
return { webId: null, error: 'Missing URL tag in event' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Compare URLs (normalize by removing trailing slashes)
|
|
124
|
+
const normalizedEventUrl = eventUrl.replace(/\/$/, '');
|
|
125
|
+
const normalizedRequestUrl = fullUrl.replace(/\/$/, '');
|
|
126
|
+
const normalizedRequestUrlNoQuery = fullUrl.split('?')[0].replace(/\/$/, '');
|
|
127
|
+
|
|
128
|
+
if (normalizedEventUrl !== normalizedRequestUrl && normalizedEventUrl !== normalizedRequestUrlNoQuery) {
|
|
129
|
+
return { webId: null, error: `URL mismatch: event URL "${eventUrl}" does not match request URL "${fullUrl}"` };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Validate method tag matches request method
|
|
133
|
+
const eventMethod = getTagValue(event, 'method');
|
|
134
|
+
if (!eventMethod) {
|
|
135
|
+
return { webId: null, error: 'Missing method tag in event' };
|
|
136
|
+
}
|
|
137
|
+
if (eventMethod.toUpperCase() !== request.method.toUpperCase()) {
|
|
138
|
+
return { webId: null, error: `Method mismatch: expected ${request.method}, got ${eventMethod}` };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Validate payload hash if present and request has body
|
|
142
|
+
const payloadTag = getTagValue(event, 'payload');
|
|
143
|
+
if (payloadTag && request.body) {
|
|
144
|
+
let bodyString;
|
|
145
|
+
if (typeof request.body === 'string') {
|
|
146
|
+
bodyString = request.body;
|
|
147
|
+
} else if (Buffer.isBuffer(request.body)) {
|
|
148
|
+
bodyString = request.body.toString();
|
|
149
|
+
} else {
|
|
150
|
+
bodyString = JSON.stringify(request.body);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const expectedHash = crypto.createHash('sha256').update(bodyString).digest('hex');
|
|
154
|
+
if (payloadTag.toLowerCase() !== expectedHash.toLowerCase()) {
|
|
155
|
+
return { webId: null, error: 'Payload hash mismatch' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Validate pubkey exists
|
|
160
|
+
if (!event.pubkey || typeof event.pubkey !== 'string' || event.pubkey.length !== 64) {
|
|
161
|
+
return { webId: null, error: 'Invalid or missing pubkey' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Verify Schnorr signature
|
|
165
|
+
const isValid = verifyEvent(event);
|
|
166
|
+
if (!isValid) {
|
|
167
|
+
return { webId: null, error: 'Invalid Schnorr signature' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Return did:nostr as the agent identifier
|
|
171
|
+
const didNostr = pubkeyToDidNostr(event.pubkey);
|
|
172
|
+
|
|
173
|
+
return { webId: didNostr, error: null };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Get Nostr pubkey from request if authenticated via NIP-98
|
|
178
|
+
* @param {object} request - Fastify request object
|
|
179
|
+
* @returns {Promise<string|null>} Hex pubkey or null
|
|
180
|
+
*/
|
|
181
|
+
export async function getNostrPubkey(request) {
|
|
182
|
+
if (!hasNostrAuth(request)) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const token = extractNostrToken(request.headers.authorization);
|
|
187
|
+
if (!token) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const event = decodeEvent(token);
|
|
193
|
+
return event?.pubkey || null;
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
package/src/auth/token.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token-based authentication
|
|
3
3
|
*
|
|
4
|
-
* Supports
|
|
4
|
+
* Supports multiple modes:
|
|
5
5
|
* 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
|
|
6
6
|
* 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
|
|
7
|
+
* 3. Nostr NIP-98 tokens: Schnorr signatures, returns did:nostr identity
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import crypto from 'crypto';
|
|
10
11
|
import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
|
|
12
|
+
import { verifyNostrAuth, hasNostrAuth } from './nostr.js';
|
|
11
13
|
|
|
12
14
|
// Secret for signing tokens (in production, use env var)
|
|
13
15
|
const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
|
|
@@ -151,6 +153,11 @@ export function getWebIdFromRequest(request) {
|
|
|
151
153
|
return null;
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// Skip Nostr tokens - use async version for those
|
|
157
|
+
if (authHeader && authHeader.startsWith('Nostr ')) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
const token = extractToken(authHeader);
|
|
155
162
|
|
|
156
163
|
if (!token) {
|
|
@@ -178,6 +185,11 @@ export async function getWebIdFromRequestAsync(request) {
|
|
|
178
185
|
return verifySolidOidc(request);
|
|
179
186
|
}
|
|
180
187
|
|
|
188
|
+
// Try Nostr NIP-98 (Schnorr signatures)
|
|
189
|
+
if (hasNostrAuth(request)) {
|
|
190
|
+
return verifyNostrAuth(request);
|
|
191
|
+
}
|
|
192
|
+
|
|
181
193
|
// Fall back to simple Bearer tokens
|
|
182
194
|
const token = extractToken(authHeader);
|
|
183
195
|
if (!token) {
|
|
@@ -121,6 +121,63 @@ export async function handlePost(request, reply) {
|
|
|
121
121
|
return reply.code(201).send();
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Create pod directory structure (reusable for registration)
|
|
126
|
+
* @param {string} name - Pod name (username)
|
|
127
|
+
* @param {string} webId - User's WebID URI
|
|
128
|
+
* @param {string} baseUrl - Base URL (without trailing slash)
|
|
129
|
+
*/
|
|
130
|
+
export async function createPodStructure(name, webId, baseUrl) {
|
|
131
|
+
const podPath = `/${name}/`;
|
|
132
|
+
const podUri = `${baseUrl}/${name}/`;
|
|
133
|
+
const issuer = baseUrl + '/';
|
|
134
|
+
|
|
135
|
+
// Create pod directory structure
|
|
136
|
+
await storage.createContainer(podPath);
|
|
137
|
+
await storage.createContainer(`${podPath}inbox/`);
|
|
138
|
+
await storage.createContainer(`${podPath}public/`);
|
|
139
|
+
await storage.createContainer(`${podPath}private/`);
|
|
140
|
+
await storage.createContainer(`${podPath}settings/`);
|
|
141
|
+
|
|
142
|
+
// Generate and write WebID profile as index.html at pod root
|
|
143
|
+
const profileHtml = generateProfile({ webId, name, podUri, issuer });
|
|
144
|
+
await storage.write(`${podPath}index.html`, profileHtml);
|
|
145
|
+
|
|
146
|
+
// Generate and write preferences
|
|
147
|
+
const prefs = generatePreferences({ webId, podUri });
|
|
148
|
+
await storage.write(`${podPath}settings/prefs`, serialize(prefs));
|
|
149
|
+
|
|
150
|
+
// Generate and write type indexes
|
|
151
|
+
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex`);
|
|
152
|
+
await storage.write(`${podPath}settings/publicTypeIndex`, serialize(publicTypeIndex));
|
|
153
|
+
|
|
154
|
+
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
|
|
155
|
+
await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
|
|
156
|
+
|
|
157
|
+
// Create default ACL files
|
|
158
|
+
// Pod root: owner full control, public read
|
|
159
|
+
const rootAcl = generateOwnerAcl(podUri, webId, true);
|
|
160
|
+
await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
|
|
161
|
+
|
|
162
|
+
// Private folder: owner only (no public)
|
|
163
|
+
const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
|
|
164
|
+
await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
|
|
165
|
+
|
|
166
|
+
// Settings folder: owner only
|
|
167
|
+
const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
|
|
168
|
+
await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
|
|
169
|
+
|
|
170
|
+
// Inbox: owner full, public append
|
|
171
|
+
const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
|
|
172
|
+
await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
|
|
173
|
+
|
|
174
|
+
// Public folder: owner full, public read (with inheritance)
|
|
175
|
+
const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
|
|
176
|
+
await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
|
|
177
|
+
|
|
178
|
+
return { podPath, podUri };
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
/**
|
|
125
182
|
* Create a pod (container) for a user
|
|
126
183
|
* POST /.pods with { "name": "alice" }
|
|
@@ -149,8 +206,8 @@ export async function handleCreatePod(request, reply) {
|
|
|
149
206
|
if (!email || typeof email !== 'string') {
|
|
150
207
|
return reply.code(400).send({ error: 'Email required for account creation' });
|
|
151
208
|
}
|
|
152
|
-
if (!password
|
|
153
|
-
return reply.code(400).send({ error: 'Password required
|
|
209
|
+
if (!password) {
|
|
210
|
+
return reply.code(400).send({ error: 'Password required' });
|
|
154
211
|
}
|
|
155
212
|
}
|
|
156
213
|
|
|
@@ -189,49 +246,8 @@ export async function handleCreatePod(request, reply) {
|
|
|
189
246
|
const issuer = baseUri + '/';
|
|
190
247
|
|
|
191
248
|
try {
|
|
192
|
-
//
|
|
193
|
-
await
|
|
194
|
-
await storage.createContainer(`${podPath}inbox/`);
|
|
195
|
-
await storage.createContainer(`${podPath}public/`);
|
|
196
|
-
await storage.createContainer(`${podPath}private/`);
|
|
197
|
-
await storage.createContainer(`${podPath}settings/`);
|
|
198
|
-
|
|
199
|
-
// Generate and write WebID profile as index.html at pod root
|
|
200
|
-
const profileHtml = generateProfile({ webId, name, podUri, issuer });
|
|
201
|
-
await storage.write(`${podPath}index.html`, profileHtml);
|
|
202
|
-
|
|
203
|
-
// Generate and write preferences
|
|
204
|
-
const prefs = generatePreferences({ webId, podUri });
|
|
205
|
-
await storage.write(`${podPath}settings/prefs`, serialize(prefs));
|
|
206
|
-
|
|
207
|
-
// Generate and write type indexes
|
|
208
|
-
const publicTypeIndex = generateTypeIndex(`${podUri}settings/publicTypeIndex`);
|
|
209
|
-
await storage.write(`${podPath}settings/publicTypeIndex`, serialize(publicTypeIndex));
|
|
210
|
-
|
|
211
|
-
const privateTypeIndex = generateTypeIndex(`${podUri}settings/privateTypeIndex`);
|
|
212
|
-
await storage.write(`${podPath}settings/privateTypeIndex`, serialize(privateTypeIndex));
|
|
213
|
-
|
|
214
|
-
// Create default ACL files
|
|
215
|
-
// Pod root: owner full control, public read
|
|
216
|
-
const rootAcl = generateOwnerAcl(podUri, webId, true);
|
|
217
|
-
await storage.write(`${podPath}.acl`, serializeAcl(rootAcl));
|
|
218
|
-
|
|
219
|
-
// Private folder: owner only (no public)
|
|
220
|
-
const privateAcl = generatePrivateAcl(`${podUri}private/`, webId);
|
|
221
|
-
await storage.write(`${podPath}private/.acl`, serializeAcl(privateAcl));
|
|
222
|
-
|
|
223
|
-
// Settings folder: owner only
|
|
224
|
-
const settingsAcl = generatePrivateAcl(`${podUri}settings/`, webId);
|
|
225
|
-
await storage.write(`${podPath}settings/.acl`, serializeAcl(settingsAcl));
|
|
226
|
-
|
|
227
|
-
// Inbox: owner full, public append
|
|
228
|
-
const inboxAcl = generateInboxAcl(`${podUri}inbox/`, webId);
|
|
229
|
-
await storage.write(`${podPath}inbox/.acl`, serializeAcl(inboxAcl));
|
|
230
|
-
|
|
231
|
-
// Public folder: owner full, public read (with inheritance)
|
|
232
|
-
const publicAcl = generatePublicFolderAcl(`${podUri}public/`, webId);
|
|
233
|
-
await storage.write(`${podPath}public/.acl`, serializeAcl(publicAcl));
|
|
234
|
-
|
|
249
|
+
// Use shared pod creation function
|
|
250
|
+
await createPodStructure(name, webId, baseUri);
|
|
235
251
|
} catch (err) {
|
|
236
252
|
console.error('Pod creation error:', err);
|
|
237
253
|
// Cleanup on failure
|
|
@@ -249,7 +265,7 @@ export async function handleCreatePod(request, reply) {
|
|
|
249
265
|
if (idpEnabled) {
|
|
250
266
|
try {
|
|
251
267
|
const { createAccount } = await import('../idp/accounts.js');
|
|
252
|
-
await createAccount({ email, password, webId, podName: name });
|
|
268
|
+
await createAccount({ username: name, email, password, webId, podName: name });
|
|
253
269
|
|
|
254
270
|
return reply.code(201).send({
|
|
255
271
|
name,
|
package/src/idp/accounts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Account management for the Identity Provider
|
|
3
|
-
* Handles user accounts with
|
|
3
|
+
* Handles user accounts with username/password authentication
|
|
4
|
+
* Email is optional - internally uses username@jss if not provided
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import bcrypt from 'bcrypt';
|
|
@@ -8,6 +9,9 @@ import crypto from 'crypto';
|
|
|
8
9
|
import fs from 'fs-extra';
|
|
9
10
|
import path from 'path';
|
|
10
11
|
|
|
12
|
+
// Internal domain for generated emails
|
|
13
|
+
const INTERNAL_DOMAIN = 'jss';
|
|
14
|
+
|
|
11
15
|
/**
|
|
12
16
|
* Get accounts directory (computed dynamically to support changing DATA_ROOT)
|
|
13
17
|
*/
|
|
@@ -16,6 +20,10 @@ function getAccountsDir() {
|
|
|
16
20
|
return path.join(dataRoot, '.idp', 'accounts');
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
function getUsernameIndexPath() {
|
|
24
|
+
return path.join(getAccountsDir(), '_username_index.json');
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
function getEmailIndexPath() {
|
|
20
28
|
return path.join(getAccountsDir(), '_email_index.json');
|
|
21
29
|
}
|
|
@@ -55,21 +63,34 @@ async function saveIndex(indexPath, index) {
|
|
|
55
63
|
/**
|
|
56
64
|
* Create a new user account
|
|
57
65
|
* @param {object} options - Account options
|
|
58
|
-
* @param {string} options.
|
|
66
|
+
* @param {string} options.username - Username (typically same as podName)
|
|
59
67
|
* @param {string} options.password - Plain text password
|
|
60
68
|
* @param {string} options.webId - User's WebID URI
|
|
61
69
|
* @param {string} options.podName - Pod name
|
|
70
|
+
* @param {string} [options.email] - Optional email (defaults to username@jss)
|
|
62
71
|
* @returns {Promise<object>} - Created account (without password)
|
|
63
72
|
*/
|
|
64
|
-
export async function createAccount({
|
|
73
|
+
export async function createAccount({ username, password, webId, podName, email }) {
|
|
65
74
|
await ensureDir();
|
|
66
75
|
|
|
67
|
-
const
|
|
76
|
+
const normalizedUsername = username.toLowerCase().trim();
|
|
77
|
+
// Use provided email or generate internal one
|
|
78
|
+
const normalizedEmail = email
|
|
79
|
+
? email.toLowerCase().trim()
|
|
80
|
+
: `${normalizedUsername}@${INTERNAL_DOMAIN}`;
|
|
81
|
+
|
|
82
|
+
// Check username uniqueness
|
|
83
|
+
const existingByUsername = await findByUsername(normalizedUsername);
|
|
84
|
+
if (existingByUsername) {
|
|
85
|
+
throw new Error('Username already taken');
|
|
86
|
+
}
|
|
68
87
|
|
|
69
|
-
// Check email uniqueness
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
// Check email uniqueness (if real email provided)
|
|
89
|
+
if (email) {
|
|
90
|
+
const existingByEmail = await findByEmail(normalizedEmail);
|
|
91
|
+
if (existingByEmail) {
|
|
92
|
+
throw new Error('Email already registered');
|
|
93
|
+
}
|
|
73
94
|
}
|
|
74
95
|
|
|
75
96
|
// Check webId uniqueness
|
|
@@ -84,6 +105,7 @@ export async function createAccount({ email, password, webId, podName }) {
|
|
|
84
105
|
|
|
85
106
|
const account = {
|
|
86
107
|
id,
|
|
108
|
+
username: normalizedUsername,
|
|
87
109
|
email: normalizedEmail,
|
|
88
110
|
passwordHash,
|
|
89
111
|
webId,
|
|
@@ -96,6 +118,11 @@ export async function createAccount({ email, password, webId, podName }) {
|
|
|
96
118
|
const accountPath = path.join(getAccountsDir(), `${id}.json`);
|
|
97
119
|
await fs.writeJson(accountPath, account, { spaces: 2 });
|
|
98
120
|
|
|
121
|
+
// Update username index
|
|
122
|
+
const usernameIndex = await loadIndex(getUsernameIndexPath());
|
|
123
|
+
usernameIndex[normalizedUsername] = id;
|
|
124
|
+
await saveIndex(getUsernameIndexPath(), usernameIndex);
|
|
125
|
+
|
|
99
126
|
// Update email index
|
|
100
127
|
const emailIndex = await loadIndex(getEmailIndexPath());
|
|
101
128
|
emailIndex[normalizedEmail] = id;
|
|
@@ -112,13 +139,17 @@ export async function createAccount({ email, password, webId, podName }) {
|
|
|
112
139
|
}
|
|
113
140
|
|
|
114
141
|
/**
|
|
115
|
-
* Authenticate a user with email and password
|
|
116
|
-
* @param {string}
|
|
142
|
+
* Authenticate a user with username/email and password
|
|
143
|
+
* @param {string} identifier - Username or email
|
|
117
144
|
* @param {string} password - Plain text password
|
|
118
145
|
* @returns {Promise<object|null>} - Account if valid, null if invalid
|
|
119
146
|
*/
|
|
120
|
-
export async function authenticate(
|
|
121
|
-
|
|
147
|
+
export async function authenticate(identifier, password) {
|
|
148
|
+
// Try to find by username first, then by email
|
|
149
|
+
let account = await findByUsername(identifier);
|
|
150
|
+
if (!account) {
|
|
151
|
+
account = await findByEmail(identifier);
|
|
152
|
+
}
|
|
122
153
|
if (!account) return null;
|
|
123
154
|
|
|
124
155
|
const valid = await bcrypt.compare(password, account.passwordHash);
|
|
@@ -149,6 +180,19 @@ export async function findById(id) {
|
|
|
149
180
|
}
|
|
150
181
|
}
|
|
151
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Find an account by username
|
|
185
|
+
* @param {string} username - Username
|
|
186
|
+
* @returns {Promise<object|null>} - Account or null
|
|
187
|
+
*/
|
|
188
|
+
export async function findByUsername(username) {
|
|
189
|
+
const normalizedUsername = username.toLowerCase().trim();
|
|
190
|
+
const usernameIndex = await loadIndex(getUsernameIndexPath());
|
|
191
|
+
const id = usernameIndex[normalizedUsername];
|
|
192
|
+
if (!id) return null;
|
|
193
|
+
return findById(id);
|
|
194
|
+
}
|
|
195
|
+
|
|
152
196
|
/**
|
|
153
197
|
* Find an account by email
|
|
154
198
|
* @param {string} email - User email
|
|
@@ -201,6 +245,12 @@ export async function deleteAccount(id) {
|
|
|
201
245
|
if (!account) return;
|
|
202
246
|
|
|
203
247
|
// Remove from indexes
|
|
248
|
+
if (account.username) {
|
|
249
|
+
const usernameIndex = await loadIndex(getUsernameIndexPath());
|
|
250
|
+
delete usernameIndex[account.username];
|
|
251
|
+
await saveIndex(getUsernameIndexPath(), usernameIndex);
|
|
252
|
+
}
|
|
253
|
+
|
|
204
254
|
const emailIndex = await loadIndex(getEmailIndexPath());
|
|
205
255
|
delete emailIndex[account.email];
|
|
206
256
|
await saveIndex(getEmailIndexPath(), emailIndex);
|
package/src/idp/index.js
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
handleLogin,
|
|
12
12
|
handleConsent,
|
|
13
13
|
handleAbort,
|
|
14
|
+
handleRegisterGet,
|
|
15
|
+
handleRegisterPost,
|
|
14
16
|
} from './interactions.js';
|
|
15
17
|
import {
|
|
16
18
|
handleCredentials,
|
|
@@ -220,6 +222,15 @@ export async function idpPlugin(fastify, options) {
|
|
|
220
222
|
return handleAbort(request, reply, provider);
|
|
221
223
|
});
|
|
222
224
|
|
|
225
|
+
// Registration routes
|
|
226
|
+
fastify.get('/idp/register', async (request, reply) => {
|
|
227
|
+
return handleRegisterGet(request, reply);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
fastify.post('/idp/register', async (request, reply) => {
|
|
231
|
+
return handleRegisterPost(request, reply, issuer);
|
|
232
|
+
});
|
|
233
|
+
|
|
223
234
|
fastify.log.info(`IdP initialized with issuer: ${issuer}`);
|
|
224
235
|
}
|
|
225
236
|
|