javascript-solid-server 0.0.10 → 0.0.12
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 +11 -1
- package/README.md +110 -9
- package/bin/jss.js +226 -0
- package/package.json +11 -4
- package/src/config.js +192 -0
- package/src/handlers/container.js +35 -2
- package/src/handlers/resource.js +3 -1
- package/src/idp/accounts.js +258 -0
- package/src/idp/adapter.js +204 -0
- package/src/idp/index.js +118 -0
- package/src/idp/interactions.js +180 -0
- package/src/idp/keys.js +157 -0
- package/src/idp/provider.js +246 -0
- package/src/idp/views.js +295 -0
- package/src/server.js +39 -12
- package/test/conformance.test.js +349 -0
- package/test/idp.test.js +258 -0
package/src/idp/views.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML templates for IdP login/consent pages
|
|
3
|
+
* Minimal, functional design
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const styles = `
|
|
7
|
+
* { box-sizing: border-box; }
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
+
background: #f5f5f5;
|
|
11
|
+
margin: 0;
|
|
12
|
+
padding: 40px 20px;
|
|
13
|
+
min-height: 100vh;
|
|
14
|
+
}
|
|
15
|
+
.container {
|
|
16
|
+
max-width: 400px;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
background: white;
|
|
19
|
+
border-radius: 12px;
|
|
20
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
21
|
+
padding: 40px;
|
|
22
|
+
}
|
|
23
|
+
h1 {
|
|
24
|
+
margin: 0 0 8px 0;
|
|
25
|
+
font-size: 24px;
|
|
26
|
+
color: #333;
|
|
27
|
+
}
|
|
28
|
+
.subtitle {
|
|
29
|
+
color: #666;
|
|
30
|
+
margin: 0 0 30px 0;
|
|
31
|
+
font-size: 14px;
|
|
32
|
+
}
|
|
33
|
+
.client-info {
|
|
34
|
+
background: #f8f9fa;
|
|
35
|
+
border-radius: 8px;
|
|
36
|
+
padding: 16px;
|
|
37
|
+
margin-bottom: 24px;
|
|
38
|
+
}
|
|
39
|
+
.client-name {
|
|
40
|
+
font-weight: 600;
|
|
41
|
+
color: #333;
|
|
42
|
+
}
|
|
43
|
+
.client-uri {
|
|
44
|
+
font-size: 12px;
|
|
45
|
+
color: #666;
|
|
46
|
+
word-break: break-all;
|
|
47
|
+
}
|
|
48
|
+
label {
|
|
49
|
+
display: block;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
font-weight: 500;
|
|
52
|
+
color: #333;
|
|
53
|
+
margin-bottom: 6px;
|
|
54
|
+
}
|
|
55
|
+
input[type="email"],
|
|
56
|
+
input[type="password"] {
|
|
57
|
+
width: 100%;
|
|
58
|
+
padding: 12px;
|
|
59
|
+
border: 1px solid #ddd;
|
|
60
|
+
border-radius: 8px;
|
|
61
|
+
font-size: 16px;
|
|
62
|
+
margin-bottom: 16px;
|
|
63
|
+
transition: border-color 0.2s;
|
|
64
|
+
}
|
|
65
|
+
input:focus {
|
|
66
|
+
outline: none;
|
|
67
|
+
border-color: #0066cc;
|
|
68
|
+
}
|
|
69
|
+
.error {
|
|
70
|
+
background: #fee;
|
|
71
|
+
border: 1px solid #fcc;
|
|
72
|
+
color: #c00;
|
|
73
|
+
padding: 12px;
|
|
74
|
+
border-radius: 8px;
|
|
75
|
+
margin-bottom: 20px;
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
}
|
|
78
|
+
.btn {
|
|
79
|
+
display: inline-block;
|
|
80
|
+
padding: 12px 24px;
|
|
81
|
+
border-radius: 8px;
|
|
82
|
+
font-size: 16px;
|
|
83
|
+
font-weight: 500;
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
border: none;
|
|
86
|
+
text-decoration: none;
|
|
87
|
+
text-align: center;
|
|
88
|
+
transition: background-color 0.2s;
|
|
89
|
+
}
|
|
90
|
+
.btn-primary {
|
|
91
|
+
background: #0066cc;
|
|
92
|
+
color: white;
|
|
93
|
+
width: 100%;
|
|
94
|
+
}
|
|
95
|
+
.btn-primary:hover {
|
|
96
|
+
background: #0052a3;
|
|
97
|
+
}
|
|
98
|
+
.btn-secondary {
|
|
99
|
+
background: #f0f0f0;
|
|
100
|
+
color: #333;
|
|
101
|
+
margin-top: 12px;
|
|
102
|
+
width: 100%;
|
|
103
|
+
}
|
|
104
|
+
.btn-secondary:hover {
|
|
105
|
+
background: #e0e0e0;
|
|
106
|
+
}
|
|
107
|
+
.scopes {
|
|
108
|
+
margin: 20px 0;
|
|
109
|
+
}
|
|
110
|
+
.scope {
|
|
111
|
+
display: flex;
|
|
112
|
+
align-items: center;
|
|
113
|
+
padding: 12px;
|
|
114
|
+
background: #f8f9fa;
|
|
115
|
+
border-radius: 8px;
|
|
116
|
+
margin-bottom: 8px;
|
|
117
|
+
}
|
|
118
|
+
.scope-icon {
|
|
119
|
+
width: 24px;
|
|
120
|
+
height: 24px;
|
|
121
|
+
margin-right: 12px;
|
|
122
|
+
opacity: 0.6;
|
|
123
|
+
}
|
|
124
|
+
.scope-name {
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
}
|
|
127
|
+
.scope-desc {
|
|
128
|
+
font-size: 12px;
|
|
129
|
+
color: #666;
|
|
130
|
+
}
|
|
131
|
+
.actions {
|
|
132
|
+
margin-top: 24px;
|
|
133
|
+
}
|
|
134
|
+
.logo {
|
|
135
|
+
text-align: center;
|
|
136
|
+
margin-bottom: 24px;
|
|
137
|
+
}
|
|
138
|
+
.logo svg {
|
|
139
|
+
width: 48px;
|
|
140
|
+
height: 48px;
|
|
141
|
+
}
|
|
142
|
+
`;
|
|
143
|
+
|
|
144
|
+
const solidLogo = `
|
|
145
|
+
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
146
|
+
<circle cx="50" cy="50" r="45" fill="#7C4DFF" />
|
|
147
|
+
<path d="M30 50 L45 65 L70 40" stroke="white" stroke-width="8" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
148
|
+
</svg>
|
|
149
|
+
`;
|
|
150
|
+
|
|
151
|
+
const scopeDescriptions = {
|
|
152
|
+
openid: 'Access your identity',
|
|
153
|
+
webid: 'Access your WebID',
|
|
154
|
+
profile: 'Access your name',
|
|
155
|
+
email: 'Access your email address',
|
|
156
|
+
offline_access: 'Stay logged in',
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Login page HTML
|
|
161
|
+
*/
|
|
162
|
+
export function loginPage(uid, clientId, error = null) {
|
|
163
|
+
return `
|
|
164
|
+
<!DOCTYPE html>
|
|
165
|
+
<html lang="en">
|
|
166
|
+
<head>
|
|
167
|
+
<meta charset="UTF-8">
|
|
168
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
169
|
+
<title>Sign In - Solid IdP</title>
|
|
170
|
+
<style>${styles}</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
<div class="container">
|
|
174
|
+
<div class="logo">${solidLogo}</div>
|
|
175
|
+
<h1>Sign In</h1>
|
|
176
|
+
<p class="subtitle">Sign in to your Solid Pod</p>
|
|
177
|
+
|
|
178
|
+
${error ? `<div class="error">${escapeHtml(error)}</div>` : ''}
|
|
179
|
+
|
|
180
|
+
<form method="POST" action="/idp/interaction/${uid}/login">
|
|
181
|
+
<label for="email">Email</label>
|
|
182
|
+
<input type="email" id="email" name="email" required autofocus placeholder="you@example.com">
|
|
183
|
+
|
|
184
|
+
<label for="password">Password</label>
|
|
185
|
+
<input type="password" id="password" name="password" required placeholder="Your password">
|
|
186
|
+
|
|
187
|
+
<button type="submit" class="btn btn-primary">Sign In</button>
|
|
188
|
+
</form>
|
|
189
|
+
|
|
190
|
+
<form method="POST" action="/idp/interaction/${uid}/abort">
|
|
191
|
+
<button type="submit" class="btn btn-secondary">Cancel</button>
|
|
192
|
+
</form>
|
|
193
|
+
</div>
|
|
194
|
+
</body>
|
|
195
|
+
</html>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Consent page HTML
|
|
201
|
+
*/
|
|
202
|
+
export function consentPage(uid, client, params, account) {
|
|
203
|
+
const scopes = (params.scope || 'openid').split(' ').filter(Boolean);
|
|
204
|
+
const clientName = client?.clientName || client?.client_id || 'Unknown App';
|
|
205
|
+
const clientUri = client?.clientUri || client?.redirect_uris?.[0] || '';
|
|
206
|
+
|
|
207
|
+
const scopeItems = scopes.map(scope => `
|
|
208
|
+
<div class="scope">
|
|
209
|
+
<div class="scope-icon">✓</div>
|
|
210
|
+
<div>
|
|
211
|
+
<div class="scope-name">${escapeHtml(scope)}</div>
|
|
212
|
+
<div class="scope-desc">${escapeHtml(scopeDescriptions[scope] || 'Access requested')}</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
`).join('');
|
|
216
|
+
|
|
217
|
+
return `
|
|
218
|
+
<!DOCTYPE html>
|
|
219
|
+
<html lang="en">
|
|
220
|
+
<head>
|
|
221
|
+
<meta charset="UTF-8">
|
|
222
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
223
|
+
<title>Authorize - Solid IdP</title>
|
|
224
|
+
<style>${styles}</style>
|
|
225
|
+
</head>
|
|
226
|
+
<body>
|
|
227
|
+
<div class="container">
|
|
228
|
+
<div class="logo">${solidLogo}</div>
|
|
229
|
+
<h1>Authorize Access</h1>
|
|
230
|
+
<p class="subtitle">Allow this app to access your data?</p>
|
|
231
|
+
|
|
232
|
+
<div class="client-info">
|
|
233
|
+
<div class="client-name">${escapeHtml(clientName)}</div>
|
|
234
|
+
${clientUri ? `<div class="client-uri">${escapeHtml(clientUri)}</div>` : ''}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
${account ? `<p>Signed in as <strong>${escapeHtml(account.email)}</strong></p>` : ''}
|
|
238
|
+
|
|
239
|
+
<div class="scopes">
|
|
240
|
+
<label>This app is requesting access to:</label>
|
|
241
|
+
${scopeItems}
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div class="actions">
|
|
245
|
+
<form method="POST" action="/idp/interaction/${uid}/confirm">
|
|
246
|
+
<button type="submit" class="btn btn-primary">Allow Access</button>
|
|
247
|
+
</form>
|
|
248
|
+
|
|
249
|
+
<form method="POST" action="/idp/interaction/${uid}/abort">
|
|
250
|
+
<button type="submit" class="btn btn-secondary">Deny</button>
|
|
251
|
+
</form>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</body>
|
|
255
|
+
</html>
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Error page HTML
|
|
261
|
+
*/
|
|
262
|
+
export function errorPage(title, message) {
|
|
263
|
+
return `
|
|
264
|
+
<!DOCTYPE html>
|
|
265
|
+
<html lang="en">
|
|
266
|
+
<head>
|
|
267
|
+
<meta charset="UTF-8">
|
|
268
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
269
|
+
<title>Error - Solid IdP</title>
|
|
270
|
+
<style>${styles}</style>
|
|
271
|
+
</head>
|
|
272
|
+
<body>
|
|
273
|
+
<div class="container">
|
|
274
|
+
<div class="logo">${solidLogo}</div>
|
|
275
|
+
<h1 style="color: #c00;">${escapeHtml(title)}</h1>
|
|
276
|
+
<p>${escapeHtml(message)}</p>
|
|
277
|
+
<a href="/" class="btn btn-secondary">Go Home</a>
|
|
278
|
+
</div>
|
|
279
|
+
</body>
|
|
280
|
+
</html>
|
|
281
|
+
`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Escape HTML to prevent XSS
|
|
286
|
+
*/
|
|
287
|
+
function escapeHtml(text) {
|
|
288
|
+
if (!text) return '';
|
|
289
|
+
return String(text)
|
|
290
|
+
.replace(/&/g, '&')
|
|
291
|
+
.replace(/</g, '<')
|
|
292
|
+
.replace(/>/g, '>')
|
|
293
|
+
.replace(/"/g, '"')
|
|
294
|
+
.replace(/'/g, ''');
|
|
295
|
+
}
|
package/src/server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { handlePost, handleCreatePod } from './handlers/container.js';
|
|
|
4
4
|
import { getCorsHeaders } from './ldp/headers.js';
|
|
5
5
|
import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
6
6
|
import { notificationsPlugin } from './notifications/index.js';
|
|
7
|
+
import { idpPlugin } from './idp/index.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Create and configure Fastify server
|
|
@@ -11,19 +12,42 @@ import { notificationsPlugin } from './notifications/index.js';
|
|
|
11
12
|
* @param {boolean} options.logger - Enable logging (default true)
|
|
12
13
|
* @param {boolean} options.conneg - Enable content negotiation for RDF (default false)
|
|
13
14
|
* @param {boolean} options.notifications - Enable WebSocket notifications (default false)
|
|
15
|
+
* @param {boolean} options.idp - Enable built-in Identity Provider (default false)
|
|
16
|
+
* @param {string} options.idpIssuer - IdP issuer URL (default: server URL)
|
|
17
|
+
* @param {object} options.ssl - SSL configuration { key, cert } (default null)
|
|
18
|
+
* @param {string} options.root - Data directory path (default from env or ./data)
|
|
14
19
|
*/
|
|
15
20
|
export function createServer(options = {}) {
|
|
16
21
|
// Content negotiation is OFF by default - we're a JSON-LD native server
|
|
17
22
|
const connegEnabled = options.conneg ?? false;
|
|
18
23
|
// WebSocket notifications are OFF by default
|
|
19
24
|
const notificationsEnabled = options.notifications ?? false;
|
|
25
|
+
// Identity Provider is OFF by default
|
|
26
|
+
const idpEnabled = options.idp ?? false;
|
|
27
|
+
const idpIssuer = options.idpIssuer;
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
// Set data root via environment variable if provided
|
|
30
|
+
if (options.root) {
|
|
31
|
+
process.env.DATA_ROOT = options.root;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fastify options
|
|
35
|
+
const fastifyOptions = {
|
|
22
36
|
logger: options.logger ?? true,
|
|
23
37
|
trustProxy: true,
|
|
24
38
|
// Handle raw body for non-JSON content
|
|
25
39
|
bodyLimit: 10 * 1024 * 1024 // 10MB
|
|
26
|
-
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Add HTTPS support if SSL config provided
|
|
43
|
+
if (options.ssl && options.ssl.key && options.ssl.cert) {
|
|
44
|
+
fastifyOptions.https = {
|
|
45
|
+
key: options.ssl.key,
|
|
46
|
+
cert: options.ssl.cert,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fastify = Fastify(fastifyOptions);
|
|
27
51
|
|
|
28
52
|
// Add raw body parser for all content types
|
|
29
53
|
fastify.addContentTypeParser('*', { parseAs: 'buffer' }, (req, body, done) => {
|
|
@@ -33,9 +57,11 @@ export function createServer(options = {}) {
|
|
|
33
57
|
// Attach server config to requests
|
|
34
58
|
fastify.decorateRequest('connegEnabled', null);
|
|
35
59
|
fastify.decorateRequest('notificationsEnabled', null);
|
|
60
|
+
fastify.decorateRequest('idpEnabled', null);
|
|
36
61
|
fastify.addHook('onRequest', async (request) => {
|
|
37
62
|
request.connegEnabled = connegEnabled;
|
|
38
63
|
request.notificationsEnabled = notificationsEnabled;
|
|
64
|
+
request.idpEnabled = idpEnabled;
|
|
39
65
|
});
|
|
40
66
|
|
|
41
67
|
// Register WebSocket notifications plugin if enabled
|
|
@@ -43,6 +69,11 @@ export function createServer(options = {}) {
|
|
|
43
69
|
fastify.register(notificationsPlugin);
|
|
44
70
|
}
|
|
45
71
|
|
|
72
|
+
// Register Identity Provider plugin if enabled
|
|
73
|
+
if (idpEnabled) {
|
|
74
|
+
fastify.register(idpPlugin, { issuer: idpIssuer });
|
|
75
|
+
}
|
|
76
|
+
|
|
46
77
|
// Global CORS preflight
|
|
47
78
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
48
79
|
// Add CORS headers to all responses
|
|
@@ -54,21 +85,17 @@ export function createServer(options = {}) {
|
|
|
54
85
|
const wsProtocol = request.protocol === 'https' ? 'wss' : 'ws';
|
|
55
86
|
reply.header('Updates-Via', `${wsProtocol}://${request.hostname}/.notifications`);
|
|
56
87
|
}
|
|
57
|
-
|
|
58
|
-
// Handle preflight OPTIONS
|
|
59
|
-
if (request.method === 'OPTIONS') {
|
|
60
|
-
// Add Allow header for LDP compliance
|
|
61
|
-
reply.header('Allow', 'GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS');
|
|
62
|
-
reply.code(204).send();
|
|
63
|
-
return reply;
|
|
64
|
-
}
|
|
88
|
+
// Note: OPTIONS requests are handled by handleOptions to include Accept-* headers
|
|
65
89
|
});
|
|
66
90
|
|
|
67
91
|
// Authorization hook - check WAC permissions
|
|
68
92
|
// Skip for pod creation endpoint (needs special handling)
|
|
69
93
|
fastify.addHook('preHandler', async (request, reply) => {
|
|
70
|
-
// Skip auth for pod creation and
|
|
71
|
-
if (request.url === '/.pods' ||
|
|
94
|
+
// Skip auth for pod creation, OPTIONS, IdP routes, and well-known endpoints
|
|
95
|
+
if (request.url === '/.pods' ||
|
|
96
|
+
request.method === 'OPTIONS' ||
|
|
97
|
+
request.url.startsWith('/idp/') ||
|
|
98
|
+
request.url.startsWith('/.well-known/')) {
|
|
72
99
|
return;
|
|
73
100
|
}
|
|
74
101
|
|