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.
- package/.claude/settings.local.json +11 -1
- package/README.md +110 -9
- package/bin/jss.js +226 -0
- package/package.json +11 -4
- package/src/config.js +192 -0
- package/src/handlers/container.js +35 -2
- package/src/handlers/resource.js +3 -1
- package/src/idp/accounts.js +258 -0
- package/src/idp/adapter.js +204 -0
- package/src/idp/index.js +118 -0
- package/src/idp/interactions.js +180 -0
- package/src/idp/keys.js +157 -0
- package/src/idp/provider.js +246 -0
- package/src/idp/views.js +295 -0
- package/src/server.js +39 -12
- package/test/conformance.test.js +349 -0
- package/test/idp.test.js +258 -0
|
@@ -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
|
-
//
|
|
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({
|
package/src/handlers/resource.js
CHANGED
|
@@ -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;
|
package/src/idp/index.js
ADDED
|
@@ -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;
|