javascript-solid-server 0.0.12 → 0.0.15
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 +27 -1
- package/CTH.md +222 -0
- package/README.md +55 -2
- package/bin/jss.js +5 -1
- package/cth-config/application.yaml +2 -0
- package/cth-config/jss.ttl +6 -0
- package/cth-config/test-subjects.ttl +14 -0
- package/cth.env +19 -0
- package/package.json +2 -1
- package/scripts/test-cth-compat.js +370 -0
- package/src/auth/middleware.js +6 -2
- package/src/auth/token.js +44 -1
- package/src/handlers/container.js +8 -3
- package/src/handlers/resource.js +65 -4
- package/src/idp/accounts.js +11 -2
- package/src/idp/credentials.js +225 -0
- package/src/idp/index.js +129 -21
- package/src/idp/interactions.js +123 -11
- package/src/idp/provider.js +68 -2
- package/src/rdf/turtle.js +15 -2
- package/src/wac/parser.js +43 -1
- package/test/idp.test.js +173 -1
- package/test/ldp.test.js +10 -5
- package/test-data-idp-accounts/.idp/accounts/292738d6-3363-4f40-9a6b-884bfd17830a.json +9 -0
- package/test-data-idp-accounts/.idp/accounts/_email_index.json +3 -0
- package/test-data-idp-accounts/.idp/accounts/_webid_index.json +3 -0
- package/test-data-idp-accounts/.idp/keys/jwks.json +22 -0
- package/test-dpop-flow.js +148 -0
- package/test-subjects.ttl +21 -0
|
@@ -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 } from './accounts.js';
|
|
9
|
+
import { getJwks } from './keys.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Handle POST /idp/credentials
|
|
13
|
+
* Accepts email/password (or username/password) and returns access token
|
|
14
|
+
*
|
|
15
|
+
* Request body (JSON or form):
|
|
16
|
+
* - email or username: User email address
|
|
17
|
+
* - password: User password
|
|
18
|
+
*
|
|
19
|
+
* Optional headers:
|
|
20
|
+
* - DPoP: DPoP proof JWT (for DPoP-bound tokens)
|
|
21
|
+
*
|
|
22
|
+
* Response:
|
|
23
|
+
* - access_token: JWT access token with webid claim
|
|
24
|
+
* - token_type: 'DPoP' or 'Bearer'
|
|
25
|
+
* - expires_in: Token lifetime in seconds
|
|
26
|
+
* - webid: User's WebID
|
|
27
|
+
*/
|
|
28
|
+
export async function handleCredentials(request, reply, issuer) {
|
|
29
|
+
// Parse body (JSON or form-encoded)
|
|
30
|
+
let email, password;
|
|
31
|
+
|
|
32
|
+
const contentType = request.headers['content-type'] || '';
|
|
33
|
+
let body = request.body;
|
|
34
|
+
|
|
35
|
+
// Convert buffer to string if needed
|
|
36
|
+
if (Buffer.isBuffer(body)) {
|
|
37
|
+
body = body.toString('utf-8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (contentType.includes('application/json')) {
|
|
41
|
+
// JSON - Fastify parses this automatically
|
|
42
|
+
if (typeof body === 'string') {
|
|
43
|
+
try {
|
|
44
|
+
body = JSON.parse(body);
|
|
45
|
+
} catch {
|
|
46
|
+
// Not valid JSON
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
email = body?.email || body?.username;
|
|
50
|
+
password = body?.password;
|
|
51
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
52
|
+
// Parse form-encoded body
|
|
53
|
+
if (typeof body === 'string') {
|
|
54
|
+
const params = new URLSearchParams(body);
|
|
55
|
+
email = params.get('email') || params.get('username');
|
|
56
|
+
password = params.get('password');
|
|
57
|
+
} else if (typeof body === 'object') {
|
|
58
|
+
email = body?.email || body?.username;
|
|
59
|
+
password = body?.password;
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
// Try to parse as object
|
|
63
|
+
if (typeof body === 'object') {
|
|
64
|
+
email = body?.email || body?.username;
|
|
65
|
+
password = body?.password;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Validate input
|
|
70
|
+
if (!email || !password) {
|
|
71
|
+
return reply.code(400).send({
|
|
72
|
+
error: 'invalid_request',
|
|
73
|
+
error_description: 'Username/email and password are required',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Authenticate
|
|
78
|
+
const account = await authenticate(email, password);
|
|
79
|
+
|
|
80
|
+
if (!account) {
|
|
81
|
+
return reply.code(401).send({
|
|
82
|
+
error: 'invalid_grant',
|
|
83
|
+
error_description: 'Invalid email or password',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check for DPoP header
|
|
88
|
+
const dpopHeader = request.headers['dpop'];
|
|
89
|
+
let dpopJkt = null;
|
|
90
|
+
|
|
91
|
+
if (dpopHeader) {
|
|
92
|
+
try {
|
|
93
|
+
// Validate DPoP proof and extract thumbprint
|
|
94
|
+
const credUrl = `${issuer.replace(/\/$/, '')}/idp/credentials`;
|
|
95
|
+
dpopJkt = await validateDpopProof(dpopHeader, 'POST', credUrl);
|
|
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
|
+
|
|
106
|
+
// Always generate a proper JWT - CTH requires JWT format
|
|
107
|
+
const jwks = await getJwks();
|
|
108
|
+
const signingKey = jwks.keys[0];
|
|
109
|
+
const privateKey = await jose.importJWK(signingKey, 'ES256');
|
|
110
|
+
|
|
111
|
+
const now = Math.floor(Date.now() / 1000);
|
|
112
|
+
const tokenPayload = {
|
|
113
|
+
iss: issuer,
|
|
114
|
+
sub: account.id,
|
|
115
|
+
aud: 'solid', // Solid-OIDC requires this audience
|
|
116
|
+
webid: account.webId,
|
|
117
|
+
iat: now,
|
|
118
|
+
exp: now + expiresIn,
|
|
119
|
+
jti: crypto.randomUUID(),
|
|
120
|
+
client_id: 'credentials_client',
|
|
121
|
+
scope: 'openid webid',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Add DPoP binding confirmation if DPoP proof was provided
|
|
125
|
+
let tokenType;
|
|
126
|
+
if (dpopJkt) {
|
|
127
|
+
tokenPayload.cnf = { jkt: dpopJkt };
|
|
128
|
+
tokenType = 'DPoP';
|
|
129
|
+
} else {
|
|
130
|
+
tokenType = 'Bearer';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const accessToken = await new jose.SignJWT(tokenPayload)
|
|
134
|
+
.setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
|
|
135
|
+
.sign(privateKey);
|
|
136
|
+
|
|
137
|
+
// Response
|
|
138
|
+
const response = {
|
|
139
|
+
access_token: accessToken,
|
|
140
|
+
token_type: tokenType,
|
|
141
|
+
expires_in: expiresIn,
|
|
142
|
+
webid: account.webId,
|
|
143
|
+
id: account.id,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
reply.header('Cache-Control', 'no-store');
|
|
147
|
+
reply.header('Pragma', 'no-cache');
|
|
148
|
+
|
|
149
|
+
return response;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate a DPoP proof and return the JWK thumbprint
|
|
154
|
+
* @param {string} proof - The DPoP proof JWT
|
|
155
|
+
* @param {string} method - HTTP method
|
|
156
|
+
* @param {string} url - Request URL
|
|
157
|
+
* @returns {Promise<string>} - JWK thumbprint
|
|
158
|
+
*/
|
|
159
|
+
async function validateDpopProof(proof, method, url) {
|
|
160
|
+
// Decode the proof header to get the public key
|
|
161
|
+
const protectedHeader = jose.decodeProtectedHeader(proof);
|
|
162
|
+
|
|
163
|
+
// DPoP proofs must have a JWK in the header
|
|
164
|
+
if (!protectedHeader.jwk) {
|
|
165
|
+
throw new Error('DPoP proof must contain jwk in header');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Verify the proof signature
|
|
169
|
+
const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
|
|
170
|
+
|
|
171
|
+
let payload;
|
|
172
|
+
try {
|
|
173
|
+
const result = await jose.jwtVerify(proof, publicKey, {
|
|
174
|
+
typ: 'dpop+jwt',
|
|
175
|
+
maxTokenAge: '60s',
|
|
176
|
+
});
|
|
177
|
+
payload = result.payload;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
throw new Error(`DPoP proof verification failed: ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Verify htm (HTTP method)
|
|
183
|
+
if (payload.htm !== method) {
|
|
184
|
+
throw new Error(`DPoP htm mismatch: expected ${method}, got ${payload.htm}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Verify htu (HTTP URL) - compare without query string
|
|
188
|
+
const proofUrl = new URL(payload.htu);
|
|
189
|
+
const requestUrl = new URL(url);
|
|
190
|
+
if (proofUrl.origin + proofUrl.pathname !== requestUrl.origin + requestUrl.pathname) {
|
|
191
|
+
throw new Error('DPoP htu mismatch');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Calculate JWK thumbprint
|
|
195
|
+
const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
|
|
196
|
+
|
|
197
|
+
return thumbprint;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Handle GET /idp/credentials
|
|
202
|
+
* Returns info about the credentials endpoint
|
|
203
|
+
*/
|
|
204
|
+
export function handleCredentialsInfo(request, reply, issuer) {
|
|
205
|
+
return {
|
|
206
|
+
endpoint: `${issuer}/idp/credentials`,
|
|
207
|
+
method: 'POST',
|
|
208
|
+
description: 'Obtain access tokens using email/username and password',
|
|
209
|
+
content_types: ['application/json', 'application/x-www-form-urlencoded'],
|
|
210
|
+
parameters: {
|
|
211
|
+
email: 'User email address (or use "username")',
|
|
212
|
+
username: 'Alias for email (for CTH compatibility)',
|
|
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
|
+
}
|
package/src/idp/index.js
CHANGED
|
@@ -12,6 +12,10 @@ import {
|
|
|
12
12
|
handleConsent,
|
|
13
13
|
handleAbort,
|
|
14
14
|
} from './interactions.js';
|
|
15
|
+
import {
|
|
16
|
+
handleCredentials,
|
|
17
|
+
handleCredentialsInfo,
|
|
18
|
+
} from './credentials.js';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* IdP Fastify Plugin
|
|
@@ -32,38 +36,123 @@ export async function idpPlugin(fastify, options) {
|
|
|
32
36
|
// Create the OIDC provider
|
|
33
37
|
const provider = await createProvider(issuer);
|
|
34
38
|
|
|
39
|
+
// Add error listener to catch internal oidc-provider errors
|
|
40
|
+
provider.on('server_error', (ctx, err) => {
|
|
41
|
+
fastify.log.error({
|
|
42
|
+
err: err.message,
|
|
43
|
+
stack: err.stack,
|
|
44
|
+
path: ctx?.path,
|
|
45
|
+
cause: err.cause?.message,
|
|
46
|
+
error_description: err.error_description,
|
|
47
|
+
}, 'oidc-provider server error');
|
|
48
|
+
});
|
|
49
|
+
provider.on('grant.error', (ctx, err) => {
|
|
50
|
+
fastify.log.error({
|
|
51
|
+
err: err.message,
|
|
52
|
+
stack: err.stack?.substring(0, 800),
|
|
53
|
+
cause: err.cause?.message,
|
|
54
|
+
error_description: err.error_description,
|
|
55
|
+
}, 'oidc-provider grant error');
|
|
56
|
+
});
|
|
57
|
+
|
|
35
58
|
// Store provider reference on fastify for handlers
|
|
36
59
|
fastify.decorate('oidcProvider', provider);
|
|
37
60
|
|
|
38
61
|
// Register middleware support for oidc-provider (Koa app)
|
|
39
|
-
await fastify.register(middie
|
|
40
|
-
|
|
62
|
+
await fastify.register(middie);
|
|
63
|
+
|
|
64
|
+
// Helper to forward requests to oidc-provider
|
|
65
|
+
const forwardToProvider = async (request, reply) => {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
// Get raw Node.js req/res
|
|
68
|
+
const req = request.raw;
|
|
69
|
+
const res = reply.raw;
|
|
70
|
+
|
|
71
|
+
// oidc-provider is now configured with /idp routes, no stripping needed
|
|
72
|
+
// Ensure parsed body is accessible to oidc-provider
|
|
73
|
+
// Fastify parses body into request.body, oidc-provider looks for req.body
|
|
74
|
+
if (request.body !== undefined) {
|
|
75
|
+
if (Buffer.isBuffer(request.body)) {
|
|
76
|
+
// Parse buffer to object if it's JSON
|
|
77
|
+
const contentType = request.headers['content-type'] || '';
|
|
78
|
+
if (contentType.includes('application/json')) {
|
|
79
|
+
try {
|
|
80
|
+
req.body = JSON.parse(request.body.toString());
|
|
81
|
+
} catch (e) {
|
|
82
|
+
req.body = request.body;
|
|
83
|
+
}
|
|
84
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
85
|
+
// Parse form data
|
|
86
|
+
const params = new URLSearchParams(request.body.toString());
|
|
87
|
+
req.body = Object.fromEntries(params.entries());
|
|
88
|
+
} else {
|
|
89
|
+
req.body = request.body;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
req.body = request.body;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Call oidc-provider's callback
|
|
97
|
+
provider.callback()(req, res);
|
|
98
|
+
|
|
99
|
+
// Wait for response to finish
|
|
100
|
+
res.on('finish', resolve);
|
|
101
|
+
res.on('error', reject);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Legacy handler for /auth/:uid without prefix - redirect to /idp/auth/:uid
|
|
106
|
+
// In case any old redirects or cached URLs exist
|
|
107
|
+
fastify.get('/auth/:uid', async (request, reply) => {
|
|
108
|
+
return reply.redirect(`/idp/auth/${request.params.uid}`);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Catch-all route for oidc-provider paths
|
|
112
|
+
// Must be registered BEFORE specific routes to be matched as fallback
|
|
113
|
+
const oidcPaths = ['/idp/auth', '/idp/token', '/idp/reg', '/idp/me', '/idp/session', '/idp/session/*'];
|
|
114
|
+
|
|
115
|
+
for (const path of oidcPaths) {
|
|
116
|
+
fastify.route({
|
|
117
|
+
method: ['GET', 'POST', 'DELETE'],
|
|
118
|
+
url: path,
|
|
119
|
+
handler: forwardToProvider,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Also handle /idp/auth/:uid for continued authorization after login
|
|
124
|
+
fastify.get('/idp/auth/:uid', forwardToProvider);
|
|
125
|
+
|
|
126
|
+
// Token sub-paths
|
|
127
|
+
fastify.route({
|
|
128
|
+
method: ['GET', 'POST'],
|
|
129
|
+
url: '/idp/token/introspection',
|
|
130
|
+
handler: forwardToProvider,
|
|
41
131
|
});
|
|
42
132
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (req.url.startsWith('/interaction/')) {
|
|
48
|
-
return next();
|
|
49
|
-
}
|
|
50
|
-
// Let oidc-provider handle everything else
|
|
51
|
-
provider.callback()(req, res);
|
|
133
|
+
fastify.route({
|
|
134
|
+
method: ['GET', 'POST'],
|
|
135
|
+
url: '/idp/token/revocation',
|
|
136
|
+
handler: forwardToProvider,
|
|
52
137
|
});
|
|
53
138
|
|
|
54
139
|
// /.well-known/openid-configuration
|
|
55
140
|
fastify.get('/.well-known/openid-configuration', async (request, reply) => {
|
|
141
|
+
// Ensure issuer has trailing slash for CTH compatibility
|
|
142
|
+
const normalizedIssuer = issuer.endsWith('/') ? issuer : issuer + '/';
|
|
143
|
+
// Base URL without trailing slash for building endpoint URLs
|
|
144
|
+
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
|
56
145
|
// Build discovery document
|
|
57
146
|
const config = {
|
|
58
|
-
issuer,
|
|
59
|
-
authorization_endpoint: `${
|
|
60
|
-
token_endpoint: `${
|
|
61
|
-
userinfo_endpoint: `${
|
|
62
|
-
jwks_uri: `${
|
|
63
|
-
registration_endpoint: `${
|
|
64
|
-
introspection_endpoint: `${
|
|
65
|
-
revocation_endpoint: `${
|
|
66
|
-
end_session_endpoint: `${
|
|
147
|
+
issuer: normalizedIssuer,
|
|
148
|
+
authorization_endpoint: `${baseUrl}/idp/auth`,
|
|
149
|
+
token_endpoint: `${baseUrl}/idp/token`,
|
|
150
|
+
userinfo_endpoint: `${baseUrl}/idp/me`,
|
|
151
|
+
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
|
152
|
+
registration_endpoint: `${baseUrl}/idp/reg`,
|
|
153
|
+
introspection_endpoint: `${baseUrl}/idp/token/introspection`,
|
|
154
|
+
revocation_endpoint: `${baseUrl}/idp/token/revocation`,
|
|
155
|
+
end_session_endpoint: `${baseUrl}/idp/session/end`,
|
|
67
156
|
scopes_supported: ['openid', 'webid', 'profile', 'email', 'offline_access'],
|
|
68
157
|
response_types_supported: ['code'],
|
|
69
158
|
response_modes_supported: ['query', 'fragment', 'form_post'],
|
|
@@ -89,6 +178,19 @@ export async function idpPlugin(fastify, options) {
|
|
|
89
178
|
return jwks;
|
|
90
179
|
});
|
|
91
180
|
|
|
181
|
+
// Programmatic credentials endpoint for CTH compatibility
|
|
182
|
+
// Allows obtaining tokens via email/password without browser interaction
|
|
183
|
+
|
|
184
|
+
// GET credentials info
|
|
185
|
+
fastify.get('/idp/credentials', async (request, reply) => {
|
|
186
|
+
return handleCredentialsInfo(request, reply, issuer);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// POST credentials - obtain tokens
|
|
190
|
+
fastify.post('/idp/credentials', async (request, reply) => {
|
|
191
|
+
return handleCredentials(request, reply, issuer);
|
|
192
|
+
});
|
|
193
|
+
|
|
92
194
|
// Interaction routes (our custom login/consent UI)
|
|
93
195
|
// These bypass oidc-provider and use our handlers
|
|
94
196
|
|
|
@@ -97,7 +199,13 @@ export async function idpPlugin(fastify, options) {
|
|
|
97
199
|
return handleInteractionGet(request, reply, provider);
|
|
98
200
|
});
|
|
99
201
|
|
|
100
|
-
// POST
|
|
202
|
+
// POST interaction - direct form submission (CTH compatibility)
|
|
203
|
+
// This handles form submissions directly to /idp/interaction/:uid
|
|
204
|
+
fastify.post('/idp/interaction/:uid', async (request, reply) => {
|
|
205
|
+
return handleLogin(request, reply, provider);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// POST login (explicit path)
|
|
101
209
|
fastify.post('/idp/interaction/:uid/login', async (request, reply) => {
|
|
102
210
|
return handleLogin(request, reply, provider);
|
|
103
211
|
});
|
package/src/idp/interactions.js
CHANGED
|
@@ -48,7 +48,44 @@ export async function handleInteractionGet(request, reply, provider) {
|
|
|
48
48
|
*/
|
|
49
49
|
export async function handleLogin(request, reply, provider) {
|
|
50
50
|
const { uid } = request.params;
|
|
51
|
-
|
|
51
|
+
|
|
52
|
+
// Parse body - handle multiple formats (Buffer, string, object)
|
|
53
|
+
let parsedBody = request.body || {};
|
|
54
|
+
const contentType = request.headers['content-type'] || '';
|
|
55
|
+
|
|
56
|
+
if (Buffer.isBuffer(parsedBody)) {
|
|
57
|
+
const bodyStr = parsedBody.toString();
|
|
58
|
+
if (contentType.includes('application/json')) {
|
|
59
|
+
try {
|
|
60
|
+
parsedBody = JSON.parse(bodyStr);
|
|
61
|
+
} catch (e) {
|
|
62
|
+
parsedBody = {};
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Assume form-urlencoded
|
|
66
|
+
const params = new URLSearchParams(bodyStr);
|
|
67
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
68
|
+
}
|
|
69
|
+
} else if (typeof parsedBody === 'string') {
|
|
70
|
+
// Body might be a string for form-urlencoded
|
|
71
|
+
if (contentType.includes('application/json')) {
|
|
72
|
+
try {
|
|
73
|
+
parsedBody = JSON.parse(parsedBody);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
parsedBody = {};
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const params = new URLSearchParams(parsedBody);
|
|
79
|
+
parsedBody = Object.fromEntries(params.entries());
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// If it's already an object, use as-is
|
|
83
|
+
|
|
84
|
+
// Support both 'email' and 'username' fields for CTH compatibility
|
|
85
|
+
const email = parsedBody.email || parsedBody.username;
|
|
86
|
+
const password = parsedBody.password;
|
|
87
|
+
|
|
88
|
+
request.log.info({ email, hasPassword: !!password, bodyType: typeof request.body, keys: Object.keys(parsedBody) }, 'Login attempt');
|
|
52
89
|
|
|
53
90
|
try {
|
|
54
91
|
const interaction = await provider.Interaction.find(uid);
|
|
@@ -79,14 +116,86 @@ export async function handleLogin(request, reply, provider) {
|
|
|
79
116
|
},
|
|
80
117
|
};
|
|
81
118
|
|
|
82
|
-
|
|
83
|
-
request.raw,
|
|
84
|
-
reply.raw,
|
|
85
|
-
result,
|
|
86
|
-
{ mergeWithLastSubmission: false }
|
|
87
|
-
);
|
|
119
|
+
request.log.info({ accountId: account.id, uid }, 'Login successful');
|
|
88
120
|
|
|
89
|
-
return
|
|
121
|
+
// For CTH compatibility, we need to return a response that CTH can handle.
|
|
122
|
+
// CTH expects either:
|
|
123
|
+
// 1. A redirect it can follow (but Java HttpClient follows to final destination which fails)
|
|
124
|
+
// 2. A 200 response with "location" in body (CSS v3+ style)
|
|
125
|
+
//
|
|
126
|
+
// We use interactionResult to get the redirect URL, then save it and return JSON
|
|
127
|
+
|
|
128
|
+
// Save the login result to the interaction for programmatic clients
|
|
129
|
+
// This allows the auth endpoint to continue the flow when resumed
|
|
130
|
+
interaction.result = result;
|
|
131
|
+
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
|
|
132
|
+
|
|
133
|
+
// For CTH and programmatic clients: use interactionFinished with hijacked response
|
|
134
|
+
// to properly complete the interaction while returning JSON
|
|
135
|
+
try {
|
|
136
|
+
reply.hijack();
|
|
137
|
+
|
|
138
|
+
// Create a mock response that captures the redirect and returns JSON
|
|
139
|
+
let capturedLocation = null;
|
|
140
|
+
let headersSent = false;
|
|
141
|
+
const mockRes = {
|
|
142
|
+
statusCode: 200,
|
|
143
|
+
headersSent: false,
|
|
144
|
+
setHeader: (name, value) => {
|
|
145
|
+
if (name.toLowerCase() === 'location') {
|
|
146
|
+
capturedLocation = value;
|
|
147
|
+
}
|
|
148
|
+
return mockRes;
|
|
149
|
+
},
|
|
150
|
+
getHeader: (name) => {
|
|
151
|
+
if (name.toLowerCase() === 'location') return capturedLocation;
|
|
152
|
+
return undefined;
|
|
153
|
+
},
|
|
154
|
+
removeHeader: () => mockRes,
|
|
155
|
+
writeHead: (status, headers) => {
|
|
156
|
+
if (headers) {
|
|
157
|
+
if (typeof headers === 'object' && !Array.isArray(headers)) {
|
|
158
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
159
|
+
if (key.toLowerCase() === 'location') {
|
|
160
|
+
capturedLocation = value;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return mockRes;
|
|
166
|
+
},
|
|
167
|
+
write: () => mockRes,
|
|
168
|
+
end: (body) => {
|
|
169
|
+
if (!headersSent) {
|
|
170
|
+
headersSent = true;
|
|
171
|
+
const location = capturedLocation || `/idp/auth/${uid}`;
|
|
172
|
+
reply.raw.writeHead(200, {
|
|
173
|
+
'Content-Type': 'application/json',
|
|
174
|
+
'Location': location,
|
|
175
|
+
});
|
|
176
|
+
reply.raw.end(JSON.stringify({ location }));
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
finished: false,
|
|
180
|
+
on: () => mockRes,
|
|
181
|
+
once: () => mockRes,
|
|
182
|
+
emit: () => mockRes,
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
await provider.interactionFinished(request.raw, mockRes, result, { mergeWithLastSubmission: false });
|
|
186
|
+
return;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
request.log.warn({ err: err.message, errName: err.name, uid }, 'interactionFinished failed, using fallback');
|
|
189
|
+
|
|
190
|
+
// Fallback: return the redirect URL for manual following
|
|
191
|
+
// The interaction result is already saved above
|
|
192
|
+
const redirectTo = `/idp/auth/${uid}`;
|
|
193
|
+
return reply
|
|
194
|
+
.code(200)
|
|
195
|
+
.header('Location', redirectTo)
|
|
196
|
+
.type('application/json')
|
|
197
|
+
.send({ location: redirectTo });
|
|
198
|
+
}
|
|
90
199
|
} catch (err) {
|
|
91
200
|
request.log.error(err, 'Login error');
|
|
92
201
|
return reply.code(500).type('text/html').send(errorPage('Login failed', err.message));
|
|
@@ -138,14 +247,16 @@ export async function handleConsent(request, reply, provider) {
|
|
|
138
247
|
},
|
|
139
248
|
};
|
|
140
249
|
|
|
141
|
-
|
|
250
|
+
// Mark reply as sent since interactionFinished will handle the response
|
|
251
|
+
reply.hijack();
|
|
252
|
+
|
|
253
|
+
// Use interactionFinished which handles the redirect directly
|
|
254
|
+
return provider.interactionFinished(
|
|
142
255
|
request.raw,
|
|
143
256
|
reply.raw,
|
|
144
257
|
result,
|
|
145
258
|
{ mergeWithLastSubmission: true }
|
|
146
259
|
);
|
|
147
|
-
|
|
148
|
-
return reply.redirect(redirectTo);
|
|
149
260
|
} catch (err) {
|
|
150
261
|
request.log.error(err, 'Consent error');
|
|
151
262
|
return reply.code(500).type('text/html').send(errorPage('Consent failed', err.message));
|
|
@@ -165,6 +276,7 @@ export async function handleAbort(request, reply, provider) {
|
|
|
165
276
|
error_description: 'User cancelled the authorization request',
|
|
166
277
|
};
|
|
167
278
|
|
|
279
|
+
// oidc-provider is configured with /idp routes, so redirectTo will have correct path
|
|
168
280
|
const redirectTo = await provider.interactionResult(
|
|
169
281
|
request.raw,
|
|
170
282
|
reply.raw,
|
package/src/idp/provider.js
CHANGED
|
@@ -27,16 +27,19 @@ export async function createProvider(issuer) {
|
|
|
27
27
|
// Cookie configuration
|
|
28
28
|
cookies: {
|
|
29
29
|
keys: cookieKeys,
|
|
30
|
+
// Use root path so cookies work across all endpoints
|
|
30
31
|
long: {
|
|
31
32
|
signed: true,
|
|
32
33
|
maxAge: 14 * 24 * 60 * 60 * 1000, // 14 days
|
|
33
34
|
httpOnly: true,
|
|
34
35
|
sameSite: 'lax',
|
|
36
|
+
path: '/',
|
|
35
37
|
},
|
|
36
38
|
short: {
|
|
37
39
|
signed: true,
|
|
38
40
|
httpOnly: true,
|
|
39
41
|
sameSite: 'lax',
|
|
42
|
+
path: '/',
|
|
40
43
|
},
|
|
41
44
|
},
|
|
42
45
|
|
|
@@ -94,13 +97,16 @@ export async function createProvider(issuer) {
|
|
|
94
97
|
enabled: false, // Keep disabled for MVP
|
|
95
98
|
},
|
|
96
99
|
|
|
97
|
-
// Allow resource parameter
|
|
100
|
+
// Allow resource parameter - always use JWT format for access tokens
|
|
101
|
+
// Resource must be a valid URI, but audience can be 'solid' for Solid-OIDC
|
|
98
102
|
resourceIndicators: {
|
|
99
103
|
enabled: true,
|
|
100
|
-
|
|
104
|
+
// Default to a URI resource that maps to audience 'solid'
|
|
105
|
+
defaultResource: () => 'urn:solid',
|
|
101
106
|
getResourceServerInfo: () => ({
|
|
102
107
|
scope: 'openid webid profile email offline_access',
|
|
103
108
|
accessTokenFormat: 'jwt',
|
|
109
|
+
audience: 'solid', // Solid-OIDC requires this audience
|
|
104
110
|
}),
|
|
105
111
|
useGrantedResource: () => true,
|
|
106
112
|
},
|
|
@@ -176,6 +182,60 @@ export async function createProvider(issuer) {
|
|
|
176
182
|
},
|
|
177
183
|
},
|
|
178
184
|
|
|
185
|
+
// Auto-approve consent by loading/creating grants automatically
|
|
186
|
+
// This skips the consent prompt for all clients (appropriate for test/dev servers)
|
|
187
|
+
loadExistingGrant: async (ctx) => {
|
|
188
|
+
// Check if there's an existing grant for this client/account pair
|
|
189
|
+
const grantId = ctx.oidc.session?.grantIdFor(ctx.oidc.client?.clientId);
|
|
190
|
+
|
|
191
|
+
if (grantId) {
|
|
192
|
+
const existingGrant = await ctx.oidc.provider.Grant.find(grantId);
|
|
193
|
+
if (existingGrant) {
|
|
194
|
+
return existingGrant;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Auto-approve: create a new grant with all requested scopes
|
|
199
|
+
if (ctx.oidc.session?.accountId && ctx.oidc.client?.clientId) {
|
|
200
|
+
const grant = new ctx.oidc.provider.Grant({
|
|
201
|
+
accountId: ctx.oidc.session.accountId,
|
|
202
|
+
clientId: ctx.oidc.client.clientId,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Grant all requested OIDC scopes
|
|
206
|
+
if (ctx.oidc.params?.scope) {
|
|
207
|
+
grant.addOIDCScope(ctx.oidc.params.scope);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Grant all requested resource scopes
|
|
211
|
+
if (ctx.oidc.params?.resource) {
|
|
212
|
+
const resources = Array.isArray(ctx.oidc.params.resource)
|
|
213
|
+
? ctx.oidc.params.resource
|
|
214
|
+
: [ctx.oidc.params.resource];
|
|
215
|
+
for (const resource of resources) {
|
|
216
|
+
grant.addResourceScope(resource, ctx.oidc.params.scope || 'openid');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await grant.save();
|
|
221
|
+
return grant;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return undefined;
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
// Configure routes with /idp prefix so oidc-provider uses correct paths
|
|
228
|
+
routes: {
|
|
229
|
+
authorization: '/idp/auth',
|
|
230
|
+
token: '/idp/token',
|
|
231
|
+
userinfo: '/idp/me',
|
|
232
|
+
jwks: '/.well-known/jwks.json',
|
|
233
|
+
registration: '/idp/reg',
|
|
234
|
+
introspection: '/idp/token/introspection',
|
|
235
|
+
revocation: '/idp/token/revocation',
|
|
236
|
+
end_session: '/idp/session/end',
|
|
237
|
+
},
|
|
238
|
+
|
|
179
239
|
// Enable refresh token rotation
|
|
180
240
|
rotateRefreshToken: (ctx) => {
|
|
181
241
|
return true;
|
|
@@ -186,6 +246,7 @@ export async function createProvider(issuer) {
|
|
|
186
246
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
187
247
|
response_types: ['code'],
|
|
188
248
|
token_endpoint_auth_method: 'none', // Public clients by default
|
|
249
|
+
id_token_signed_response_alg: 'ES256', // ES256 is what we support
|
|
189
250
|
},
|
|
190
251
|
|
|
191
252
|
// Response modes
|
|
@@ -201,6 +262,11 @@ export async function createProvider(issuer) {
|
|
|
201
262
|
methods: ['S256'],
|
|
202
263
|
},
|
|
203
264
|
|
|
265
|
+
// Enable RS256 for DPoP (CTH uses RS256)
|
|
266
|
+
enabledJWA: {
|
|
267
|
+
dPoPSigningAlgValues: ['ES256', 'RS256', 'Ed25519', 'EdDSA'],
|
|
268
|
+
},
|
|
269
|
+
|
|
204
270
|
// Enable request parameter
|
|
205
271
|
requestObjects: {
|
|
206
272
|
request: false,
|