javascript-solid-server 0.0.12 → 0.0.13
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 +2 -1
- package/README.md +24 -2
- package/data/alice/.acl +50 -0
- package/data/alice/inbox/.acl +50 -0
- package/data/alice/index.html +80 -0
- package/data/alice/private/.acl +32 -0
- package/data/alice/public/test.json +1 -0
- package/data/alice/settings/.acl +32 -0
- package/data/alice/settings/prefs +17 -0
- package/data/alice/settings/privateTypeIndex +7 -0
- package/data/alice/settings/publicTypeIndex +7 -0
- package/data/bob/.acl +50 -0
- package/data/bob/inbox/.acl +50 -0
- package/data/bob/index.html +80 -0
- package/data/bob/private/.acl +32 -0
- package/data/bob/settings/.acl +32 -0
- package/data/bob/settings/prefs +17 -0
- package/data/bob/settings/privateTypeIndex +7 -0
- package/data/bob/settings/publicTypeIndex +7 -0
- package/package.json +2 -1
- package/scripts/test-cth-compat.js +369 -0
- package/src/idp/credentials.js +225 -0
- package/src/idp/index.js +19 -2
- package/test/idp.test.js +169 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CTH Compatibility Test Script
|
|
4
|
+
*
|
|
5
|
+
* Tests that JSS is configured correctly for the Solid Conformance Test Harness.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node scripts/test-cth-compat.js [--port 3000] [--run-cth]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createServer } from '../src/server.js';
|
|
12
|
+
import fs from 'fs-extra';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
|
|
18
|
+
// Configuration
|
|
19
|
+
const PORT = parseInt(process.argv.find(a => a.startsWith('--port='))?.split('=')[1] || '3456');
|
|
20
|
+
const RUN_CTH = process.argv.includes('--run-cth');
|
|
21
|
+
const BASE_URL = `http://localhost:${PORT}`;
|
|
22
|
+
const DATA_DIR = path.join(__dirname, '../.test-cth-data');
|
|
23
|
+
|
|
24
|
+
// Test users
|
|
25
|
+
const ALICE = {
|
|
26
|
+
name: 'alice',
|
|
27
|
+
email: 'alice@test.local',
|
|
28
|
+
password: 'alicepassword123',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const BOB = {
|
|
32
|
+
name: 'bob',
|
|
33
|
+
email: 'bob@test.local',
|
|
34
|
+
password: 'bobpassword123',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Colors for output
|
|
38
|
+
const GREEN = '\x1b[32m';
|
|
39
|
+
const RED = '\x1b[31m';
|
|
40
|
+
const YELLOW = '\x1b[33m';
|
|
41
|
+
const CYAN = '\x1b[36m';
|
|
42
|
+
const RESET = '\x1b[0m';
|
|
43
|
+
|
|
44
|
+
function log(msg, color = RESET) {
|
|
45
|
+
console.log(`${color}${msg}${RESET}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pass(msg) {
|
|
49
|
+
log(` ✓ ${msg}`, GREEN);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fail(msg) {
|
|
53
|
+
log(` ✗ ${msg}`, RED);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function info(msg) {
|
|
57
|
+
log(` ℹ ${msg}`, CYAN);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function main() {
|
|
61
|
+
log('\n=== JSS CTH Compatibility Test ===\n', CYAN);
|
|
62
|
+
|
|
63
|
+
let server;
|
|
64
|
+
let passed = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Clean up and prepare
|
|
69
|
+
await fs.remove(DATA_DIR);
|
|
70
|
+
await fs.ensureDir(DATA_DIR);
|
|
71
|
+
|
|
72
|
+
// Start server with IdP
|
|
73
|
+
log('Starting server with IdP enabled...', YELLOW);
|
|
74
|
+
server = createServer({
|
|
75
|
+
logger: false,
|
|
76
|
+
root: DATA_DIR,
|
|
77
|
+
idp: true,
|
|
78
|
+
idpIssuer: BASE_URL,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await server.listen({ port: PORT, host: 'localhost' });
|
|
82
|
+
pass(`Server running at ${BASE_URL}`);
|
|
83
|
+
passed++;
|
|
84
|
+
|
|
85
|
+
// Test 1: OIDC Discovery
|
|
86
|
+
log('\n1. OIDC Discovery', YELLOW);
|
|
87
|
+
{
|
|
88
|
+
const res = await fetch(`${BASE_URL}/.well-known/openid-configuration`);
|
|
89
|
+
if (res.status === 200) {
|
|
90
|
+
const config = await res.json();
|
|
91
|
+
if (config.issuer === BASE_URL) {
|
|
92
|
+
pass('/.well-known/openid-configuration returns valid config');
|
|
93
|
+
passed++;
|
|
94
|
+
} else {
|
|
95
|
+
fail(`Issuer mismatch: expected ${BASE_URL}, got ${config.issuer}`);
|
|
96
|
+
failed++;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
fail(`OIDC discovery returned ${res.status}`);
|
|
100
|
+
failed++;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Test 2: JWKS
|
|
105
|
+
log('\n2. JWKS Endpoint', YELLOW);
|
|
106
|
+
{
|
|
107
|
+
const res = await fetch(`${BASE_URL}/.well-known/jwks.json`);
|
|
108
|
+
if (res.status === 200) {
|
|
109
|
+
const jwks = await res.json();
|
|
110
|
+
if (jwks.keys?.length > 0) {
|
|
111
|
+
pass('/.well-known/jwks.json returns keys');
|
|
112
|
+
passed++;
|
|
113
|
+
} else {
|
|
114
|
+
fail('JWKS has no keys');
|
|
115
|
+
failed++;
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
fail(`JWKS returned ${res.status}`);
|
|
119
|
+
failed++;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Test 3: Create Alice
|
|
124
|
+
log('\n3. Create Test User: Alice', YELLOW);
|
|
125
|
+
let aliceWebId, aliceToken;
|
|
126
|
+
{
|
|
127
|
+
const res = await fetch(`${BASE_URL}/.pods`, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: JSON.stringify(ALICE),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (res.status === 201) {
|
|
134
|
+
const data = await res.json();
|
|
135
|
+
aliceWebId = data.webId;
|
|
136
|
+
pass(`Created Alice: ${aliceWebId}`);
|
|
137
|
+
passed++;
|
|
138
|
+
} else {
|
|
139
|
+
const err = await res.text();
|
|
140
|
+
fail(`Failed to create Alice: ${res.status} - ${err}`);
|
|
141
|
+
failed++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Test 4: Create Bob
|
|
146
|
+
log('\n4. Create Test User: Bob', YELLOW);
|
|
147
|
+
let bobWebId;
|
|
148
|
+
{
|
|
149
|
+
const res = await fetch(`${BASE_URL}/.pods`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'Content-Type': 'application/json' },
|
|
152
|
+
body: JSON.stringify(BOB),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (res.status === 201) {
|
|
156
|
+
const data = await res.json();
|
|
157
|
+
bobWebId = data.webId;
|
|
158
|
+
pass(`Created Bob: ${bobWebId}`);
|
|
159
|
+
passed++;
|
|
160
|
+
} else {
|
|
161
|
+
const err = await res.text();
|
|
162
|
+
fail(`Failed to create Bob: ${res.status} - ${err}`);
|
|
163
|
+
failed++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Test 5: Credentials endpoint - JSON
|
|
168
|
+
log('\n5. Credentials Endpoint (JSON)', YELLOW);
|
|
169
|
+
{
|
|
170
|
+
const res = await fetch(`${BASE_URL}/idp/credentials`, {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({ email: ALICE.email, password: ALICE.password }),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (res.status === 200) {
|
|
177
|
+
const data = await res.json();
|
|
178
|
+
if (data.access_token && data.webid) {
|
|
179
|
+
aliceToken = data.access_token;
|
|
180
|
+
pass(`Got token for Alice (type: ${data.token_type})`);
|
|
181
|
+
passed++;
|
|
182
|
+
} else {
|
|
183
|
+
fail('Missing access_token or webid in response');
|
|
184
|
+
failed++;
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
const err = await res.text();
|
|
188
|
+
fail(`Credentials endpoint failed: ${res.status} - ${err}`);
|
|
189
|
+
failed++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Test 6: Credentials endpoint - Form encoded
|
|
194
|
+
log('\n6. Credentials Endpoint (Form-encoded)', YELLOW);
|
|
195
|
+
{
|
|
196
|
+
const res = await fetch(`${BASE_URL}/idp/credentials`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
199
|
+
body: `email=${encodeURIComponent(BOB.email)}&password=${encodeURIComponent(BOB.password)}`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (res.status === 200) {
|
|
203
|
+
const data = await res.json();
|
|
204
|
+
if (data.access_token) {
|
|
205
|
+
pass('Form-encoded credentials work');
|
|
206
|
+
passed++;
|
|
207
|
+
} else {
|
|
208
|
+
fail('Missing access_token in response');
|
|
209
|
+
failed++;
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
const err = await res.text();
|
|
213
|
+
fail(`Form-encoded credentials failed: ${res.status} - ${err}`);
|
|
214
|
+
failed++;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Test 7: Invalid credentials
|
|
219
|
+
log('\n7. Invalid Credentials Handling', YELLOW);
|
|
220
|
+
{
|
|
221
|
+
const res = await fetch(`${BASE_URL}/idp/credentials`, {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({ email: ALICE.email, password: 'wrongpassword' }),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (res.status === 401) {
|
|
228
|
+
pass('Returns 401 for invalid password');
|
|
229
|
+
passed++;
|
|
230
|
+
} else {
|
|
231
|
+
fail(`Expected 401, got ${res.status}`);
|
|
232
|
+
failed++;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Test 8: Token authentication
|
|
237
|
+
log('\n8. Token Authentication', YELLOW);
|
|
238
|
+
{
|
|
239
|
+
if (aliceToken) {
|
|
240
|
+
const res = await fetch(`${BASE_URL}/alice/`, {
|
|
241
|
+
headers: { 'Authorization': `Bearer ${aliceToken}` },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
if (res.status === 200) {
|
|
245
|
+
pass('Token grants access to own pod');
|
|
246
|
+
passed++;
|
|
247
|
+
} else {
|
|
248
|
+
fail(`Token auth failed: ${res.status}`);
|
|
249
|
+
failed++;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
fail('No token available to test');
|
|
253
|
+
failed++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Test 9: Write with token
|
|
258
|
+
log('\n9. Write with Token', YELLOW);
|
|
259
|
+
{
|
|
260
|
+
if (aliceToken) {
|
|
261
|
+
const res = await fetch(`${BASE_URL}/alice/public/test.json`, {
|
|
262
|
+
method: 'PUT',
|
|
263
|
+
headers: {
|
|
264
|
+
'Authorization': `Bearer ${aliceToken}`,
|
|
265
|
+
'Content-Type': 'application/ld+json',
|
|
266
|
+
},
|
|
267
|
+
body: JSON.stringify({ '@id': '#test', 'http://example.org/value': 42 }),
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if ([200, 201, 204].includes(res.status)) {
|
|
271
|
+
pass('Can write to pod with token');
|
|
272
|
+
passed++;
|
|
273
|
+
} else {
|
|
274
|
+
fail(`Write failed: ${res.status}`);
|
|
275
|
+
failed++;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
fail('No token available to test');
|
|
279
|
+
failed++;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Test 10: Public read
|
|
284
|
+
log('\n10. Public Read Access', YELLOW);
|
|
285
|
+
{
|
|
286
|
+
const res = await fetch(`${BASE_URL}/alice/public/test.json`);
|
|
287
|
+
|
|
288
|
+
if (res.status === 200) {
|
|
289
|
+
pass('Public resources are readable');
|
|
290
|
+
passed++;
|
|
291
|
+
} else {
|
|
292
|
+
fail(`Public read failed: ${res.status}`);
|
|
293
|
+
failed++;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Summary
|
|
298
|
+
log('\n=== Summary ===\n', CYAN);
|
|
299
|
+
log(`Passed: ${passed}`, GREEN);
|
|
300
|
+
if (failed > 0) {
|
|
301
|
+
log(`Failed: ${failed}`, RED);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Generate CTH env file
|
|
305
|
+
log('\n=== CTH Configuration ===\n', CYAN);
|
|
306
|
+
|
|
307
|
+
const envContent = `# CTH Environment for JSS
|
|
308
|
+
# Generated by test-cth-compat.js
|
|
309
|
+
|
|
310
|
+
SOLID_IDENTITY_PROVIDER=${BASE_URL}
|
|
311
|
+
RESOURCE_SERVER_ROOT=${BASE_URL}
|
|
312
|
+
TEST_CONTAINER=alice/public/
|
|
313
|
+
|
|
314
|
+
USERS_ALICE_WEBID=${aliceWebId || `${BASE_URL}/alice/#me`}
|
|
315
|
+
USERS_ALICE_USERNAME=${ALICE.email}
|
|
316
|
+
USERS_ALICE_PASSWORD=${ALICE.password}
|
|
317
|
+
|
|
318
|
+
USERS_BOB_WEBID=${bobWebId || `${BASE_URL}/bob/#me`}
|
|
319
|
+
USERS_BOB_USERNAME=${BOB.email}
|
|
320
|
+
USERS_BOB_PASSWORD=${BOB.password}
|
|
321
|
+
|
|
322
|
+
LOGIN_ENDPOINT=${BASE_URL}/idp/credentials
|
|
323
|
+
|
|
324
|
+
# For self-signed certs
|
|
325
|
+
ALLOW_SELF_SIGNED_CERTS=true
|
|
326
|
+
`;
|
|
327
|
+
|
|
328
|
+
const envPath = path.join(__dirname, '../cth.env');
|
|
329
|
+
await fs.writeFile(envPath, envContent);
|
|
330
|
+
info(`CTH env file written to: ${envPath}`);
|
|
331
|
+
|
|
332
|
+
log('\nTo run CTH:', YELLOW);
|
|
333
|
+
log(`
|
|
334
|
+
# Keep JSS running with IdP:
|
|
335
|
+
JSS_PORT=${PORT} jss start --idp
|
|
336
|
+
|
|
337
|
+
# In another terminal, run CTH:
|
|
338
|
+
docker run -i --rm \\
|
|
339
|
+
--network host \\
|
|
340
|
+
-v "$(pwd)"/reports:/reports \\
|
|
341
|
+
--env-file=cth.env \\
|
|
342
|
+
solidproject/conformance-test-harness \\
|
|
343
|
+
--output=/reports \\
|
|
344
|
+
--target=jss
|
|
345
|
+
`);
|
|
346
|
+
|
|
347
|
+
if (RUN_CTH) {
|
|
348
|
+
log('\nRunning CTH (this may take a while)...', YELLOW);
|
|
349
|
+
// Would spawn docker here, but keeping server running is complex
|
|
350
|
+
info('CTH auto-run not implemented yet. Run manually with commands above.');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
} catch (err) {
|
|
354
|
+
fail(`Error: ${err.message}`);
|
|
355
|
+
console.error(err);
|
|
356
|
+
failed++;
|
|
357
|
+
} finally {
|
|
358
|
+
// Cleanup
|
|
359
|
+
if (server) {
|
|
360
|
+
await server.close();
|
|
361
|
+
}
|
|
362
|
+
await fs.remove(DATA_DIR);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Exit with error code if any tests failed
|
|
366
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
main();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic credentials endpoint for CTH compatibility
|
|
3
|
+
* Allows obtaining tokens via email/password without browser interaction
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import * as jose from 'jose';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { authenticate, findByEmail } from './accounts.js';
|
|
9
|
+
import { getJwks } from './keys.js';
|
|
10
|
+
import { createToken as createSimpleToken } from '../auth/token.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Handle POST /idp/credentials
|
|
14
|
+
* Accepts email/password and returns access token
|
|
15
|
+
*
|
|
16
|
+
* Request body (JSON or form):
|
|
17
|
+
* - email: User email
|
|
18
|
+
* - password: User password
|
|
19
|
+
*
|
|
20
|
+
* Optional headers:
|
|
21
|
+
* - DPoP: DPoP proof JWT (for DPoP-bound tokens)
|
|
22
|
+
*
|
|
23
|
+
* Response:
|
|
24
|
+
* - access_token: JWT access token with webid claim
|
|
25
|
+
* - token_type: 'DPoP' or 'Bearer'
|
|
26
|
+
* - expires_in: Token lifetime in seconds
|
|
27
|
+
* - webid: User's WebID
|
|
28
|
+
*/
|
|
29
|
+
export async function handleCredentials(request, reply, issuer) {
|
|
30
|
+
// Parse body (JSON or form-encoded)
|
|
31
|
+
let email, password;
|
|
32
|
+
|
|
33
|
+
const contentType = request.headers['content-type'] || '';
|
|
34
|
+
let body = request.body;
|
|
35
|
+
|
|
36
|
+
// Convert buffer to string if needed
|
|
37
|
+
if (Buffer.isBuffer(body)) {
|
|
38
|
+
body = body.toString('utf-8');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (contentType.includes('application/json')) {
|
|
42
|
+
// JSON - Fastify parses this automatically
|
|
43
|
+
if (typeof body === 'string') {
|
|
44
|
+
try {
|
|
45
|
+
body = JSON.parse(body);
|
|
46
|
+
} catch {
|
|
47
|
+
// Not valid JSON
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
email = body?.email;
|
|
51
|
+
password = body?.password;
|
|
52
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
53
|
+
// Parse form-encoded body
|
|
54
|
+
if (typeof body === 'string') {
|
|
55
|
+
const params = new URLSearchParams(body);
|
|
56
|
+
email = params.get('email');
|
|
57
|
+
password = params.get('password');
|
|
58
|
+
} else if (typeof body === 'object') {
|
|
59
|
+
email = body?.email;
|
|
60
|
+
password = body?.password;
|
|
61
|
+
}
|
|
62
|
+
} else {
|
|
63
|
+
// Try to parse as object
|
|
64
|
+
if (typeof body === 'object') {
|
|
65
|
+
email = body?.email;
|
|
66
|
+
password = body?.password;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate input
|
|
71
|
+
if (!email || !password) {
|
|
72
|
+
return reply.code(400).send({
|
|
73
|
+
error: 'invalid_request',
|
|
74
|
+
error_description: 'Email and password are required',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Authenticate
|
|
79
|
+
const account = await authenticate(email, password);
|
|
80
|
+
|
|
81
|
+
if (!account) {
|
|
82
|
+
return reply.code(401).send({
|
|
83
|
+
error: 'invalid_grant',
|
|
84
|
+
error_description: 'Invalid email or password',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Check for DPoP header
|
|
89
|
+
const dpopHeader = request.headers['dpop'];
|
|
90
|
+
let dpopJkt = null;
|
|
91
|
+
|
|
92
|
+
if (dpopHeader) {
|
|
93
|
+
try {
|
|
94
|
+
// Validate DPoP proof and extract thumbprint
|
|
95
|
+
dpopJkt = await validateDpopProof(dpopHeader, 'POST', `${issuer}/idp/credentials`);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return reply.code(400).send({
|
|
98
|
+
error: 'invalid_dpop_proof',
|
|
99
|
+
error_description: err.message,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const expiresIn = 3600; // 1 hour
|
|
105
|
+
let accessToken;
|
|
106
|
+
let tokenType;
|
|
107
|
+
|
|
108
|
+
if (dpopJkt) {
|
|
109
|
+
// Generate DPoP-bound JWT for Solid-OIDC clients
|
|
110
|
+
const jwks = await getJwks();
|
|
111
|
+
const signingKey = jwks.keys[0];
|
|
112
|
+
const privateKey = await jose.importJWK(signingKey, 'ES256');
|
|
113
|
+
|
|
114
|
+
const now = Math.floor(Date.now() / 1000);
|
|
115
|
+
const tokenPayload = {
|
|
116
|
+
iss: issuer,
|
|
117
|
+
sub: account.id,
|
|
118
|
+
aud: 'solid',
|
|
119
|
+
webid: account.webId,
|
|
120
|
+
iat: now,
|
|
121
|
+
exp: now + expiresIn,
|
|
122
|
+
jti: crypto.randomUUID(),
|
|
123
|
+
client_id: 'credentials_client',
|
|
124
|
+
scope: 'openid webid',
|
|
125
|
+
cnf: { jkt: dpopJkt },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
accessToken = await new jose.SignJWT(tokenPayload)
|
|
129
|
+
.setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
|
|
130
|
+
.sign(privateKey);
|
|
131
|
+
tokenType = 'DPoP';
|
|
132
|
+
} else {
|
|
133
|
+
// Generate simple token for Bearer auth (development/testing)
|
|
134
|
+
accessToken = createSimpleToken(account.webId, expiresIn);
|
|
135
|
+
tokenType = 'Bearer';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Response
|
|
139
|
+
const response = {
|
|
140
|
+
access_token: accessToken,
|
|
141
|
+
token_type: tokenType,
|
|
142
|
+
expires_in: expiresIn,
|
|
143
|
+
webid: account.webId,
|
|
144
|
+
id: account.id,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
reply.header('Cache-Control', 'no-store');
|
|
148
|
+
reply.header('Pragma', 'no-cache');
|
|
149
|
+
|
|
150
|
+
return response;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate a DPoP proof and return the JWK thumbprint
|
|
155
|
+
* @param {string} proof - The DPoP proof JWT
|
|
156
|
+
* @param {string} method - HTTP method
|
|
157
|
+
* @param {string} url - Request URL
|
|
158
|
+
* @returns {Promise<string>} - JWK thumbprint
|
|
159
|
+
*/
|
|
160
|
+
async function validateDpopProof(proof, method, url) {
|
|
161
|
+
// Decode the proof header to get the public key
|
|
162
|
+
const protectedHeader = jose.decodeProtectedHeader(proof);
|
|
163
|
+
|
|
164
|
+
// DPoP proofs must have a JWK in the header
|
|
165
|
+
if (!protectedHeader.jwk) {
|
|
166
|
+
throw new Error('DPoP proof must contain jwk in header');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Verify the proof signature
|
|
170
|
+
const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
|
|
171
|
+
|
|
172
|
+
let payload;
|
|
173
|
+
try {
|
|
174
|
+
const result = await jose.jwtVerify(proof, publicKey, {
|
|
175
|
+
typ: 'dpop+jwt',
|
|
176
|
+
maxTokenAge: '60s',
|
|
177
|
+
});
|
|
178
|
+
payload = result.payload;
|
|
179
|
+
} catch (err) {
|
|
180
|
+
throw new Error(`DPoP proof verification failed: ${err.message}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Verify htm (HTTP method)
|
|
184
|
+
if (payload.htm !== method) {
|
|
185
|
+
throw new Error(`DPoP htm mismatch: expected ${method}, got ${payload.htm}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Verify htu (HTTP URL) - compare without query string
|
|
189
|
+
const proofUrl = new URL(payload.htu);
|
|
190
|
+
const requestUrl = new URL(url);
|
|
191
|
+
if (proofUrl.origin + proofUrl.pathname !== requestUrl.origin + requestUrl.pathname) {
|
|
192
|
+
throw new Error('DPoP htu mismatch');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Calculate JWK thumbprint
|
|
196
|
+
const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
|
|
197
|
+
|
|
198
|
+
return thumbprint;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Handle GET /idp/credentials
|
|
203
|
+
* Returns info about the credentials endpoint
|
|
204
|
+
*/
|
|
205
|
+
export function handleCredentialsInfo(request, reply, issuer) {
|
|
206
|
+
return {
|
|
207
|
+
endpoint: `${issuer}/idp/credentials`,
|
|
208
|
+
method: 'POST',
|
|
209
|
+
description: 'Obtain access tokens using email and password',
|
|
210
|
+
content_types: ['application/json', 'application/x-www-form-urlencoded'],
|
|
211
|
+
parameters: {
|
|
212
|
+
email: 'User email address',
|
|
213
|
+
password: 'User password',
|
|
214
|
+
},
|
|
215
|
+
optional_headers: {
|
|
216
|
+
DPoP: 'DPoP proof JWT for DPoP-bound tokens',
|
|
217
|
+
},
|
|
218
|
+
response: {
|
|
219
|
+
access_token: 'JWT access token with webid claim',
|
|
220
|
+
token_type: 'DPoP or Bearer',
|
|
221
|
+
expires_in: 'Token lifetime in seconds',
|
|
222
|
+
webid: 'User WebID',
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/idp/index.js
CHANGED
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
handleConsent,
|
|
13
13
|
handleAbort,
|
|
14
14
|
} from './interactions.js';
|
|
15
|
+
import {
|
|
16
|
+
handleCredentials,
|
|
17
|
+
handleCredentialsInfo,
|
|
18
|
+
} from './credentials.js';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* IdP Fastify Plugin
|
|
@@ -43,8 +47,8 @@ export async function idpPlugin(fastify, options) {
|
|
|
43
47
|
// Mount oidc-provider on /idp path
|
|
44
48
|
// oidc-provider is a Koa app, middie handles the bridge
|
|
45
49
|
fastify.use('/idp', (req, res, next) => {
|
|
46
|
-
// Skip our custom
|
|
47
|
-
if (req.url.startsWith('/interaction/')) {
|
|
50
|
+
// Skip our custom routes (handled by Fastify)
|
|
51
|
+
if (req.url.startsWith('/interaction/') || req.url.startsWith('/credentials')) {
|
|
48
52
|
return next();
|
|
49
53
|
}
|
|
50
54
|
// Let oidc-provider handle everything else
|
|
@@ -89,6 +93,19 @@ export async function idpPlugin(fastify, options) {
|
|
|
89
93
|
return jwks;
|
|
90
94
|
});
|
|
91
95
|
|
|
96
|
+
// Programmatic credentials endpoint for CTH compatibility
|
|
97
|
+
// Allows obtaining tokens via email/password without browser interaction
|
|
98
|
+
|
|
99
|
+
// GET credentials info
|
|
100
|
+
fastify.get('/idp/credentials', async (request, reply) => {
|
|
101
|
+
return handleCredentialsInfo(request, reply, issuer);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// POST credentials - obtain tokens
|
|
105
|
+
fastify.post('/idp/credentials', async (request, reply) => {
|
|
106
|
+
return handleCredentials(request, reply, issuer);
|
|
107
|
+
});
|
|
108
|
+
|
|
92
109
|
// Interaction routes (our custom login/consent UI)
|
|
93
110
|
// These bypass oidc-provider and use our handlers
|
|
94
111
|
|