javascript-solid-server 0.0.11 → 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.
@@ -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,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
+ }