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.
@@ -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.20",
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 two modes:
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 || password.length < 8) {
153
- return reply.code(400).send({ error: 'Password required (minimum 8 characters)' });
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
- // Create pod directory structure
193
- await storage.createContainer(podPath);
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,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Account management for the Identity Provider
3
- * Handles user accounts with email/password authentication
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.email - User email
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({ email, password, webId, podName }) {
73
+ export async function createAccount({ username, password, webId, podName, email }) {
65
74
  await ensureDir();
66
75
 
67
- const normalizedEmail = email.toLowerCase().trim();
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
- const existingByEmail = await findByEmail(normalizedEmail);
71
- if (existingByEmail) {
72
- throw new Error('Email already registered');
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} email - User email
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(email, password) {
121
- const account = await findByEmail(email);
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