Skip to content

Commit f3e9cd4

Browse files
Fix CTH authentication tests - 6/6 scenarios pass
Key fixes for CTH (Conformance Test Harness) compatibility: - Add WWW-Authenticate header to 401 responses (required by Solid spec) - Fix credentials endpoint to always return proper JWTs with aud:'solid' - Fix DPoP URL validation (remove double slash from issuer) - Add RS256 to DPoP signing algorithms (CTH uses RS256) - Enable PUT for container creation (Solid spec requirement) - Fix default ACL to not propagate public read to children (children now require authentication by default) - Configure resourceIndicators for JWT access token format - Fix interaction login flow to return JSON for CTH programmatic login - Handle scope as array or string in claims function The authentication-header test now passes all 6 scenarios: - GET, HEAD, PUT, POST, DELETE, PATCH to protected resources
1 parent f7cb7f5 commit f3e9cd4

11 files changed

Lines changed: 449 additions & 86 deletions

File tree

bin/jss.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@ program
6262
const protocol = config.ssl ? 'https' : 'http';
6363
const serverHost = config.host === '0.0.0.0' ? 'localhost' : config.host;
6464
const baseUrl = `${protocol}://${serverHost}:${config.port}`;
65-
const idpIssuer = config.idpIssuer || baseUrl;
65+
// Ensure issuer has trailing slash for CTH compatibility
66+
let idpIssuer = config.idpIssuer || baseUrl;
67+
if (idpIssuer && !idpIssuer.endsWith('/')) {
68+
idpIssuer = idpIssuer + '/';
69+
}
6670

6771
// Create and start server
6872
const server = createServer({

src/auth/middleware.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,16 @@ function getParentPath(path) {
7979
* @param {boolean} isAuthenticated - Whether user is authenticated
8080
* @param {string} wacAllow - WAC-Allow header value
8181
* @param {string|null} authError - Authentication error message (for DPoP failures)
82+
* @param {string|null} issuer - IdP issuer URL for WWW-Authenticate header
8283
*/
83-
export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null) {
84+
export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null, issuer = null) {
8485
reply.header('WAC-Allow', wacAllow);
8586

8687
if (!isAuthenticated) {
87-
// Not authenticated - return 401
88+
// Not authenticated - return 401 with WWW-Authenticate header
89+
// Solid-OIDC requires DPoP authentication
90+
const realm = issuer || 'Solid';
91+
reply.header('WWW-Authenticate', `DPoP realm="${realm}", Bearer realm="${realm}"`);
8892
return reply.code(401).send({
8993
error: 'Unauthorized',
9094
message: authError || 'Authentication required'

src/handlers/container.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ export async function handleCreatePod(request, reply) {
157157
const baseUri = `${request.protocol}://${request.hostname}`;
158158
const podUri = `${baseUri}${podPath}`;
159159
const webId = `${podUri}#me`;
160-
const issuer = baseUri;
160+
// Issuer needs trailing slash for CTH compatibility
161+
const issuer = baseUri + '/';
161162

162163
try {
163164
// Create pod directory structure
@@ -223,7 +224,7 @@ export async function handleCreatePod(request, reply) {
223224
webId,
224225
podUri,
225226
idpIssuer: issuer,
226-
loginUrl: `${issuer}/idp/auth`,
227+
loginUrl: `${baseUri}/idp/auth`,
227228
});
228229
} catch (err) {
229230
console.error('Account creation error:', err);

src/handlers/resource.js

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,46 @@ export async function handleGet(request, reply) {
5151
const content = await storage.read(indexPath);
5252
const indexStats = await storage.stat(indexPath);
5353

54+
// Check if RDF format requested via content negotiation
55+
const acceptHeader = request.headers.accept || '';
56+
const wantsTurtle = connegEnabled && (
57+
acceptHeader.includes('text/turtle') ||
58+
acceptHeader.includes('text/n3') ||
59+
acceptHeader.includes('application/n-triples')
60+
);
61+
62+
if (wantsTurtle) {
63+
// Extract JSON-LD from HTML and convert to Turtle
64+
try {
65+
const htmlStr = content.toString();
66+
const jsonLdMatch = htmlStr.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
67+
if (jsonLdMatch) {
68+
const jsonLd = JSON.parse(jsonLdMatch[1]);
69+
const { content: turtleContent } = await fromJsonLd(
70+
jsonLd,
71+
'text/turtle',
72+
resourceUrl,
73+
true
74+
);
75+
76+
const headers = getAllHeaders({
77+
isContainer: true,
78+
etag: indexStats?.etag || stats.etag,
79+
contentType: 'text/turtle',
80+
origin,
81+
resourceUrl,
82+
connegEnabled
83+
});
84+
85+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
86+
return reply.send(turtleContent);
87+
}
88+
} catch (err) {
89+
// Fall through to serve HTML if conversion fails
90+
console.error('Failed to convert profile to Turtle:', err.message);
91+
}
92+
}
93+
5494
const headers = getAllHeaders({
5595
isContainer: true,
5696
etag: indexStats?.etag || stats.etag,
@@ -176,15 +216,36 @@ export async function handleHead(request, reply) {
176216
*/
177217
export async function handlePut(request, reply) {
178218
const urlPath = request.url.split('?')[0];
219+
const connegEnabled = request.connegEnabled || false;
220+
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
179221

180-
// Don't allow PUT to containers
222+
// Handle container creation via PUT
181223
if (isContainer(urlPath)) {
182-
return reply.code(409).send({ error: 'Cannot PUT to container. Use POST instead.' });
224+
const stats = await storage.stat(urlPath);
225+
if (stats?.isDirectory) {
226+
// Container already exists - don't allow PUT to modify
227+
return reply.code(409).send({ error: 'Cannot PUT to existing container' });
228+
}
229+
230+
// Create the container (and any intermediate containers)
231+
const success = await storage.createContainer(urlPath);
232+
if (!success) {
233+
return reply.code(500).send({ error: 'Failed to create container' });
234+
}
235+
236+
const origin = request.headers.origin;
237+
const headers = getAllHeaders({
238+
isContainer: true,
239+
origin,
240+
connegEnabled
241+
});
242+
headers['Location'] = resourceUrl;
243+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
244+
emitChange(request.protocol + '://' + request.hostname, urlPath, 'created');
245+
return reply.code(201).send();
183246
}
184247

185-
const connegEnabled = request.connegEnabled || false;
186248
const contentType = request.headers['content-type'] || '';
187-
const resourceUrl = `${request.protocol}://${request.hostname}${urlPath}`;
188249

189250
// Check if we can accept this input type
190251
if (!canAcceptInput(contentType, connegEnabled)) {

src/idp/accounts.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,22 @@ export async function getAccountForProvider(id) {
241241
// Always include webid for Solid-OIDC
242242
result.webid = account.webId;
243243

244+
// Handle scope being a string, array, Set, or object with keys
245+
const hasScope = (s) => {
246+
if (typeof scope === 'string') return scope.includes(s);
247+
if (Array.isArray(scope)) return scope.includes(s);
248+
if (scope instanceof Set) return scope.has(s);
249+
if (scope && typeof scope === 'object') return s in scope || Object.keys(scope).includes(s);
250+
return false;
251+
};
252+
244253
// Profile scope
245-
if (scope.includes('profile')) {
254+
if (hasScope('profile')) {
246255
result.name = account.podName;
247256
}
248257

249258
// Email scope
250-
if (scope.includes('email')) {
259+
if (hasScope('email')) {
251260
result.email = account.email;
252261
result.email_verified = false; // We don't have email verification yet
253262
}

src/idp/credentials.js

Lines changed: 38 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@
55

66
import * as jose from 'jose';
77
import crypto from 'crypto';
8-
import { authenticate, findByEmail } from './accounts.js';
8+
import { authenticate } from './accounts.js';
99
import { getJwks } from './keys.js';
10-
import { createToken as createSimpleToken } from '../auth/token.js';
1110

1211
/**
1312
* Handle POST /idp/credentials
14-
* Accepts email/password and returns access token
13+
* Accepts email/password (or username/password) and returns access token
1514
*
1615
* Request body (JSON or form):
17-
* - email: User email
16+
* - email or username: User email address
1817
* - password: User password
1918
*
2019
* Optional headers:
@@ -47,22 +46,22 @@ export async function handleCredentials(request, reply, issuer) {
4746
// Not valid JSON
4847
}
4948
}
50-
email = body?.email;
49+
email = body?.email || body?.username;
5150
password = body?.password;
5251
} else if (contentType.includes('application/x-www-form-urlencoded')) {
5352
// Parse form-encoded body
5453
if (typeof body === 'string') {
5554
const params = new URLSearchParams(body);
56-
email = params.get('email');
55+
email = params.get('email') || params.get('username');
5756
password = params.get('password');
5857
} else if (typeof body === 'object') {
59-
email = body?.email;
58+
email = body?.email || body?.username;
6059
password = body?.password;
6160
}
6261
} else {
6362
// Try to parse as object
6463
if (typeof body === 'object') {
65-
email = body?.email;
64+
email = body?.email || body?.username;
6665
password = body?.password;
6766
}
6867
}
@@ -71,7 +70,7 @@ export async function handleCredentials(request, reply, issuer) {
7170
if (!email || !password) {
7271
return reply.code(400).send({
7372
error: 'invalid_request',
74-
error_description: 'Email and password are required',
73+
error_description: 'Username/email and password are required',
7574
});
7675
}
7776

@@ -92,7 +91,8 @@ export async function handleCredentials(request, reply, issuer) {
9291
if (dpopHeader) {
9392
try {
9493
// Validate DPoP proof and extract thumbprint
95-
dpopJkt = await validateDpopProof(dpopHeader, 'POST', `${issuer}/idp/credentials`);
94+
const credUrl = `${issuer.replace(/\/$/, '')}/idp/credentials`;
95+
dpopJkt = await validateDpopProof(dpopHeader, 'POST', credUrl);
9696
} catch (err) {
9797
return reply.code(400).send({
9898
error: 'invalid_dpop_proof',
@@ -102,39 +102,38 @@ export async function handleCredentials(request, reply, issuer) {
102102
}
103103

104104
const expiresIn = 3600; // 1 hour
105-
let accessToken;
106-
let tokenType;
107105

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;
108126
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);
127+
tokenPayload.cnf = { jkt: dpopJkt };
131128
tokenType = 'DPoP';
132129
} else {
133-
// Generate simple token for Bearer auth (development/testing)
134-
accessToken = createSimpleToken(account.webId, expiresIn);
135130
tokenType = 'Bearer';
136131
}
137132

133+
const accessToken = await new jose.SignJWT(tokenPayload)
134+
.setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
135+
.sign(privateKey);
136+
138137
// Response
139138
const response = {
140139
access_token: accessToken,
@@ -206,10 +205,11 @@ export function handleCredentialsInfo(request, reply, issuer) {
206205
return {
207206
endpoint: `${issuer}/idp/credentials`,
208207
method: 'POST',
209-
description: 'Obtain access tokens using email and password',
208+
description: 'Obtain access tokens using email/username and password',
210209
content_types: ['application/json', 'application/x-www-form-urlencoded'],
211210
parameters: {
212-
email: 'User email address',
211+
email: 'User email address (or use "username")',
212+
username: 'Alias for email (for CTH compatibility)',
213213
password: 'User password',
214214
},
215215
optional_headers: {

0 commit comments

Comments
 (0)