javascript-solid-server 0.0.38 → 0.0.39
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 +13 -1
- package/README.md +16 -37
- package/cth.env +2 -2
- package/package.json +1 -1
- package/src/auth/middleware.js +5 -3
- package/src/handlers/resource.js +10 -1
- package/src/server.js +12 -4
- package/src/storage/filesystem.js +2 -3
- package/src/utils/url.js +15 -3
- package/src/wac/checker.js +33 -17
- package/test/pod.test.js +7 -7
- package/test/webid.test.js +25 -57
- package/test-git-nostr-auth.js +285 -0
- package/data/.idp/keys/jwks.json +0 -37
|
@@ -119,7 +119,19 @@
|
|
|
119
119
|
"Bash(DATA_ROOT=/tmp/jss-git-test JSS_PORT=4444 timeout 3 node:*)",
|
|
120
120
|
"Bash(pm2 show:*)",
|
|
121
121
|
"Bash(git config:*)",
|
|
122
|
-
"Bash(npm version:*)"
|
|
122
|
+
"Bash(npm version:*)",
|
|
123
|
+
"Bash(git init:*)",
|
|
124
|
+
"Bash(gh repo create:*)",
|
|
125
|
+
"Bash(./bin/git-credential-nostr generate:*)",
|
|
126
|
+
"Bash(./bin/git-credential-nostr get:*)",
|
|
127
|
+
"Bash(git-credential-nostr:*)",
|
|
128
|
+
"Bash(git branch:*)",
|
|
129
|
+
"Bash(GIT_TRACE=1 GIT_CURL_VERBOSE=1 git push:*)",
|
|
130
|
+
"Bash(GIT_CURL_VERBOSE=1 git push:*)",
|
|
131
|
+
"Bash(git reset:*)",
|
|
132
|
+
"Bash(echo:*)",
|
|
133
|
+
"Bash(unset DATA_ROOT)",
|
|
134
|
+
"Bash(timeout 30 npm test:*)"
|
|
123
135
|
]
|
|
124
136
|
}
|
|
125
137
|
}
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A minimal, fast, JSON-LD native Solid server.
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
### Implemented (v0.0.
|
|
7
|
+
### Implemented (v0.0.39)
|
|
8
8
|
|
|
9
9
|
- **LDP CRUD Operations** - GET, PUT, POST, DELETE, HEAD
|
|
10
10
|
- **N3 Patch** - Solid's native patch format for RDF updates
|
|
@@ -356,47 +356,25 @@ Git operations respect WAC permissions - clone requires Read access, push requir
|
|
|
356
356
|
|
|
357
357
|
### Git Push with Nostr Authentication
|
|
358
358
|
|
|
359
|
-
Git push supports NIP-98 authentication via Basic Auth.
|
|
359
|
+
Git push supports NIP-98 authentication via Basic Auth. Install the credential helper:
|
|
360
360
|
|
|
361
|
-
```
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
import { hexToBytes } from '@noble/hashes/utils';
|
|
365
|
-
import fs from 'fs';
|
|
366
|
-
import readline from 'readline';
|
|
367
|
-
|
|
368
|
-
const sk = hexToBytes(fs.readFileSync('.nostr-key', 'utf8').trim());
|
|
369
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
370
|
-
const params = {};
|
|
371
|
-
for await (const line of rl) {
|
|
372
|
-
if (!line) break;
|
|
373
|
-
const [key, ...vals] = line.split('=');
|
|
374
|
-
params[key] = vals.join('=');
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (params.protocol && params.host) {
|
|
378
|
-
let path = params.path || '/';
|
|
379
|
-
path = path.replace(/\/info\/refs$/, '').replace(/\/git-.*$/, '');
|
|
380
|
-
const baseUrl = `${params.protocol}://${params.host}${path}`;
|
|
381
|
-
|
|
382
|
-
const event = finalizeEvent({
|
|
383
|
-
kind: 27235,
|
|
384
|
-
created_at: Math.floor(Date.now() / 1000),
|
|
385
|
-
tags: [['u', baseUrl], ['method', '*']],
|
|
386
|
-
content: ''
|
|
387
|
-
}, sk);
|
|
388
|
-
|
|
389
|
-
console.log('username=nostr');
|
|
390
|
-
console.log('password=' + Buffer.from(JSON.stringify(event)).toString('base64'));
|
|
391
|
-
}
|
|
361
|
+
```bash
|
|
362
|
+
npm install -g git-credential-nostr
|
|
363
|
+
git config --global credential.helper nostr
|
|
392
364
|
```
|
|
393
365
|
|
|
394
|
-
|
|
366
|
+
Generate or configure your Nostr key:
|
|
395
367
|
|
|
396
368
|
```bash
|
|
397
|
-
|
|
369
|
+
# Generate a new keypair
|
|
370
|
+
git-credential-nostr generate
|
|
371
|
+
|
|
372
|
+
# Or use an existing private key
|
|
373
|
+
git config --global nostr.privkey YOUR_64_CHAR_HEX_PRIVKEY
|
|
398
374
|
```
|
|
399
375
|
|
|
376
|
+
See [git-credential-nostr](https://github.com/JavaScriptSolidServer/git-credential-nostr) for more details.
|
|
377
|
+
|
|
400
378
|
Add the Nostr identity to your ACL:
|
|
401
379
|
|
|
402
380
|
```turtle
|
|
@@ -580,7 +558,7 @@ npm run benchmark
|
|
|
580
558
|
npm test
|
|
581
559
|
```
|
|
582
560
|
|
|
583
|
-
Currently passing: **
|
|
561
|
+
Currently passing: **187 tests** (including 27 conformance tests)
|
|
584
562
|
|
|
585
563
|
### Conformance Test Harness (CTH)
|
|
586
564
|
|
|
@@ -628,7 +606,8 @@ src/
|
|
|
628
606
|
├── auth/
|
|
629
607
|
│ ├── middleware.js # Auth hook
|
|
630
608
|
│ ├── token.js # Simple token auth
|
|
631
|
-
│
|
|
609
|
+
│ ├── solid-oidc.js # DPoP verification
|
|
610
|
+
│ └── nostr.js # NIP-98 Nostr authentication
|
|
632
611
|
├── wac/
|
|
633
612
|
│ ├── parser.js # ACL parsing
|
|
634
613
|
│ └── checker.js # Permission checking
|
package/cth.env
CHANGED
|
@@ -5,11 +5,11 @@ SOLID_IDENTITY_PROVIDER=http://localhost:3456
|
|
|
5
5
|
RESOURCE_SERVER_ROOT=http://localhost:3456
|
|
6
6
|
TEST_CONTAINER=alice/public/
|
|
7
7
|
|
|
8
|
-
USERS_ALICE_WEBID=http://localhost:3456/alice
|
|
8
|
+
USERS_ALICE_WEBID=http://localhost:3456/alice/profile/card#me
|
|
9
9
|
USERS_ALICE_USERNAME=alice@test.local
|
|
10
10
|
USERS_ALICE_PASSWORD=alicepassword123
|
|
11
11
|
|
|
12
|
-
USERS_BOB_WEBID=http://localhost:3456/bob
|
|
12
|
+
USERS_BOB_WEBID=http://localhost:3456/bob/profile/card#me
|
|
13
13
|
USERS_BOB_USERNAME=bob@test.local
|
|
14
14
|
USERS_BOB_PASSWORD=bobpassword123
|
|
15
15
|
|
package/package.json
CHANGED
package/src/auth/middleware.js
CHANGED
|
@@ -13,9 +13,11 @@ import { getEffectiveUrlPath } from '../utils/url.js';
|
|
|
13
13
|
* Check if request is authorized
|
|
14
14
|
* @param {object} request - Fastify request
|
|
15
15
|
* @param {object} reply - Fastify reply
|
|
16
|
+
* @param {object} options - Optional settings
|
|
17
|
+
* @param {string} options.requiredMode - Override the required access mode (e.g., 'Write' for git push)
|
|
16
18
|
* @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
|
|
17
19
|
*/
|
|
18
|
-
export async function authorize(request, reply) {
|
|
20
|
+
export async function authorize(request, reply, options = {}) {
|
|
19
21
|
const urlPath = request.url.split('?')[0];
|
|
20
22
|
const method = request.method;
|
|
21
23
|
|
|
@@ -44,8 +46,8 @@ export async function authorize(request, reply) {
|
|
|
44
46
|
// Build resource URL (uses actual request hostname which may be subdomain)
|
|
45
47
|
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
|
|
46
48
|
|
|
47
|
-
// Get required access mode
|
|
48
|
-
const requiredMode = getRequiredMode(method);
|
|
49
|
+
// Get required access mode - use override if provided, otherwise derive from method
|
|
50
|
+
const requiredMode = options.requiredMode || getRequiredMode(method);
|
|
49
51
|
|
|
50
52
|
// For write operations on non-existent resources, check parent container
|
|
51
53
|
let checkPath = storagePath;
|
package/src/handlers/resource.js
CHANGED
|
@@ -320,10 +320,19 @@ export async function handleGet(request, reply) {
|
|
|
320
320
|
}
|
|
321
321
|
|
|
322
322
|
// Serve content as-is (no conneg or non-RDF resource)
|
|
323
|
+
// For extensionless files (like profile/card), detect HTML by content
|
|
324
|
+
let actualContentType = storedContentType;
|
|
325
|
+
if (storedContentType === 'application/octet-stream') {
|
|
326
|
+
const contentStr = content.toString().trimStart();
|
|
327
|
+
if (contentStr.startsWith('<!DOCTYPE') || contentStr.startsWith('<html')) {
|
|
328
|
+
actualContentType = 'text/html';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
323
332
|
const headers = getAllHeaders({
|
|
324
333
|
isContainer: false,
|
|
325
334
|
etag: stats.etag,
|
|
326
|
-
contentType:
|
|
335
|
+
contentType: actualContentType,
|
|
327
336
|
origin,
|
|
328
337
|
resourceUrl,
|
|
329
338
|
connegEnabled
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,7 @@ import { authorize, handleUnauthorized } from './auth/middleware.js';
|
|
|
9
9
|
import { notificationsPlugin } from './notifications/index.js';
|
|
10
10
|
import { idpPlugin } from './idp/index.js';
|
|
11
11
|
import { isGitRequest, isGitWriteOperation, handleGit } from './handlers/git.js';
|
|
12
|
+
import { AccessMode } from './wac/parser.js';
|
|
12
13
|
|
|
13
14
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
|
|
@@ -135,7 +136,7 @@ export function createServer(options = {}) {
|
|
|
135
136
|
// Security: Block access to dotfiles except allowed Solid-specific ones
|
|
136
137
|
// This prevents exposure of .git/, .env, .htpasswd, etc.
|
|
137
138
|
// Git protocol requests bypass this check when git is enabled
|
|
138
|
-
const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta'];
|
|
139
|
+
const ALLOWED_DOTFILES = ['.well-known', '.acl', '.meta', '.pods', '.notifications'];
|
|
139
140
|
fastify.addHook('onRequest', async (request, reply) => {
|
|
140
141
|
// Allow git protocol requests through when git is enabled
|
|
141
142
|
if (gitEnabled && isGitRequest(request.url)) {
|
|
@@ -162,15 +163,22 @@ export function createServer(options = {}) {
|
|
|
162
163
|
return;
|
|
163
164
|
}
|
|
164
165
|
|
|
165
|
-
//
|
|
166
|
-
const
|
|
166
|
+
// Determine required mode: Write for push, Read for clone/fetch
|
|
167
|
+
const needsWrite = isGitWriteOperation(request.url);
|
|
168
|
+
const requiredMode = needsWrite ? AccessMode.WRITE : AccessMode.READ;
|
|
169
|
+
|
|
170
|
+
// Run WAC authorization with the correct mode for git operations
|
|
171
|
+
const { authorized, webId, wacAllow, authError } = await authorize(request, reply, { requiredMode });
|
|
167
172
|
request.webId = webId;
|
|
168
173
|
request.wacAllow = wacAllow;
|
|
169
174
|
|
|
170
175
|
if (!authorized) {
|
|
171
|
-
const needsWrite = isGitWriteOperation(request.url);
|
|
172
176
|
const message = needsWrite ? 'Write access required for push' : 'Read access required for clone';
|
|
173
177
|
reply.header('WAC-Allow', wacAllow);
|
|
178
|
+
if (!webId) {
|
|
179
|
+
// No authentication - request Basic auth for git clients
|
|
180
|
+
reply.header('WWW-Authenticate', 'Basic realm="Solid"');
|
|
181
|
+
}
|
|
174
182
|
return reply.code(webId ? 403 : 401).send({ error: message });
|
|
175
183
|
}
|
|
176
184
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { getDataRoot, urlToPath, isContainer } from '../utils/url.js';
|
|
5
5
|
|
|
6
|
-
//
|
|
7
|
-
fs.ensureDirSync(DATA_ROOT);
|
|
6
|
+
// Note: Data directory is ensured in server.js after DATA_ROOT is set
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* Check if resource exists
|
package/src/utils/url.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
|
|
3
3
|
// Base directory for storing all pods
|
|
4
|
-
|
|
4
|
+
// Use a getter function to read env var at runtime (not import time)
|
|
5
|
+
// This is necessary because ES modules are loaded before the CLI sets the env var
|
|
6
|
+
export function getDataRoot() {
|
|
7
|
+
return process.env.DATA_ROOT || './data';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Legacy export - kept for compatibility, but callers should use getDataRoot()
|
|
11
|
+
export let DATA_ROOT = './data';
|
|
12
|
+
|
|
13
|
+
// Update DATA_ROOT when env var is set (called from storage init)
|
|
14
|
+
export function updateDataRoot() {
|
|
15
|
+
DATA_ROOT = getDataRoot();
|
|
16
|
+
}
|
|
5
17
|
|
|
6
18
|
/**
|
|
7
19
|
* Convert URL path to filesystem path
|
|
@@ -16,7 +28,7 @@ export function urlToPath(urlPath) {
|
|
|
16
28
|
// Security: prevent path traversal
|
|
17
29
|
normalized = normalized.replace(/\.\./g, '');
|
|
18
30
|
|
|
19
|
-
return path.join(
|
|
31
|
+
return path.join(getDataRoot(), normalized);
|
|
20
32
|
}
|
|
21
33
|
|
|
22
34
|
/**
|
|
@@ -35,7 +47,7 @@ export function urlToPathWithPod(urlPath, podName) {
|
|
|
35
47
|
normalized = normalized.replace(/\.\./g, '');
|
|
36
48
|
|
|
37
49
|
// Prepend pod name to path
|
|
38
|
-
return path.join(
|
|
50
|
+
return path.join(getDataRoot(), podName, normalized);
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
/**
|
package/src/wac/checker.js
CHANGED
|
@@ -33,19 +33,20 @@ export async function checkAccess({
|
|
|
33
33
|
return { allowed: true, wacAllow: 'user="read write append control", public="read write append"' };
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
const { authorizations, isDefault, targetUrl } = aclResult;
|
|
36
|
+
const { authorizations, isDefault, targetUrl: aclContainerUrl } = aclResult;
|
|
37
37
|
|
|
38
38
|
// Check authorizations
|
|
39
|
+
// Note: For default ACLs, we check if the ACL's default rules apply to the actual resource URL
|
|
39
40
|
const allowed = checkAuthorizations(
|
|
40
41
|
authorizations,
|
|
41
|
-
|
|
42
|
+
resourceUrl, // Use actual resource URL, not the ACL container URL
|
|
42
43
|
agentWebId,
|
|
43
44
|
requiredMode,
|
|
44
45
|
isDefault
|
|
45
46
|
);
|
|
46
47
|
|
|
47
48
|
// Calculate WAC-Allow header
|
|
48
|
-
const wacAllow = calculateWacAllow(authorizations,
|
|
49
|
+
const wacAllow = calculateWacAllow(authorizations, resourceUrl, agentWebId, isDefault);
|
|
49
50
|
|
|
50
51
|
return { allowed, wacAllow };
|
|
51
52
|
}
|
|
@@ -117,13 +118,17 @@ function getParentPath(path) {
|
|
|
117
118
|
*/
|
|
118
119
|
function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault) {
|
|
119
120
|
for (const auth of authorizations) {
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
121
|
+
// For default ACLs, check if auth has default rules and matches target
|
|
122
|
+
// For direct ACLs, check if accessTo matches target
|
|
123
|
+
if (isDefault) {
|
|
124
|
+
// Skip if no default rules defined
|
|
125
|
+
if (auth.default.length === 0) continue;
|
|
126
|
+
// Skip if target URL doesn't match any default URL prefix
|
|
127
|
+
if (!auth.default.some(d => urlMatches(d, targetUrl, true))) continue;
|
|
128
|
+
} else {
|
|
129
|
+
// Skip if accessTo doesn't match target
|
|
130
|
+
if (!auth.accessTo.some(a => urlMatches(a, targetUrl))) continue;
|
|
131
|
+
}
|
|
127
132
|
|
|
128
133
|
// Check if agent is authorized
|
|
129
134
|
const agentAuthorized = isAgentAuthorized(auth, agentWebId);
|
|
@@ -172,10 +177,20 @@ function isAgentAuthorized(auth, agentWebId) {
|
|
|
172
177
|
|
|
173
178
|
/**
|
|
174
179
|
* Check if URLs match (handles trailing slashes)
|
|
180
|
+
* @param {string} pattern - The ACL URL pattern
|
|
181
|
+
* @param {string} url - The target URL to check
|
|
182
|
+
* @param {boolean} prefixMatch - If true, check if url starts with pattern (for acl:default)
|
|
175
183
|
*/
|
|
176
|
-
function urlMatches(pattern, url) {
|
|
184
|
+
function urlMatches(pattern, url, prefixMatch = false) {
|
|
177
185
|
const normalizedPattern = pattern.replace(/\/$/, '');
|
|
178
186
|
const normalizedUrl = url.replace(/\/$/, '');
|
|
187
|
+
|
|
188
|
+
if (prefixMatch) {
|
|
189
|
+
// For default ACLs: target must be same as or under the pattern
|
|
190
|
+
return normalizedUrl === normalizedPattern ||
|
|
191
|
+
normalizedUrl.startsWith(normalizedPattern + '/');
|
|
192
|
+
}
|
|
193
|
+
|
|
179
194
|
return normalizedPattern === normalizedUrl;
|
|
180
195
|
}
|
|
181
196
|
|
|
@@ -187,12 +202,13 @@ function calculateWacAllow(authorizations, targetUrl, agentWebId, isDefault) {
|
|
|
187
202
|
const publicModes = new Set();
|
|
188
203
|
|
|
189
204
|
for (const auth of authorizations) {
|
|
190
|
-
// Check if applies to resource
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
// Check if applies to resource - use same logic as checkAuthorizations
|
|
206
|
+
if (isDefault) {
|
|
207
|
+
if (auth.default.length === 0) continue;
|
|
208
|
+
if (!auth.default.some(d => urlMatches(d, targetUrl, true))) continue;
|
|
209
|
+
} else {
|
|
210
|
+
if (!auth.accessTo.some(a => urlMatches(a, targetUrl))) continue;
|
|
211
|
+
}
|
|
196
212
|
|
|
197
213
|
// Check what modes this grants
|
|
198
214
|
const modes = auth.modes.map(m => {
|
package/test/pod.test.js
CHANGED
|
@@ -36,7 +36,7 @@ describe('Pod Lifecycle', () => {
|
|
|
36
36
|
|
|
37
37
|
const data = await res.json();
|
|
38
38
|
assert.strictEqual(data.name, 'alice');
|
|
39
|
-
assert.ok(data.webId.endsWith('/alice
|
|
39
|
+
assert.ok(data.webId.endsWith('/alice/profile/card#me'));
|
|
40
40
|
assert.ok(data.podUri.endsWith('/alice/'));
|
|
41
41
|
});
|
|
42
42
|
|
|
@@ -95,24 +95,24 @@ describe('Pod Lifecycle', () => {
|
|
|
95
95
|
const priv = await request('/carol/private/', { auth: 'carol' });
|
|
96
96
|
assertStatus(priv, 200);
|
|
97
97
|
|
|
98
|
-
// Check
|
|
99
|
-
const settings = await request('/carol/
|
|
98
|
+
// Check Settings exists (needs auth)
|
|
99
|
+
const settings = await request('/carol/Settings/', { auth: 'carol' });
|
|
100
100
|
assertStatus(settings, 200);
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it('should create settings files', async () => {
|
|
104
104
|
await createTestPod('dan');
|
|
105
105
|
|
|
106
|
-
// Check
|
|
107
|
-
const prefs = await request('/dan/
|
|
106
|
+
// Check Preferences.ttl (needs auth - Settings is private)
|
|
107
|
+
const prefs = await request('/dan/Settings/Preferences.ttl', { auth: 'dan' });
|
|
108
108
|
assertStatus(prefs, 200);
|
|
109
109
|
|
|
110
110
|
// Check public type index (needs auth)
|
|
111
|
-
const pubIndex = await request('/dan/
|
|
111
|
+
const pubIndex = await request('/dan/Settings/publicTypeIndex.ttl', { auth: 'dan' });
|
|
112
112
|
assertStatus(pubIndex, 200);
|
|
113
113
|
|
|
114
114
|
// Check private type index (needs auth)
|
|
115
|
-
const privIndex = await request('/dan/
|
|
115
|
+
const privIndex = await request('/dan/Settings/privateTypeIndex.ttl', { auth: 'dan' });
|
|
116
116
|
assertStatus(privIndex, 200);
|
|
117
117
|
});
|
|
118
118
|
});
|
package/test/webid.test.js
CHANGED
|
@@ -30,119 +30,87 @@ describe('WebID Profile', () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe('Profile Document', () => {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Profile is at /pod/profile/card following Solid convention
|
|
34
|
+
const profilePath = '/webidtest/profile/card';
|
|
35
|
+
|
|
36
|
+
it('should serve profile as HTML', async () => {
|
|
37
|
+
const res = await request(profilePath);
|
|
35
38
|
|
|
36
39
|
assertStatus(res, 200);
|
|
37
40
|
assertHeaderContains(res, 'Content-Type', 'text/html');
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
it('should contain JSON-LD structured data', async () => {
|
|
41
|
-
const res = await request(
|
|
44
|
+
const res = await request(profilePath);
|
|
42
45
|
const html = await res.text();
|
|
43
46
|
|
|
44
47
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
45
48
|
assert.ok(jsonLd['@context'], 'Should have @context');
|
|
46
|
-
|
|
49
|
+
// Profile uses flat structure, not @graph
|
|
50
|
+
assert.ok(jsonLd['@id'], 'Should have @id');
|
|
47
51
|
});
|
|
48
52
|
|
|
49
53
|
it('should have correct WebID URI', async () => {
|
|
50
|
-
const res = await request(
|
|
54
|
+
const res = await request(profilePath);
|
|
51
55
|
const html = await res.text();
|
|
52
56
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
53
57
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
Array.isArray(node['@type'])
|
|
57
|
-
? node['@type'].includes('foaf:Person')
|
|
58
|
-
: node['@type'] === 'foaf:Person'
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
assert.ok(person, 'Should have a foaf:Person');
|
|
62
|
-
assert.ok(person['@id'].endsWith('/webidtest/#me'), 'WebID should end with /#me');
|
|
58
|
+
// Profile is a flat structure with the person as the main entity
|
|
59
|
+
assert.ok(jsonLd['@id'].endsWith('/webidtest/profile/card#me'), 'WebID should end with /profile/card#me');
|
|
63
60
|
});
|
|
64
61
|
|
|
65
62
|
it('should have foaf:name', async () => {
|
|
66
|
-
const res = await request(
|
|
63
|
+
const res = await request(profilePath);
|
|
67
64
|
const html = await res.text();
|
|
68
65
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
69
66
|
|
|
70
|
-
|
|
71
|
-
Array.isArray(node['@type'])
|
|
72
|
-
? node['@type'].includes('foaf:Person')
|
|
73
|
-
: node['@type'] === 'foaf:Person'
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
assert.strictEqual(person['foaf:name'], 'webidtest');
|
|
67
|
+
assert.strictEqual(jsonLd['foaf:name'], 'webidtest');
|
|
77
68
|
});
|
|
78
69
|
|
|
79
70
|
it('should have solid:oidcIssuer', async () => {
|
|
80
|
-
const res = await request(
|
|
71
|
+
const res = await request(profilePath);
|
|
81
72
|
const html = await res.text();
|
|
82
73
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
83
74
|
|
|
84
|
-
|
|
85
|
-
Array.isArray(node['@type'])
|
|
86
|
-
? node['@type'].includes('foaf:Person')
|
|
87
|
-
: node['@type'] === 'foaf:Person'
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
assert.ok(person['oidcIssuer'], 'Should have oidcIssuer');
|
|
75
|
+
assert.ok(jsonLd['oidcIssuer'], 'Should have oidcIssuer');
|
|
91
76
|
});
|
|
92
77
|
|
|
93
78
|
it('should have pim:storage pointing to pod', async () => {
|
|
94
|
-
const res = await request(
|
|
79
|
+
const res = await request(profilePath);
|
|
95
80
|
const html = await res.text();
|
|
96
81
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
97
82
|
|
|
98
|
-
|
|
99
|
-
Array.isArray(node['@type'])
|
|
100
|
-
? node['@type'].includes('foaf:Person')
|
|
101
|
-
: node['@type'] === 'foaf:Person'
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
assert.ok(person['storage'].endsWith('/webidtest/'), 'Storage should point to pod');
|
|
83
|
+
assert.ok(jsonLd['storage'].endsWith('/webidtest/'), 'Storage should point to pod');
|
|
105
84
|
});
|
|
106
85
|
|
|
107
86
|
it('should have ldp:inbox', async () => {
|
|
108
|
-
const res = await request(
|
|
87
|
+
const res = await request(profilePath);
|
|
109
88
|
const html = await res.text();
|
|
110
89
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
111
90
|
|
|
112
|
-
|
|
113
|
-
Array.isArray(node['@type'])
|
|
114
|
-
? node['@type'].includes('foaf:Person')
|
|
115
|
-
: node['@type'] === 'foaf:Person'
|
|
116
|
-
);
|
|
117
|
-
|
|
118
|
-
assert.ok(person['inbox'].endsWith('/webidtest/inbox/'), 'Should have inbox');
|
|
91
|
+
assert.ok(jsonLd['inbox'].endsWith('/webidtest/inbox/'), 'Should have inbox');
|
|
119
92
|
});
|
|
120
93
|
|
|
121
|
-
it('should have
|
|
122
|
-
const res = await request(
|
|
94
|
+
it('should have mainEntityOfPage', async () => {
|
|
95
|
+
const res = await request(profilePath);
|
|
123
96
|
const html = await res.text();
|
|
124
97
|
const jsonLd = extractJsonLdFromHtml(html);
|
|
125
98
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
assert.ok(doc, 'Should have PersonalProfileDocument');
|
|
131
|
-
assert.ok(doc['foaf:maker'], 'Should have foaf:maker');
|
|
132
|
-
assert.ok(doc['foaf:primaryTopic'], 'Should have foaf:primaryTopic');
|
|
99
|
+
// Check for mainEntityOfPage which links to the profile document
|
|
100
|
+
assert.ok(jsonLd['mainEntityOfPage'], 'Should have mainEntityOfPage');
|
|
133
101
|
});
|
|
134
102
|
});
|
|
135
103
|
|
|
136
104
|
describe('WebID Resolution', () => {
|
|
137
105
|
it('should return LDP headers', async () => {
|
|
138
|
-
const res = await request('/webidtest/');
|
|
106
|
+
const res = await request('/webidtest/profile/card');
|
|
139
107
|
|
|
140
108
|
assertHeaderContains(res, 'Link', 'ldp#Resource');
|
|
141
109
|
assertHeader(res, 'WAC-Allow');
|
|
142
110
|
});
|
|
143
111
|
|
|
144
112
|
it('should return CORS headers', async () => {
|
|
145
|
-
const res = await request('/webidtest/', {
|
|
113
|
+
const res = await request('/webidtest/profile/card', {
|
|
146
114
|
headers: { 'Origin': 'https://example.com' }
|
|
147
115
|
});
|
|
148
116
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Test git push/pull with Nostr NIP-98 authentication
|
|
4
|
+
*
|
|
5
|
+
* Usage: node test-git-nostr-auth.js
|
|
6
|
+
*
|
|
7
|
+
* Prerequisites:
|
|
8
|
+
* - JSS server running on localhost:4000 with --git flag
|
|
9
|
+
* - git-credential-nostr installed globally
|
|
10
|
+
* - git config --global credential.helper nostr
|
|
11
|
+
*
|
|
12
|
+
* This script:
|
|
13
|
+
* 1. Creates a test git repo on the server
|
|
14
|
+
* 2. Sets up ACL with authorized Nostr DID
|
|
15
|
+
* 3. Tests clone (public read)
|
|
16
|
+
* 4. Tests push with authorized key (should succeed)
|
|
17
|
+
* 5. Tests push with unauthorized key (should fail with 403)
|
|
18
|
+
* 6. Cleans up
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools/pure';
|
|
22
|
+
import { bytesToHex } from '@noble/hashes/utils';
|
|
23
|
+
import { execSync, spawn } from 'child_process';
|
|
24
|
+
import fs from 'fs-extra';
|
|
25
|
+
import path from 'path';
|
|
26
|
+
import os from 'os';
|
|
27
|
+
|
|
28
|
+
const BASE_URL = process.env.TEST_URL || 'http://localhost:4000';
|
|
29
|
+
const DATA_ROOT = process.env.DATA_ROOT || '/home/melvin/jss/data';
|
|
30
|
+
const TEST_POD = 'test-git-nostr';
|
|
31
|
+
const TEST_REPO = 'test-repo';
|
|
32
|
+
|
|
33
|
+
// Generate keypairs for testing
|
|
34
|
+
const authorizedSk = generateSecretKey();
|
|
35
|
+
const authorizedPk = getPublicKey(authorizedSk);
|
|
36
|
+
const authorizedPrivHex = bytesToHex(authorizedSk);
|
|
37
|
+
|
|
38
|
+
const unauthorizedSk = generateSecretKey();
|
|
39
|
+
const unauthorizedPk = getPublicKey(unauthorizedSk);
|
|
40
|
+
const unauthorizedPrivHex = bytesToHex(unauthorizedSk);
|
|
41
|
+
|
|
42
|
+
let tempDir;
|
|
43
|
+
let passed = 0;
|
|
44
|
+
let failed = 0;
|
|
45
|
+
|
|
46
|
+
function log(msg) {
|
|
47
|
+
console.log(` ${msg}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pass(test) {
|
|
51
|
+
console.log(`✓ ${test}`);
|
|
52
|
+
passed++;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function fail(test, error) {
|
|
56
|
+
console.log(`✗ ${test}`);
|
|
57
|
+
console.log(` Error: ${error}`);
|
|
58
|
+
failed++;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function exec(cmd, options = {}) {
|
|
62
|
+
try {
|
|
63
|
+
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...options });
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (options.allowFail) {
|
|
66
|
+
return { error: true, stderr: e.stderr, stdout: e.stdout, status: e.status };
|
|
67
|
+
}
|
|
68
|
+
throw e;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function setup() {
|
|
73
|
+
console.log('\n=== Setup ===\n');
|
|
74
|
+
|
|
75
|
+
// Create temp directory for client repos
|
|
76
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'git-nostr-test-'));
|
|
77
|
+
log(`Temp directory: ${tempDir}`);
|
|
78
|
+
|
|
79
|
+
// Create test pod directory
|
|
80
|
+
const podPath = path.join(DATA_ROOT, TEST_POD);
|
|
81
|
+
fs.ensureDirSync(podPath);
|
|
82
|
+
log(`Created pod: ${podPath}`);
|
|
83
|
+
|
|
84
|
+
// Create bare git repo
|
|
85
|
+
const repoPath = path.join(podPath, TEST_REPO);
|
|
86
|
+
fs.ensureDirSync(repoPath);
|
|
87
|
+
exec(`git init --bare`, { cwd: repoPath });
|
|
88
|
+
exec(`git config --local receive.denyCurrentBranch ignore`, { cwd: repoPath });
|
|
89
|
+
exec(`git config --local http.receivepack true`, { cwd: repoPath });
|
|
90
|
+
exec(`git symbolic-ref HEAD refs/heads/main`, { cwd: repoPath });
|
|
91
|
+
log(`Created bare repo: ${repoPath}`);
|
|
92
|
+
|
|
93
|
+
// Create ACL with authorized Nostr DID
|
|
94
|
+
const acl = {
|
|
95
|
+
"@context": {
|
|
96
|
+
"acl": "http://www.w3.org/ns/auth/acl#",
|
|
97
|
+
"foaf": "http://xmlns.com/foaf/0.1/"
|
|
98
|
+
},
|
|
99
|
+
"@graph": [
|
|
100
|
+
{
|
|
101
|
+
"@id": "#nostr",
|
|
102
|
+
"@type": "acl:Authorization",
|
|
103
|
+
"acl:agent": { "@id": `did:nostr:${authorizedPk}` },
|
|
104
|
+
"acl:accessTo": { "@id": `${BASE_URL}/${TEST_POD}/` },
|
|
105
|
+
"acl:mode": [
|
|
106
|
+
{ "@id": "acl:Read" },
|
|
107
|
+
{ "@id": "acl:Write" }
|
|
108
|
+
],
|
|
109
|
+
"acl:default": { "@id": `${BASE_URL}/${TEST_POD}/` }
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"@id": "#public",
|
|
113
|
+
"@type": "acl:Authorization",
|
|
114
|
+
"acl:agentClass": { "@id": "foaf:Agent" },
|
|
115
|
+
"acl:accessTo": { "@id": `${BASE_URL}/${TEST_POD}/` },
|
|
116
|
+
"acl:mode": [{ "@id": "acl:Read" }],
|
|
117
|
+
"acl:default": { "@id": `${BASE_URL}/${TEST_POD}/` }
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
fs.writeJsonSync(path.join(podPath, '.acl'), acl, { spaces: 2 });
|
|
123
|
+
log(`Created ACL with authorized DID: did:nostr:${authorizedPk.slice(0, 16)}...`);
|
|
124
|
+
|
|
125
|
+
// Create initial commit in a temp client repo
|
|
126
|
+
const initRepo = path.join(tempDir, 'init');
|
|
127
|
+
fs.ensureDirSync(initRepo);
|
|
128
|
+
exec('git init', { cwd: initRepo });
|
|
129
|
+
exec('git config user.email "test@example.com"', { cwd: initRepo });
|
|
130
|
+
exec('git config user.name "Test User"', { cwd: initRepo });
|
|
131
|
+
fs.writeFileSync(path.join(initRepo, 'README.md'), '# Test Repo\n');
|
|
132
|
+
exec('git add .', { cwd: initRepo });
|
|
133
|
+
exec('git commit -m "Initial commit"', { cwd: initRepo });
|
|
134
|
+
exec('git branch -m main', { cwd: initRepo });
|
|
135
|
+
exec(`git remote add origin ${BASE_URL}/${TEST_POD}/${TEST_REPO}/`, { cwd: initRepo });
|
|
136
|
+
|
|
137
|
+
// Configure authorized key for initial push
|
|
138
|
+
exec(`git config nostr.privkey ${authorizedPrivHex}`, { cwd: initRepo });
|
|
139
|
+
|
|
140
|
+
log('Created initial commit');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function testClone() {
|
|
144
|
+
console.log('\n=== Test: Clone (public read) ===\n');
|
|
145
|
+
|
|
146
|
+
const cloneDir = path.join(tempDir, 'clone-test');
|
|
147
|
+
try {
|
|
148
|
+
exec(`git clone ${BASE_URL}/${TEST_POD}/${TEST_REPO}/ ${cloneDir}`);
|
|
149
|
+
if (fs.existsSync(path.join(cloneDir, '.git'))) {
|
|
150
|
+
pass('Clone succeeded (public read works)');
|
|
151
|
+
} else {
|
|
152
|
+
fail('Clone', 'No .git directory created');
|
|
153
|
+
}
|
|
154
|
+
} catch (e) {
|
|
155
|
+
// Clone might fail if repo is empty, that's ok
|
|
156
|
+
if (e.message.includes('empty repository')) {
|
|
157
|
+
pass('Clone of empty repo handled correctly');
|
|
158
|
+
} else {
|
|
159
|
+
fail('Clone', e.message);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function testPushAuthorized() {
|
|
165
|
+
console.log('\n=== Test: Push with authorized key ===\n');
|
|
166
|
+
|
|
167
|
+
const initRepo = path.join(tempDir, 'init');
|
|
168
|
+
try {
|
|
169
|
+
// Push should work with authorized key
|
|
170
|
+
exec('git push -u origin main', { cwd: initRepo });
|
|
171
|
+
pass('Push with authorized key succeeded');
|
|
172
|
+
} catch (e) {
|
|
173
|
+
fail('Push with authorized key', e.stderr || e.message);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function testPushUnauthorized() {
|
|
178
|
+
console.log('\n=== Test: Push with unauthorized key ===\n');
|
|
179
|
+
|
|
180
|
+
const cloneDir = path.join(tempDir, 'unauthorized-test');
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
// Clone first (should work - public read)
|
|
184
|
+
exec(`git clone ${BASE_URL}/${TEST_POD}/${TEST_REPO}/ ${cloneDir}`);
|
|
185
|
+
exec('git config user.email "test@example.com"', { cwd: cloneDir });
|
|
186
|
+
exec('git config user.name "Test User"', { cwd: cloneDir });
|
|
187
|
+
|
|
188
|
+
// Configure unauthorized key
|
|
189
|
+
exec(`git config nostr.privkey ${unauthorizedPrivHex}`, { cwd: cloneDir });
|
|
190
|
+
|
|
191
|
+
// Make a change
|
|
192
|
+
fs.appendFileSync(path.join(cloneDir, 'README.md'), '\nUnauthorized change\n');
|
|
193
|
+
exec('git add .', { cwd: cloneDir });
|
|
194
|
+
exec('git commit -m "Unauthorized change"', { cwd: cloneDir });
|
|
195
|
+
|
|
196
|
+
// Push should fail with 403
|
|
197
|
+
const result = exec('git push 2>&1', { cwd: cloneDir, allowFail: true });
|
|
198
|
+
|
|
199
|
+
if (result.error && (result.stderr?.includes('403') || result.stdout?.includes('403'))) {
|
|
200
|
+
pass('Push with unauthorized key correctly rejected (403)');
|
|
201
|
+
} else if (result.error) {
|
|
202
|
+
// Check if it failed for the right reason
|
|
203
|
+
if (result.stderr?.includes('Authentication failed') || result.stderr?.includes('403')) {
|
|
204
|
+
pass('Push with unauthorized key correctly rejected');
|
|
205
|
+
} else {
|
|
206
|
+
fail('Push with unauthorized key', `Unexpected error: ${result.stderr}`);
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
fail('Push with unauthorized key', 'Push should have failed but succeeded');
|
|
210
|
+
}
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e.stderr?.includes('403') || e.message?.includes('403')) {
|
|
213
|
+
pass('Push with unauthorized key correctly rejected (403)');
|
|
214
|
+
} else {
|
|
215
|
+
fail('Push with unauthorized key', e.stderr || e.message);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function testPullAfterPush() {
|
|
221
|
+
console.log('\n=== Test: Pull after push ===\n');
|
|
222
|
+
|
|
223
|
+
const pullDir = path.join(tempDir, 'pull-test');
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
exec(`git clone ${BASE_URL}/${TEST_POD}/${TEST_REPO}/ ${pullDir}`);
|
|
227
|
+
const readme = fs.readFileSync(path.join(pullDir, 'README.md'), 'utf8');
|
|
228
|
+
|
|
229
|
+
if (readme.includes('Test Repo')) {
|
|
230
|
+
pass('Pull retrieved pushed content');
|
|
231
|
+
} else {
|
|
232
|
+
fail('Pull after push', 'Content mismatch');
|
|
233
|
+
}
|
|
234
|
+
} catch (e) {
|
|
235
|
+
fail('Pull after push', e.stderr || e.message);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function cleanup() {
|
|
240
|
+
console.log('\n=== Cleanup ===\n');
|
|
241
|
+
|
|
242
|
+
// Remove temp directory
|
|
243
|
+
if (tempDir) {
|
|
244
|
+
fs.removeSync(tempDir);
|
|
245
|
+
log(`Removed temp directory: ${tempDir}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Remove test pod
|
|
249
|
+
const podPath = path.join(DATA_ROOT, TEST_POD);
|
|
250
|
+
if (fs.existsSync(podPath)) {
|
|
251
|
+
fs.removeSync(podPath);
|
|
252
|
+
log(`Removed test pod: ${podPath}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function main() {
|
|
257
|
+
console.log('╔════════════════════════════════════════════════════╗');
|
|
258
|
+
console.log('║ Git Push/Pull with Nostr Authentication Test ║');
|
|
259
|
+
console.log('╚════════════════════════════════════════════════════╝');
|
|
260
|
+
|
|
261
|
+
console.log(`\nServer: ${BASE_URL}`);
|
|
262
|
+
console.log(`Data root: ${DATA_ROOT}`);
|
|
263
|
+
console.log(`Authorized pubkey: ${authorizedPk.slice(0, 16)}...`);
|
|
264
|
+
console.log(`Unauthorized pubkey: ${unauthorizedPk.slice(0, 16)}...`);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await setup();
|
|
268
|
+
await testPushAuthorized();
|
|
269
|
+
await testClone();
|
|
270
|
+
await testPullAfterPush();
|
|
271
|
+
await testPushUnauthorized();
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.error('\nFatal error:', e.message);
|
|
274
|
+
} finally {
|
|
275
|
+
await cleanup();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
console.log('\n╔════════════════════════════════════════════════════╗');
|
|
279
|
+
console.log(`║ Results: ${passed} passed, ${failed} failed${' '.repeat(27 - String(passed).length - String(failed).length)}║`);
|
|
280
|
+
console.log('╚════════════════════════════════════════════════════╝\n');
|
|
281
|
+
|
|
282
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
main().catch(console.error);
|
package/data/.idp/keys/jwks.json
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"jwks": {
|
|
3
|
-
"keys": [
|
|
4
|
-
{
|
|
5
|
-
"kty": "RSA",
|
|
6
|
-
"n": "zVVbuPqzcpJI4er_VpHQQd8UgRDI-PcLlwUGRG3DGqMvAWzB4UX2m5e5Uhc2vnzBC2U_1bZ5u3ovhZZhXqqj0ItBTOWjhjOZz4YpaPiwM68sqp_JUNNZPSLljhXnEhEv8q57Wcl0N8orJeuSmfjh7shsLKN-3nbWjCbQr4oAUneQ_2HNvazUTgRLKYCniS0SaI_ogTwuIJlNdFIc3FAHs4yNoSaXZDkdGWhGn49wYdB58nkjJ2ja8wc-rwNtcKEG7HZWNhoVCredav9jswWgAGodJofAxP_PCjguqn6-TzvGodXM3p0Rj1qaf27Ok39GbtOEwX4upwFKj-yJaZETQQ",
|
|
7
|
-
"e": "AQAB",
|
|
8
|
-
"d": "BAgqbUkP876bW4NMqP-jPB3kJk4k8h2QvtIIj9iraXcdlcyzyGeCKoc5ynq99pLWzBFchebnwEaLjxcXOZ-GaLKJUVgbhEe4XBa1crQaaqNkkEOjtXh28sBAC3CS6VwIyd5C-g3-gBdyTjPwXKFiV1jcZep-c-IXv6gF9kJyk-vv3aT6VabcqRBOk4PeNxL_VJt65x5JQBqyV06fv7OBrt15ez0dZL-fYMPhMhBZom0YGCkMVAC4VHAOmoVuHk3hQQCQv5x9-hq27qfgHAlkSKxTEDp2aJi4nIfzqEnFHrBqB9BwR0JEyV3ankVPzBcfEGffi5XY3kWK3EDE3oX9SQ",
|
|
9
|
-
"p": "6ZGd_spW6xSiTNwlU-to7Qr06dr1hXzWLyFbJM9_APFxE761WJtAsN50oNg-rKRQk2JdlC0N04OFYWBED-pvJ0AIEVnKJfWsxEQaBkRueZefUcf-0L1M2Q7-LQdqz9qTR3hSZ42lJhPKMdLRI2WrxTz1v1p0w0yl3bwcbBRLtTk",
|
|
10
|
-
"q": "4Q2QFyMKQUQhr4-JlQbI3VtBhfP-65oZg-13hG5Xc9kT0qo_-79YCK_4wMUX4WaSH6fG6lobTgZento3d6MXCAFpEl8WM4uRRNlzTLCC2FDZXms8i6hJHN89QFPLEPQd5bDXsw8pmA3ANIeHcwzR243VuWAYeQfeOJ-qymUjlkk",
|
|
11
|
-
"dp": "AUldDm885VSaxEOeLQUp8cxSpwseuRqD74SGhQBjmbS6w7oUM6W_SHohOFWYmsjY7Mbo7w0Ee3rI_E1UcqX-8L9oi_frpiPhTL93STuNRDwyk3e_jpTMXJG5krPswbJZh1ZBVfKwyzHmtjmMD17bAF4imGg-JmlArKUBnxLJi_k",
|
|
12
|
-
"dq": "A6Z7qtRnqy1WuolCewdUJLsBMhIGFX43YbttT9mWU4u21ZjrVsMAw4tPJplLzN0kC51mDZEOllJmIH97nNYpXnjfYmvmaUmfPpWkWB8Y0Ddnfy-QGNfO78fzL2LsjUbYYUxgA0iArTWz42Y7XTNdCAmh6NLVMslc4mA8nfHMBPk",
|
|
13
|
-
"qi": "Wikwy4ljfyLLHDO1Fpm2hn-_G7rWbwOP0ANJzY62cIEorbyyr4zAaaPflGMrDJHadlLdww_x2L1g2lNePN7mY97kFJVL0ceuwUmxDr434LqyRZWTrTP5qP6vrkjGjMImBuJEftXdOyJElcqHgtpvltydZRjlvhugnxcOKRble3Y",
|
|
14
|
-
"kid": "509868cc-c850-4708-9331-8e38273df4db",
|
|
15
|
-
"use": "sig",
|
|
16
|
-
"alg": "RS256",
|
|
17
|
-
"iat": 1767263929
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
"kty": "EC",
|
|
21
|
-
"x": "KSGjhPP_QyoqHu8v891hEk_TsF5yuQUN0hFBY_qq26o",
|
|
22
|
-
"y": "aTYYEwxOR0tM82FtJoCVJ7u8xtf3y0exoWVJzJAZQ24",
|
|
23
|
-
"crv": "P-256",
|
|
24
|
-
"d": "Mkm_Oh4_emAzg2MbxoMuHcJZo3E-O_-YlK8mlSHWU8M",
|
|
25
|
-
"kid": "92009452-c551-4389-97fd-314ddd59b7d4",
|
|
26
|
-
"use": "sig",
|
|
27
|
-
"alg": "ES256",
|
|
28
|
-
"iat": 1767263929
|
|
29
|
-
}
|
|
30
|
-
]
|
|
31
|
-
},
|
|
32
|
-
"cookieKeys": [
|
|
33
|
-
"IiO299-8bui4UVILu0azGvlCnNTckSGMPMPQSG2HqHw",
|
|
34
|
-
"NbeOVkfw-xN9T4J0_1uzAmDp6tYOwFW-yTJLSd7nqDU"
|
|
35
|
-
],
|
|
36
|
-
"createdAt": "2026-01-01T10:38:49.528Z"
|
|
37
|
-
}
|