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.
@@ -110,6 +110,7 @@ export async function handlePost(request, reply) {
110
110
  /**
111
111
  * Create a pod (container) for a user
112
112
  * POST /.pods with { "name": "alice" }
113
+ * With IdP enabled: { "name": "alice", "email": "alice@example.com", "password": "secret" }
113
114
  *
114
115
  * Creates the following structure:
115
116
  * /{name}/
@@ -122,12 +123,23 @@ export async function handlePost(request, reply) {
122
123
  * /{name}/settings/privateTypeIndex
123
124
  */
124
125
  export async function handleCreatePod(request, reply) {
125
- const { name } = request.body || {};
126
+ const { name, email, password } = request.body || {};
127
+ const idpEnabled = request.idpEnabled;
126
128
 
127
129
  if (!name || typeof name !== 'string') {
128
130
  return reply.code(400).send({ error: 'Pod name required' });
129
131
  }
130
132
 
133
+ // If IdP is enabled, require email and password
134
+ if (idpEnabled) {
135
+ if (!email || typeof email !== 'string') {
136
+ return reply.code(400).send({ error: 'Email required for account creation' });
137
+ }
138
+ if (!password || password.length < 8) {
139
+ return reply.code(400).send({ error: 'Password required (minimum 8 characters)' });
140
+ }
141
+ }
142
+
131
143
  // Validate pod name (alphanumeric, dash, underscore)
132
144
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
133
145
  return reply.code(400).send({ error: 'Invalid pod name. Use alphanumeric, dash, or underscore only.' });
@@ -200,7 +212,28 @@ export async function handleCreatePod(request, reply) {
200
212
 
201
213
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
202
214
 
203
- // Generate token for the pod owner
215
+ // If IdP is enabled, create account instead of simple token
216
+ if (idpEnabled) {
217
+ try {
218
+ const { createAccount } = await import('../idp/accounts.js');
219
+ await createAccount({ email, password, webId, podName: name });
220
+
221
+ return reply.code(201).send({
222
+ name,
223
+ webId,
224
+ podUri,
225
+ idpIssuer: issuer,
226
+ loginUrl: `${issuer}/idp/auth`,
227
+ });
228
+ } catch (err) {
229
+ console.error('Account creation error:', err);
230
+ // Rollback pod creation on account failure
231
+ await storage.remove(podPath);
232
+ return reply.code(409).send({ error: err.message });
233
+ }
234
+ }
235
+
236
+ // Generate token for the pod owner (simple auth mode)
204
237
  const token = createToken(webId);
205
238
 
206
239
  return reply.code(201).send({
@@ -315,10 +315,12 @@ export async function handleOptions(request, reply) {
315
315
 
316
316
  const origin = request.headers.origin;
317
317
  const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
318
+ const connegEnabled = request.connegEnabled || false;
318
319
  const headers = getAllHeaders({
319
320
  isContainer: stats?.isDirectory || isContainer(urlPath),
320
321
  origin,
321
- resourceUrl
322
+ resourceUrl,
323
+ connegEnabled
322
324
  });
323
325
 
324
326
  Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Account management for the Identity Provider
3
+ * Handles user accounts with email/password authentication
4
+ */
5
+
6
+ import bcrypt from 'bcrypt';
7
+ import crypto from 'crypto';
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+
11
+ /**
12
+ * Get accounts directory (computed dynamically to support changing DATA_ROOT)
13
+ */
14
+ function getAccountsDir() {
15
+ const dataRoot = process.env.DATA_ROOT || './data';
16
+ return path.join(dataRoot, '.idp', 'accounts');
17
+ }
18
+
19
+ function getEmailIndexPath() {
20
+ return path.join(getAccountsDir(), '_email_index.json');
21
+ }
22
+
23
+ function getWebIdIndexPath() {
24
+ return path.join(getAccountsDir(), '_webid_index.json');
25
+ }
26
+
27
+ const SALT_ROUNDS = 10;
28
+
29
+ /**
30
+ * Initialize the accounts directory
31
+ */
32
+ async function ensureDir() {
33
+ await fs.ensureDir(getAccountsDir());
34
+ }
35
+
36
+ /**
37
+ * Load an index file
38
+ */
39
+ async function loadIndex(indexPath) {
40
+ try {
41
+ return await fs.readJson(indexPath);
42
+ } catch (err) {
43
+ if (err.code === 'ENOENT') return {};
44
+ throw err;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Save an index file
50
+ */
51
+ async function saveIndex(indexPath, index) {
52
+ await fs.writeJson(indexPath, index, { spaces: 2 });
53
+ }
54
+
55
+ /**
56
+ * Create a new user account
57
+ * @param {object} options - Account options
58
+ * @param {string} options.email - User email
59
+ * @param {string} options.password - Plain text password
60
+ * @param {string} options.webId - User's WebID URI
61
+ * @param {string} options.podName - Pod name
62
+ * @returns {Promise<object>} - Created account (without password)
63
+ */
64
+ export async function createAccount({ email, password, webId, podName }) {
65
+ await ensureDir();
66
+
67
+ const normalizedEmail = email.toLowerCase().trim();
68
+
69
+ // Check email uniqueness
70
+ const existingByEmail = await findByEmail(normalizedEmail);
71
+ if (existingByEmail) {
72
+ throw new Error('Email already registered');
73
+ }
74
+
75
+ // Check webId uniqueness
76
+ const existingByWebId = await findByWebId(webId);
77
+ if (existingByWebId) {
78
+ throw new Error('WebID already has an account');
79
+ }
80
+
81
+ // Generate account ID and hash password
82
+ const id = crypto.randomUUID();
83
+ const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
84
+
85
+ const account = {
86
+ id,
87
+ email: normalizedEmail,
88
+ passwordHash,
89
+ webId,
90
+ podName,
91
+ createdAt: new Date().toISOString(),
92
+ lastLogin: null,
93
+ };
94
+
95
+ // Save account
96
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
97
+ await fs.writeJson(accountPath, account, { spaces: 2 });
98
+
99
+ // Update email index
100
+ const emailIndex = await loadIndex(getEmailIndexPath());
101
+ emailIndex[normalizedEmail] = id;
102
+ await saveIndex(getEmailIndexPath(), emailIndex);
103
+
104
+ // Update webId index
105
+ const webIdIndex = await loadIndex(getWebIdIndexPath());
106
+ webIdIndex[webId] = id;
107
+ await saveIndex(getWebIdIndexPath(), webIdIndex);
108
+
109
+ // Return account without password hash
110
+ const { passwordHash: _, ...safeAccount } = account;
111
+ return safeAccount;
112
+ }
113
+
114
+ /**
115
+ * Authenticate a user with email and password
116
+ * @param {string} email - User email
117
+ * @param {string} password - Plain text password
118
+ * @returns {Promise<object|null>} - Account if valid, null if invalid
119
+ */
120
+ export async function authenticate(email, password) {
121
+ const account = await findByEmail(email);
122
+ if (!account) return null;
123
+
124
+ const valid = await bcrypt.compare(password, account.passwordHash);
125
+ if (!valid) return null;
126
+
127
+ // Update last login
128
+ account.lastLogin = new Date().toISOString();
129
+ const accountPath = path.join(getAccountsDir(), `${account.id}.json`);
130
+ await fs.writeJson(accountPath, account, { spaces: 2 });
131
+
132
+ // Return account without password hash
133
+ const { passwordHash: _, ...safeAccount } = account;
134
+ return safeAccount;
135
+ }
136
+
137
+ /**
138
+ * Find an account by ID
139
+ * @param {string} id - Account ID
140
+ * @returns {Promise<object|null>} - Account or null
141
+ */
142
+ export async function findById(id) {
143
+ try {
144
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
145
+ return await fs.readJson(accountPath);
146
+ } catch (err) {
147
+ if (err.code === 'ENOENT') return null;
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Find an account by email
154
+ * @param {string} email - User email
155
+ * @returns {Promise<object|null>} - Account or null
156
+ */
157
+ export async function findByEmail(email) {
158
+ const normalizedEmail = email.toLowerCase().trim();
159
+ const emailIndex = await loadIndex(getEmailIndexPath());
160
+ const id = emailIndex[normalizedEmail];
161
+ if (!id) return null;
162
+ return findById(id);
163
+ }
164
+
165
+ /**
166
+ * Find an account by WebID
167
+ * @param {string} webId - User WebID
168
+ * @returns {Promise<object|null>} - Account or null
169
+ */
170
+ export async function findByWebId(webId) {
171
+ const webIdIndex = await loadIndex(getWebIdIndexPath());
172
+ const id = webIdIndex[webId];
173
+ if (!id) return null;
174
+ return findById(id);
175
+ }
176
+
177
+ /**
178
+ * Update account password
179
+ * @param {string} id - Account ID
180
+ * @param {string} newPassword - New plain text password
181
+ */
182
+ export async function updatePassword(id, newPassword) {
183
+ const account = await findById(id);
184
+ if (!account) {
185
+ throw new Error('Account not found');
186
+ }
187
+
188
+ account.passwordHash = await bcrypt.hash(newPassword, SALT_ROUNDS);
189
+ account.passwordChangedAt = new Date().toISOString();
190
+
191
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
192
+ await fs.writeJson(accountPath, account, { spaces: 2 });
193
+ }
194
+
195
+ /**
196
+ * Delete an account
197
+ * @param {string} id - Account ID
198
+ */
199
+ export async function deleteAccount(id) {
200
+ const account = await findById(id);
201
+ if (!account) return;
202
+
203
+ // Remove from indexes
204
+ const emailIndex = await loadIndex(getEmailIndexPath());
205
+ delete emailIndex[account.email];
206
+ await saveIndex(getEmailIndexPath(), emailIndex);
207
+
208
+ const webIdIndex = await loadIndex(getWebIdIndexPath());
209
+ delete webIdIndex[account.webId];
210
+ await saveIndex(getWebIdIndexPath(), webIdIndex);
211
+
212
+ // Delete account file
213
+ const accountPath = path.join(getAccountsDir(), `${id}.json`);
214
+ await fs.remove(accountPath);
215
+ }
216
+
217
+ /**
218
+ * Get account for oidc-provider's findAccount
219
+ * This is the interface oidc-provider expects
220
+ * @param {string} id - Account ID
221
+ * @returns {Promise<object|undefined>} - Account interface for oidc-provider
222
+ */
223
+ export async function getAccountForProvider(id) {
224
+ const account = await findById(id);
225
+ if (!account) return undefined;
226
+
227
+ return {
228
+ accountId: id,
229
+ /**
230
+ * Return claims for the token
231
+ * @param {string} use - 'id_token' or 'userinfo'
232
+ * @param {string} scope - Requested scopes
233
+ * @param {object} claims - Requested claims
234
+ * @param {string[]} rejected - Rejected claims
235
+ */
236
+ async claims(use, scope, claims, rejected) {
237
+ const result = {
238
+ sub: id,
239
+ };
240
+
241
+ // Always include webid for Solid-OIDC
242
+ result.webid = account.webId;
243
+
244
+ // Profile scope
245
+ if (scope.includes('profile')) {
246
+ result.name = account.podName;
247
+ }
248
+
249
+ // Email scope
250
+ if (scope.includes('email')) {
251
+ result.email = account.email;
252
+ result.email_verified = false; // We don't have email verification yet
253
+ }
254
+
255
+ return result;
256
+ },
257
+ };
258
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Filesystem adapter for oidc-provider
3
+ * Stores OIDC data (tokens, sessions, clients, etc.) as JSON files
4
+ */
5
+
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+
9
+ /**
10
+ * Get IDP root directory (dynamic to support changing DATA_ROOT)
11
+ */
12
+ function getIdpRoot() {
13
+ const dataRoot = process.env.DATA_ROOT || './data';
14
+ return path.join(dataRoot, '.idp');
15
+ }
16
+
17
+ /**
18
+ * Convert model name to directory name
19
+ * e.g., 'AccessToken' -> 'access_token'
20
+ */
21
+ function modelToDir(model) {
22
+ return model.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
23
+ }
24
+
25
+ /**
26
+ * Filesystem adapter for oidc-provider
27
+ * Implements the adapter interface required by oidc-provider
28
+ */
29
+ class FilesystemAdapter {
30
+ constructor(model) {
31
+ this.model = model;
32
+ }
33
+
34
+ /**
35
+ * Get directory for this model (computed dynamically)
36
+ */
37
+ get dir() {
38
+ return path.join(getIdpRoot(), modelToDir(this.model));
39
+ }
40
+
41
+ /**
42
+ * Get file path for an ID
43
+ */
44
+ _path(id) {
45
+ // Sanitize ID to prevent path traversal
46
+ const safeId = id.replace(/[^a-zA-Z0-9_-]/g, '_');
47
+ return path.join(this.dir, `${safeId}.json`);
48
+ }
49
+
50
+ /**
51
+ * Create or update a stored item
52
+ * @param {string} id - Unique identifier
53
+ * @param {object} payload - Data to store
54
+ * @param {number} expiresIn - TTL in seconds
55
+ */
56
+ async upsert(id, payload, expiresIn) {
57
+ await fs.ensureDir(this.dir);
58
+
59
+ const data = {
60
+ ...payload,
61
+ _id: id,
62
+ };
63
+
64
+ // Set expiration if provided
65
+ if (expiresIn) {
66
+ data._expiresAt = Date.now() + (expiresIn * 1000);
67
+ }
68
+
69
+ await fs.writeJson(this._path(id), data, { spaces: 2 });
70
+ }
71
+
72
+ /**
73
+ * Find an item by ID
74
+ * @param {string} id - Unique identifier
75
+ * @returns {object|undefined} - The payload or undefined if not found/expired
76
+ */
77
+ async find(id) {
78
+ try {
79
+ const data = await fs.readJson(this._path(id));
80
+
81
+ // Check if expired
82
+ if (data._expiresAt && data._expiresAt < Date.now()) {
83
+ await this.destroy(id);
84
+ return undefined;
85
+ }
86
+
87
+ return data;
88
+ } catch (err) {
89
+ if (err.code === 'ENOENT') {
90
+ return undefined;
91
+ }
92
+ throw err;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Find by user code (for device flow)
98
+ * @param {string} userCode - Device flow user code
99
+ */
100
+ async findByUserCode(userCode) {
101
+ try {
102
+ const files = await fs.readdir(this.dir);
103
+ for (const file of files) {
104
+ if (file.startsWith('_')) continue; // Skip index files
105
+ const data = await fs.readJson(path.join(this.dir, file));
106
+ if (data.userCode === userCode) {
107
+ // Check expiry
108
+ if (data._expiresAt && data._expiresAt < Date.now()) {
109
+ await this.destroy(data._id);
110
+ continue;
111
+ }
112
+ return data;
113
+ }
114
+ }
115
+ } catch (err) {
116
+ if (err.code !== 'ENOENT') throw err;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ /**
122
+ * Find by UID (for sessions/interactions)
123
+ * @param {string} uid - Session/interaction UID
124
+ */
125
+ async findByUid(uid) {
126
+ try {
127
+ const files = await fs.readdir(this.dir);
128
+ for (const file of files) {
129
+ if (file.startsWith('_')) continue; // Skip index files
130
+ const data = await fs.readJson(path.join(this.dir, file));
131
+ if (data.uid === uid) {
132
+ // Check expiry
133
+ if (data._expiresAt && data._expiresAt < Date.now()) {
134
+ await this.destroy(data._id);
135
+ continue;
136
+ }
137
+ return data;
138
+ }
139
+ }
140
+ } catch (err) {
141
+ if (err.code !== 'ENOENT') throw err;
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ /**
147
+ * Delete an item
148
+ * @param {string} id - Unique identifier
149
+ */
150
+ async destroy(id) {
151
+ try {
152
+ await fs.remove(this._path(id));
153
+ } catch (err) {
154
+ if (err.code !== 'ENOENT') throw err;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Mark a token as consumed (one-time use)
160
+ * @param {string} id - Token identifier
161
+ */
162
+ async consume(id) {
163
+ const data = await this.find(id);
164
+ if (data) {
165
+ data.consumed = Date.now() / 1000; // oidc-provider expects seconds
166
+ await this.upsert(id, data);
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Revoke all tokens for a grant
172
+ * Used when user revokes consent or logs out
173
+ * @param {string} grantId - Grant identifier
174
+ */
175
+ async revokeByGrantId(grantId) {
176
+ try {
177
+ const files = await fs.readdir(this.dir);
178
+ for (const file of files) {
179
+ if (file.startsWith('_')) continue; // Skip index files
180
+ try {
181
+ const data = await fs.readJson(path.join(this.dir, file));
182
+ if (data.grantId === grantId) {
183
+ await fs.remove(path.join(this.dir, file));
184
+ }
185
+ } catch (err) {
186
+ // Skip files that can't be read
187
+ }
188
+ }
189
+ } catch (err) {
190
+ if (err.code !== 'ENOENT') throw err;
191
+ }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Adapter factory for oidc-provider
197
+ * @param {string} model - Model name (e.g., 'AccessToken', 'Client')
198
+ * @returns {FilesystemAdapter} - Adapter instance
199
+ */
200
+ export function createAdapter(model) {
201
+ return new FilesystemAdapter(model);
202
+ }
203
+
204
+ export default FilesystemAdapter;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Identity Provider Fastify Plugin
3
+ * Mounts oidc-provider and interaction routes
4
+ */
5
+
6
+ import middie from '@fastify/middie';
7
+ import { createProvider } from './provider.js';
8
+ import { initializeKeys, getPublicJwks } from './keys.js';
9
+ import {
10
+ handleInteractionGet,
11
+ handleLogin,
12
+ handleConsent,
13
+ handleAbort,
14
+ } from './interactions.js';
15
+
16
+ /**
17
+ * IdP Fastify Plugin
18
+ * @param {FastifyInstance} fastify
19
+ * @param {object} options
20
+ * @param {string} options.issuer - The issuer URL
21
+ */
22
+ export async function idpPlugin(fastify, options) {
23
+ const { issuer } = options;
24
+
25
+ if (!issuer) {
26
+ throw new Error('IdP requires issuer URL');
27
+ }
28
+
29
+ // Initialize signing keys
30
+ await initializeKeys();
31
+
32
+ // Create the OIDC provider
33
+ const provider = await createProvider(issuer);
34
+
35
+ // Store provider reference on fastify for handlers
36
+ fastify.decorate('oidcProvider', provider);
37
+
38
+ // Register middleware support for oidc-provider (Koa app)
39
+ await fastify.register(middie, {
40
+ hook: 'preHandler',
41
+ });
42
+
43
+ // Mount oidc-provider on /idp path
44
+ // oidc-provider is a Koa app, middie handles the bridge
45
+ fastify.use('/idp', (req, res, next) => {
46
+ // Skip our custom interaction routes
47
+ if (req.url.startsWith('/interaction/')) {
48
+ return next();
49
+ }
50
+ // Let oidc-provider handle everything else
51
+ provider.callback()(req, res);
52
+ });
53
+
54
+ // /.well-known/openid-configuration
55
+ fastify.get('/.well-known/openid-configuration', async (request, reply) => {
56
+ // Build discovery document
57
+ const config = {
58
+ issuer,
59
+ authorization_endpoint: `${issuer}/idp/auth`,
60
+ token_endpoint: `${issuer}/idp/token`,
61
+ userinfo_endpoint: `${issuer}/idp/me`,
62
+ jwks_uri: `${issuer}/.well-known/jwks.json`,
63
+ registration_endpoint: `${issuer}/idp/reg`,
64
+ introspection_endpoint: `${issuer}/idp/token/introspection`,
65
+ revocation_endpoint: `${issuer}/idp/token/revocation`,
66
+ end_session_endpoint: `${issuer}/idp/session/end`,
67
+ scopes_supported: ['openid', 'webid', 'profile', 'email', 'offline_access'],
68
+ response_types_supported: ['code'],
69
+ response_modes_supported: ['query', 'fragment', 'form_post'],
70
+ grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'],
71
+ subject_types_supported: ['public'],
72
+ id_token_signing_alg_values_supported: ['ES256'],
73
+ token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_post'],
74
+ claims_supported: ['sub', 'webid', 'name', 'email', 'email_verified'],
75
+ code_challenge_methods_supported: ['S256'],
76
+ dpop_signing_alg_values_supported: ['ES256', 'RS256'],
77
+ // Solid-OIDC specific
78
+ solid_oidc_supported: 'https://solidproject.org/TR/solid-oidc',
79
+ };
80
+
81
+ reply.header('Cache-Control', 'public, max-age=3600');
82
+ return config;
83
+ });
84
+
85
+ // /.well-known/jwks.json
86
+ fastify.get('/.well-known/jwks.json', async (request, reply) => {
87
+ const jwks = await getPublicJwks();
88
+ reply.header('Cache-Control', 'public, max-age=3600');
89
+ return jwks;
90
+ });
91
+
92
+ // Interaction routes (our custom login/consent UI)
93
+ // These bypass oidc-provider and use our handlers
94
+
95
+ // GET interaction - show login or consent page
96
+ fastify.get('/idp/interaction/:uid', async (request, reply) => {
97
+ return handleInteractionGet(request, reply, provider);
98
+ });
99
+
100
+ // POST login
101
+ fastify.post('/idp/interaction/:uid/login', async (request, reply) => {
102
+ return handleLogin(request, reply, provider);
103
+ });
104
+
105
+ // POST consent
106
+ fastify.post('/idp/interaction/:uid/confirm', async (request, reply) => {
107
+ return handleConsent(request, reply, provider);
108
+ });
109
+
110
+ // POST abort
111
+ fastify.post('/idp/interaction/:uid/abort', async (request, reply) => {
112
+ return handleAbort(request, reply, provider);
113
+ });
114
+
115
+ fastify.log.info(`IdP initialized with issuer: ${issuer}`);
116
+ }
117
+
118
+ export default idpPlugin;