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.
- package/.claude/settings.local.json +7 -1
- package/README.md +68 -4
- package/bin/jss.js +22 -4
- package/data/alice/.acl +50 -0
- package/data/alice/inbox/.acl +50 -0
- package/data/alice/index.html +80 -0
- package/data/alice/private/.acl +32 -0
- package/data/alice/public/test.json +1 -0
- package/data/alice/settings/.acl +32 -0
- package/data/alice/settings/prefs +17 -0
- package/data/alice/settings/privateTypeIndex +7 -0
- package/data/alice/settings/publicTypeIndex +7 -0
- package/data/bob/.acl +50 -0
- package/data/bob/inbox/.acl +50 -0
- package/data/bob/index.html +80 -0
- package/data/bob/private/.acl +32 -0
- package/data/bob/settings/.acl +32 -0
- package/data/bob/settings/prefs +17 -0
- package/data/bob/settings/privateTypeIndex +7 -0
- package/data/bob/settings/publicTypeIndex +7 -0
- package/package.json +6 -2
- package/scripts/test-cth-compat.js +369 -0
- package/src/config.js +7 -0
- package/src/handlers/container.js +35 -2
- package/src/idp/accounts.js +258 -0
- package/src/idp/adapter.js +204 -0
- package/src/idp/credentials.js +225 -0
- package/src/idp/index.js +135 -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 +18 -2
- package/test/idp.test.js +427 -0
|
@@ -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
|
+
}
|