javascript-solid-server 0.0.13 → 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.
Files changed (46) hide show
  1. package/.claude/settings.local.json +26 -1
  2. package/CTH.md +222 -0
  3. package/README.md +32 -1
  4. package/bin/jss.js +5 -1
  5. package/cth-config/application.yaml +2 -0
  6. package/cth-config/jss.ttl +6 -0
  7. package/cth-config/test-subjects.ttl +14 -0
  8. package/cth.env +19 -0
  9. package/package.json +1 -1
  10. package/scripts/test-cth-compat.js +3 -2
  11. package/src/auth/middleware.js +6 -2
  12. package/src/auth/token.js +44 -1
  13. package/src/handlers/container.js +8 -3
  14. package/src/handlers/resource.js +65 -4
  15. package/src/idp/accounts.js +11 -2
  16. package/src/idp/credentials.js +38 -38
  17. package/src/idp/index.js +112 -21
  18. package/src/idp/interactions.js +123 -11
  19. package/src/idp/provider.js +68 -2
  20. package/src/rdf/turtle.js +15 -2
  21. package/src/wac/parser.js +43 -1
  22. package/test/idp.test.js +17 -14
  23. package/test/ldp.test.js +10 -5
  24. package/test-data-idp-accounts/.idp/accounts/292738d6-3363-4f40-9a6b-884bfd17830a.json +9 -0
  25. package/test-data-idp-accounts/.idp/accounts/_email_index.json +3 -0
  26. package/test-data-idp-accounts/.idp/accounts/_webid_index.json +3 -0
  27. package/test-data-idp-accounts/.idp/keys/jwks.json +22 -0
  28. package/test-dpop-flow.js +148 -0
  29. package/test-subjects.ttl +21 -0
  30. package/data/alice/.acl +0 -50
  31. package/data/alice/inbox/.acl +0 -50
  32. package/data/alice/index.html +0 -80
  33. package/data/alice/private/.acl +0 -32
  34. package/data/alice/public/test.json +0 -1
  35. package/data/alice/settings/.acl +0 -32
  36. package/data/alice/settings/prefs +0 -17
  37. package/data/alice/settings/privateTypeIndex +0 -7
  38. package/data/alice/settings/publicTypeIndex +0 -7
  39. package/data/bob/.acl +0 -50
  40. package/data/bob/inbox/.acl +0 -50
  41. package/data/bob/index.html +0 -80
  42. package/data/bob/private/.acl +0 -32
  43. package/data/bob/settings/.acl +0 -32
  44. package/data/bob/settings/prefs +0 -17
  45. package/data/bob/settings/privateTypeIndex +0 -7
  46. package/data/bob/settings/publicTypeIndex +0 -7
@@ -5,16 +5,15 @@
5
5
 
6
6
  import * as jose from 'jose';
7
7
  import crypto from 'crypto';
8
- import { authenticate, findByEmail } from './accounts.js';
8
+ import { authenticate } from './accounts.js';
9
9
  import { getJwks } from './keys.js';
10
- import { createToken as createSimpleToken } from '../auth/token.js';
11
10
 
12
11
  /**
13
12
  * Handle POST /idp/credentials
14
- * Accepts email/password and returns access token
13
+ * Accepts email/password (or username/password) and returns access token
15
14
  *
16
15
  * Request body (JSON or form):
17
- * - email: User email
16
+ * - email or username: User email address
18
17
  * - password: User password
19
18
  *
20
19
  * Optional headers:
@@ -47,22 +46,22 @@ export async function handleCredentials(request, reply, issuer) {
47
46
  // Not valid JSON
48
47
  }
49
48
  }
50
- email = body?.email;
49
+ email = body?.email || body?.username;
51
50
  password = body?.password;
52
51
  } else if (contentType.includes('application/x-www-form-urlencoded')) {
53
52
  // Parse form-encoded body
54
53
  if (typeof body === 'string') {
55
54
  const params = new URLSearchParams(body);
56
- email = params.get('email');
55
+ email = params.get('email') || params.get('username');
57
56
  password = params.get('password');
58
57
  } else if (typeof body === 'object') {
59
- email = body?.email;
58
+ email = body?.email || body?.username;
60
59
  password = body?.password;
61
60
  }
62
61
  } else {
63
62
  // Try to parse as object
64
63
  if (typeof body === 'object') {
65
- email = body?.email;
64
+ email = body?.email || body?.username;
66
65
  password = body?.password;
67
66
  }
68
67
  }
@@ -71,7 +70,7 @@ export async function handleCredentials(request, reply, issuer) {
71
70
  if (!email || !password) {
72
71
  return reply.code(400).send({
73
72
  error: 'invalid_request',
74
- error_description: 'Email and password are required',
73
+ error_description: 'Username/email and password are required',
75
74
  });
76
75
  }
77
76
 
@@ -92,7 +91,8 @@ export async function handleCredentials(request, reply, issuer) {
92
91
  if (dpopHeader) {
93
92
  try {
94
93
  // 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);
96
96
  } catch (err) {
97
97
  return reply.code(400).send({
98
98
  error: 'invalid_dpop_proof',
@@ -102,39 +102,38 @@ export async function handleCredentials(request, reply, issuer) {
102
102
  }
103
103
 
104
104
  const expiresIn = 3600; // 1 hour
105
- let accessToken;
106
- let tokenType;
107
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;
108
126
  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 };
131
128
  tokenType = 'DPoP';
132
129
  } else {
133
- // Generate simple token for Bearer auth (development/testing)
134
- accessToken = createSimpleToken(account.webId, expiresIn);
135
130
  tokenType = 'Bearer';
136
131
  }
137
132
 
133
+ const accessToken = await new jose.SignJWT(tokenPayload)
134
+ .setProtectedHeader({ alg: 'ES256', kid: signingKey.kid })
135
+ .sign(privateKey);
136
+
138
137
  // Response
139
138
  const response = {
140
139
  access_token: accessToken,
@@ -206,10 +205,11 @@ export function handleCredentialsInfo(request, reply, issuer) {
206
205
  return {
207
206
  endpoint: `${issuer}/idp/credentials`,
208
207
  method: 'POST',
209
- description: 'Obtain access tokens using email and password',
208
+ description: 'Obtain access tokens using email/username and password',
210
209
  content_types: ['application/json', 'application/x-www-form-urlencoded'],
211
210
  parameters: {
212
- email: 'User email address',
211
+ email: 'User email address (or use "username")',
212
+ username: 'Alias for email (for CTH compatibility)',
213
213
  password: 'User password',
214
214
  },
215
215
  optional_headers: {
package/src/idp/index.js CHANGED
@@ -36,38 +36,123 @@ export async function idpPlugin(fastify, options) {
36
36
  // Create the OIDC provider
37
37
  const provider = await createProvider(issuer);
38
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
+
39
58
  // Store provider reference on fastify for handlers
40
59
  fastify.decorate('oidcProvider', provider);
41
60
 
42
61
  // Register middleware support for oidc-provider (Koa app)
43
- await fastify.register(middie, {
44
- hook: 'preHandler',
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,
45
131
  });
46
132
 
47
- // Mount oidc-provider on /idp path
48
- // oidc-provider is a Koa app, middie handles the bridge
49
- fastify.use('/idp', (req, res, next) => {
50
- // Skip our custom routes (handled by Fastify)
51
- if (req.url.startsWith('/interaction/') || req.url.startsWith('/credentials')) {
52
- return next();
53
- }
54
- // Let oidc-provider handle everything else
55
- provider.callback()(req, res);
133
+ fastify.route({
134
+ method: ['GET', 'POST'],
135
+ url: '/idp/token/revocation',
136
+ handler: forwardToProvider,
56
137
  });
57
138
 
58
139
  // /.well-known/openid-configuration
59
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;
60
145
  // Build discovery document
61
146
  const config = {
62
- issuer,
63
- authorization_endpoint: `${issuer}/idp/auth`,
64
- token_endpoint: `${issuer}/idp/token`,
65
- userinfo_endpoint: `${issuer}/idp/me`,
66
- jwks_uri: `${issuer}/.well-known/jwks.json`,
67
- registration_endpoint: `${issuer}/idp/reg`,
68
- introspection_endpoint: `${issuer}/idp/token/introspection`,
69
- revocation_endpoint: `${issuer}/idp/token/revocation`,
70
- end_session_endpoint: `${issuer}/idp/session/end`,
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`,
71
156
  scopes_supported: ['openid', 'webid', 'profile', 'email', 'offline_access'],
72
157
  response_types_supported: ['code'],
73
158
  response_modes_supported: ['query', 'fragment', 'form_post'],
@@ -114,7 +199,13 @@ export async function idpPlugin(fastify, options) {
114
199
  return handleInteractionGet(request, reply, provider);
115
200
  });
116
201
 
117
- // POST login
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)
118
209
  fastify.post('/idp/interaction/:uid/login', async (request, reply) => {
119
210
  return handleLogin(request, reply, provider);
120
211
  });
@@ -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
- const { email, password } = request.body || {};
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
- const redirectTo = await provider.interactionResult(
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 reply.redirect(redirectTo);
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
- const redirectTo = await provider.interactionResult(
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,
@@ -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
- defaultResource: () => undefined,
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,
package/src/rdf/turtle.js CHANGED
@@ -199,9 +199,13 @@ function jsonLdToQuads(jsonLd, baseUri) {
199
199
  const predicateUri = expandUri(key, context);
200
200
  const predicate = namedNode(predicateUri);
201
201
 
202
+ // Check if context specifies this property should be a URI (@type: "@id")
203
+ const propContext = context[key];
204
+ const isIdType = propContext && typeof propContext === 'object' && propContext['@type'] === '@id';
205
+
202
206
  const values = Array.isArray(value) ? value : [value];
203
207
  for (const v of values) {
204
- const object = valueToTerm(v, baseUri, context);
208
+ const object = valueToTerm(v, baseUri, context, isIdType);
205
209
  if (object) {
206
210
  quads.push(quad(subject, predicate, object));
207
211
  }
@@ -265,14 +269,23 @@ function termToJsonLd(term, baseUri, prefixes) {
265
269
 
266
270
  /**
267
271
  * Convert JSON-LD value to N3.js term
272
+ * @param {any} value - The value to convert
273
+ * @param {string} baseUri - Base URI for resolving relative URIs
274
+ * @param {object} context - JSON-LD context
275
+ * @param {boolean} isIdType - Whether the property context specifies @type: "@id"
268
276
  */
269
- function valueToTerm(value, baseUri, context) {
277
+ function valueToTerm(value, baseUri, context, isIdType = false) {
270
278
  if (value === null || value === undefined) {
271
279
  return null;
272
280
  }
273
281
 
274
282
  // Plain values
275
283
  if (typeof value === 'string') {
284
+ // If context says this should be a URI, treat it as a named node
285
+ if (isIdType) {
286
+ const uri = resolveUri(value, baseUri);
287
+ return namedNode(uri);
288
+ }
276
289
  return literal(value);
277
290
  }
278
291
  if (typeof value === 'number') {
package/src/wac/parser.js CHANGED
@@ -190,9 +190,11 @@ export function generateOwnerAcl(resourceUrl, ownerWebId, isContainer = false) {
190
190
  ];
191
191
 
192
192
  // Add default rules for containers
193
+ // Only owner gets default - children don't inherit public read
193
194
  if (isContainer) {
194
195
  graph[0]['acl:default'] = { '@id': resourceUrl };
195
- graph[1]['acl:default'] = { '@id': resourceUrl };
196
+ // Note: intentionally not adding default to #public
197
+ // so child resources require authentication by default
196
198
  }
197
199
 
198
200
  return {
@@ -276,6 +278,46 @@ export function generateInboxAcl(resourceUrl, ownerWebId) {
276
278
  };
277
279
  }
278
280
 
281
+ /**
282
+ * Generate a public folder ACL (owner full control, public read with inheritance)
283
+ * Used for /public/ folders where content should be publicly readable
284
+ * @param {string} resourceUrl - URL of the folder
285
+ * @param {string} ownerWebId - WebID of the owner
286
+ * @returns {object} JSON-LD ACL document
287
+ */
288
+ export function generatePublicFolderAcl(resourceUrl, ownerWebId) {
289
+ return {
290
+ '@context': {
291
+ 'acl': ACL,
292
+ 'foaf': FOAF
293
+ },
294
+ '@graph': [
295
+ {
296
+ '@id': '#owner',
297
+ '@type': 'acl:Authorization',
298
+ 'acl:agent': { '@id': ownerWebId },
299
+ 'acl:accessTo': { '@id': resourceUrl },
300
+ 'acl:default': { '@id': resourceUrl },
301
+ 'acl:mode': [
302
+ { '@id': 'acl:Read' },
303
+ { '@id': 'acl:Write' },
304
+ { '@id': 'acl:Control' }
305
+ ]
306
+ },
307
+ {
308
+ '@id': '#public',
309
+ '@type': 'acl:Authorization',
310
+ 'acl:agentClass': { '@id': 'foaf:Agent' },
311
+ 'acl:accessTo': { '@id': resourceUrl },
312
+ 'acl:default': { '@id': resourceUrl },
313
+ 'acl:mode': [
314
+ { '@id': 'acl:Read' }
315
+ ]
316
+ }
317
+ ]
318
+ };
319
+ }
320
+
279
321
  /**
280
322
  * Serialize ACL to JSON string
281
323
  */