javascript-solid-server 0.0.5 → 0.0.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,11 +11,17 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "fastify": "^4.25.2",
14
- "fs-extra": "^11.2.0"
14
+ "fs-extra": "^11.2.0",
15
+ "jose": "^6.1.3"
15
16
  },
16
17
  "engines": {
17
18
  "node": ">=18.0.0"
18
19
  },
19
- "keywords": ["solid", "ldp", "linked-data", "decentralized"],
20
+ "keywords": [
21
+ "solid",
22
+ "ldp",
23
+ "linked-data",
24
+ "decentralized"
25
+ ],
20
26
  "license": "MIT"
21
27
  }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Authorization middleware
3
3
  * Combines authentication (token verification) with WAC checking
4
+ * Supports both simple Bearer tokens and Solid-OIDC DPoP tokens
4
5
  */
5
6
 
6
- import { getWebIdFromRequest } from './token.js';
7
+ import { getWebIdFromRequestAsync } from './token.js';
7
8
  import { checkAccess, getRequiredMode } from '../wac/checker.js';
8
9
  import * as storage from '../storage/filesystem.js';
9
10
 
@@ -11,7 +12,7 @@ import * as storage from '../storage/filesystem.js';
11
12
  * Check if request is authorized
12
13
  * @param {object} request - Fastify request
13
14
  * @param {object} reply - Fastify reply
14
- * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string}>}
15
+ * @returns {Promise<{authorized: boolean, webId: string|null, wacAllow: string, authError: string|null}>}
15
16
  */
16
17
  export async function authorize(request, reply) {
17
18
  const urlPath = request.url.split('?')[0];
@@ -20,11 +21,11 @@ export async function authorize(request, reply) {
20
21
  // Skip auth for .acl files (they need special handling)
21
22
  // and for OPTIONS (CORS preflight)
22
23
  if (urlPath.endsWith('.acl') || method === 'OPTIONS') {
23
- return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"' };
24
+ return { authorized: true, webId: null, wacAllow: 'user="read write append control", public="read write append"', authError: null };
24
25
  }
25
26
 
26
- // Get WebID from token (null if not authenticated)
27
- const webId = getWebIdFromRequest(request);
27
+ // Get WebID from token (supports both simple and Solid-OIDC tokens)
28
+ const { webId, error: authError } = await getWebIdFromRequestAsync(request);
28
29
 
29
30
  // Get resource info
30
31
  const stats = await storage.stat(urlPath);
@@ -59,7 +60,7 @@ export async function authorize(request, reply) {
59
60
  requiredMode
60
61
  });
61
62
 
62
- return { authorized: allowed, webId, wacAllow };
63
+ return { authorized: allowed, webId, wacAllow, authError };
63
64
  }
64
65
 
65
66
  /**
@@ -77,15 +78,16 @@ function getParentPath(path) {
77
78
  * @param {object} reply - Fastify reply
78
79
  * @param {boolean} isAuthenticated - Whether user is authenticated
79
80
  * @param {string} wacAllow - WAC-Allow header value
81
+ * @param {string|null} authError - Authentication error message (for DPoP failures)
80
82
  */
81
- export function handleUnauthorized(reply, isAuthenticated, wacAllow) {
83
+ export function handleUnauthorized(reply, isAuthenticated, wacAllow, authError = null) {
82
84
  reply.header('WAC-Allow', wacAllow);
83
85
 
84
86
  if (!isAuthenticated) {
85
87
  // Not authenticated - return 401
86
88
  return reply.code(401).send({
87
89
  error: 'Unauthorized',
88
- message: 'Authentication required'
90
+ message: authError || 'Authentication required'
89
91
  });
90
92
  } else {
91
93
  // Authenticated but not authorized - return 403
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Solid-OIDC Resource Server
3
+ * Verifies DPoP-bound access tokens from external Identity Providers
4
+ *
5
+ * Flow:
6
+ * 1. User authenticates at external IdP (e.g., solidcommunity.net)
7
+ * 2. User gets DPoP-bound access token
8
+ * 3. User sends request with Authorization: DPoP <token> and DPoP: <proof>
9
+ * 4. We verify the token and DPoP proof
10
+ * 5. Extract WebID from token
11
+ */
12
+
13
+ import * as jose from 'jose';
14
+
15
+ // Cache for OIDC configurations and JWKS
16
+ const oidcConfigCache = new Map();
17
+ const jwksCache = new Map();
18
+
19
+ // Cache TTL (15 minutes)
20
+ const CACHE_TTL = 15 * 60 * 1000;
21
+
22
+ // DPoP proof max age (5 minutes)
23
+ const DPOP_MAX_AGE = 5 * 60;
24
+
25
+ /**
26
+ * Verify a Solid-OIDC request and extract WebID
27
+ * @param {object} request - Fastify request object
28
+ * @returns {Promise<{webId: string|null, error: string|null}>}
29
+ */
30
+ export async function verifySolidOidc(request) {
31
+ const authHeader = request.headers.authorization;
32
+ const dpopHeader = request.headers.dpop;
33
+
34
+ // Check for DPoP authorization scheme
35
+ if (!authHeader || !authHeader.startsWith('DPoP ')) {
36
+ return { webId: null, error: null }; // Not a Solid-OIDC request
37
+ }
38
+
39
+ if (!dpopHeader) {
40
+ return { webId: null, error: 'Missing DPoP proof header' };
41
+ }
42
+
43
+ const accessToken = authHeader.slice(5); // Remove 'DPoP ' prefix
44
+
45
+ try {
46
+ // Step 1: Decode access token (without verification) to get issuer
47
+ const tokenPayload = jose.decodeJwt(accessToken);
48
+ const issuer = tokenPayload.iss;
49
+
50
+ if (!issuer) {
51
+ return { webId: null, error: 'Access token missing issuer' };
52
+ }
53
+
54
+ // Step 2: Verify DPoP proof
55
+ const dpopResult = await verifyDpopProof(dpopHeader, request, accessToken);
56
+ if (dpopResult.error) {
57
+ return { webId: null, error: dpopResult.error };
58
+ }
59
+
60
+ // Step 3: Fetch JWKS and verify access token
61
+ const jwks = await getJwks(issuer);
62
+ const { payload } = await jose.jwtVerify(accessToken, jwks, {
63
+ issuer,
64
+ clockTolerance: 30 // 30 seconds clock skew tolerance
65
+ });
66
+
67
+ // Step 4: Verify DPoP binding (cnf.jkt must match DPoP key thumbprint)
68
+ if (payload.cnf?.jkt) {
69
+ if (payload.cnf.jkt !== dpopResult.thumbprint) {
70
+ return { webId: null, error: 'DPoP key does not match token binding' };
71
+ }
72
+ }
73
+
74
+ // Step 5: Extract WebID
75
+ const webId = payload.webid || payload.sub;
76
+ if (!webId) {
77
+ return { webId: null, error: 'Token missing WebID claim' };
78
+ }
79
+
80
+ // Validate WebID is a valid URL
81
+ try {
82
+ new URL(webId);
83
+ } catch {
84
+ return { webId: null, error: 'Invalid WebID URL' };
85
+ }
86
+
87
+ return { webId, error: null };
88
+
89
+ } catch (err) {
90
+ // Handle specific JWT errors
91
+ if (err.code === 'ERR_JWT_EXPIRED') {
92
+ return { webId: null, error: 'Access token expired' };
93
+ }
94
+ if (err.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
95
+ return { webId: null, error: 'Invalid token signature' };
96
+ }
97
+ if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') {
98
+ return { webId: null, error: 'No matching key found in JWKS' };
99
+ }
100
+
101
+ console.error('Solid-OIDC verification error:', err.message);
102
+ return { webId: null, error: 'Token verification failed' };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Verify DPoP proof JWT
108
+ * @param {string} dpopProof - The DPoP proof JWT
109
+ * @param {object} request - Fastify request
110
+ * @param {string} accessToken - The access token (for ath claim verification)
111
+ * @returns {Promise<{thumbprint: string|null, error: string|null}>}
112
+ */
113
+ async function verifyDpopProof(dpopProof, request, accessToken) {
114
+ try {
115
+ // Decode header to get the public key
116
+ const protectedHeader = jose.decodeProtectedHeader(dpopProof);
117
+
118
+ if (protectedHeader.typ !== 'dpop+jwt') {
119
+ return { thumbprint: null, error: 'Invalid DPoP proof type' };
120
+ }
121
+
122
+ if (!protectedHeader.jwk) {
123
+ return { thumbprint: null, error: 'DPoP proof missing jwk header' };
124
+ }
125
+
126
+ // Import the public key from the JWK in the header
127
+ const publicKey = await jose.importJWK(protectedHeader.jwk, protectedHeader.alg);
128
+
129
+ // Verify the DPoP proof signature
130
+ const { payload } = await jose.jwtVerify(dpopProof, publicKey, {
131
+ clockTolerance: 30
132
+ });
133
+
134
+ // Verify required claims
135
+ // htm: HTTP method
136
+ const expectedMethod = request.method.toUpperCase();
137
+ if (payload.htm !== expectedMethod) {
138
+ return { thumbprint: null, error: `DPoP htm mismatch: expected ${expectedMethod}` };
139
+ }
140
+
141
+ // htu: HTTP URI (without query string and fragment)
142
+ const requestUrl = `${request.protocol}://${request.hostname}${request.url.split('?')[0]}`;
143
+ // Normalize both URLs for comparison
144
+ const payloadHtu = payload.htu?.replace(/\/$/, '');
145
+ const expectedHtu = requestUrl.replace(/\/$/, '');
146
+
147
+ if (payloadHtu !== expectedHtu) {
148
+ // Also try without port for localhost
149
+ const altHtu = requestUrl.replace(/:(\d+)/, '').replace(/\/$/, '');
150
+ if (payloadHtu !== altHtu) {
151
+ return { thumbprint: null, error: `DPoP htu mismatch: expected ${expectedHtu}` };
152
+ }
153
+ }
154
+
155
+ // iat: Issued at (must be recent)
156
+ const now = Math.floor(Date.now() / 1000);
157
+ if (!payload.iat || Math.abs(now - payload.iat) > DPOP_MAX_AGE) {
158
+ return { thumbprint: null, error: 'DPoP proof expired or invalid iat' };
159
+ }
160
+
161
+ // jti: Unique identifier (we should track these to prevent replay, but skip for now)
162
+ if (!payload.jti) {
163
+ return { thumbprint: null, error: 'DPoP proof missing jti' };
164
+ }
165
+
166
+ // ath: Access token hash (optional but recommended)
167
+ if (payload.ath) {
168
+ const expectedAth = await calculateAth(accessToken);
169
+ if (payload.ath !== expectedAth) {
170
+ return { thumbprint: null, error: 'DPoP ath mismatch' };
171
+ }
172
+ }
173
+
174
+ // Calculate JWK thumbprint for binding verification
175
+ const thumbprint = await jose.calculateJwkThumbprint(protectedHeader.jwk, 'sha256');
176
+
177
+ return { thumbprint, error: null };
178
+
179
+ } catch (err) {
180
+ console.error('DPoP verification error:', err.message);
181
+ return { thumbprint: null, error: 'Invalid DPoP proof' };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Calculate access token hash (ath) for DPoP binding
187
+ */
188
+ async function calculateAth(accessToken) {
189
+ const encoder = new TextEncoder();
190
+ const data = encoder.encode(accessToken);
191
+ const hash = await crypto.subtle.digest('SHA-256', data);
192
+ return jose.base64url.encode(new Uint8Array(hash));
193
+ }
194
+
195
+ /**
196
+ * Fetch and cache OIDC configuration
197
+ */
198
+ async function getOidcConfig(issuer) {
199
+ const cached = oidcConfigCache.get(issuer);
200
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
201
+ return cached.config;
202
+ }
203
+
204
+ const configUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
205
+
206
+ try {
207
+ const response = await fetch(configUrl);
208
+ if (!response.ok) {
209
+ throw new Error(`Failed to fetch OIDC config: ${response.status}`);
210
+ }
211
+
212
+ const config = await response.json();
213
+ oidcConfigCache.set(issuer, { config, timestamp: Date.now() });
214
+ return config;
215
+
216
+ } catch (err) {
217
+ console.error(`Failed to fetch OIDC config from ${issuer}:`, err.message);
218
+ throw err;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get JWKS for an issuer (with caching)
224
+ */
225
+ async function getJwks(issuer) {
226
+ const cached = jwksCache.get(issuer);
227
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
228
+ return cached.jwks;
229
+ }
230
+
231
+ // Get OIDC config to find JWKS URI
232
+ const config = await getOidcConfig(issuer);
233
+ const jwksUri = config.jwks_uri;
234
+
235
+ if (!jwksUri) {
236
+ throw new Error('OIDC config missing jwks_uri');
237
+ }
238
+
239
+ // Create a remote JWKS set
240
+ const jwks = jose.createRemoteJWKSet(new URL(jwksUri));
241
+
242
+ jwksCache.set(issuer, { jwks, timestamp: Date.now() });
243
+ return jwks;
244
+ }
245
+
246
+ /**
247
+ * Clear caches (useful for testing)
248
+ */
249
+ export function clearCaches() {
250
+ oidcConfigCache.clear();
251
+ jwksCache.clear();
252
+ }
253
+
254
+ /**
255
+ * Check if request has Solid-OIDC authorization
256
+ */
257
+ export function hasSolidOidcAuth(request) {
258
+ const authHeader = request.headers.authorization;
259
+ return authHeader && authHeader.startsWith('DPoP ');
260
+ }
package/src/auth/token.js CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Simple token-based authentication
2
+ * Token-based authentication
3
3
  *
4
- * For now, we use a simple JWT-like approach:
5
- * - Token format: base64(JSON({webId, iat, exp}))
6
- * - In production, this would be replaced with proper Solid-OIDC DPoP tokens
4
+ * Supports two modes:
5
+ * 1. Simple tokens (for local/dev use): base64(JSON({webId, iat, exp})) + HMAC signature
6
+ * 2. Solid-OIDC DPoP tokens (for federation): verified via external IdP JWKS
7
7
  */
8
8
 
9
9
  import crypto from 'crypto';
10
+ import { verifySolidOidc, hasSolidOidcAuth } from './solid-oidc.js';
10
11
 
11
12
  // Secret for signing tokens (in production, use env var)
12
13
  const SECRET = process.env.TOKEN_SECRET || 'dev-secret-change-in-production';
@@ -95,12 +96,18 @@ export function extractToken(authHeader) {
95
96
  }
96
97
 
97
98
  /**
98
- * Extract WebID from request
99
+ * Extract WebID from request (sync version for simple tokens only)
99
100
  * @param {object} request - Fastify request object
100
101
  * @returns {string | null} WebID or null if not authenticated
101
102
  */
102
103
  export function getWebIdFromRequest(request) {
103
104
  const authHeader = request.headers.authorization;
105
+
106
+ // Skip DPoP tokens - use async version for those
107
+ if (authHeader && authHeader.startsWith('DPoP ')) {
108
+ return null;
109
+ }
110
+
104
111
  const token = extractToken(authHeader);
105
112
 
106
113
  if (!token) {
@@ -110,3 +117,34 @@ export function getWebIdFromRequest(request) {
110
117
  const payload = verifyToken(token);
111
118
  return payload?.webId || null;
112
119
  }
120
+
121
+ /**
122
+ * Extract WebID from request (async version supporting Solid-OIDC)
123
+ * @param {object} request - Fastify request object
124
+ * @returns {Promise<{webId: string|null, error: string|null}>}
125
+ */
126
+ export async function getWebIdFromRequestAsync(request) {
127
+ const authHeader = request.headers.authorization;
128
+
129
+ if (!authHeader) {
130
+ return { webId: null, error: null };
131
+ }
132
+
133
+ // Try Solid-OIDC first (DPoP tokens)
134
+ if (hasSolidOidcAuth(request)) {
135
+ return verifySolidOidc(request);
136
+ }
137
+
138
+ // Fall back to simple Bearer tokens
139
+ const token = extractToken(authHeader);
140
+ if (!token) {
141
+ return { webId: null, error: null };
142
+ }
143
+
144
+ const payload = verifyToken(token);
145
+ if (payload?.webId) {
146
+ return { webId: payload.webId, error: null };
147
+ }
148
+
149
+ return { webId: null, error: 'Invalid token' };
150
+ }
package/src/server.js CHANGED
@@ -43,14 +43,14 @@ export function createServer(options = {}) {
43
43
  return;
44
44
  }
45
45
 
46
- const { authorized, webId, wacAllow } = await authorize(request, reply);
46
+ const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
47
47
 
48
48
  // Store webId and wacAllow on request for handlers to use
49
49
  request.webId = webId;
50
50
  request.wacAllow = wacAllow;
51
51
 
52
52
  if (!authorized) {
53
- return handleUnauthorized(reply, webId !== null, wacAllow);
53
+ return handleUnauthorized(reply, webId !== null, wacAllow, authError);
54
54
  }
55
55
  });
56
56
 
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Solid-OIDC tests
3
+ * Tests for DPoP token verification
4
+ */
5
+
6
+ import { describe, it, before, after } from 'node:test';
7
+ import assert from 'node:assert';
8
+ import * as jose from 'jose';
9
+ import {
10
+ startTestServer,
11
+ stopTestServer,
12
+ request,
13
+ createTestPod,
14
+ getBaseUrl,
15
+ assertStatus
16
+ } from './helpers.js';
17
+
18
+ describe('Solid-OIDC', () => {
19
+ let keyPair;
20
+ let publicJwk;
21
+
22
+ before(async () => {
23
+ await startTestServer();
24
+ await createTestPod('oidctest');
25
+
26
+ // Generate a key pair for testing
27
+ keyPair = await jose.generateKeyPair('ES256');
28
+ publicJwk = await jose.exportJWK(keyPair.publicKey);
29
+ publicJwk.alg = 'ES256';
30
+ });
31
+
32
+ after(async () => {
33
+ await stopTestServer();
34
+ });
35
+
36
+ describe('DPoP Header Parsing', () => {
37
+ // Use private folder - requires authentication
38
+ const privatePath = '/oidctest/private/';
39
+
40
+ it('should reject requests with DPoP auth but no DPoP proof', async () => {
41
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
42
+ headers: {
43
+ 'Authorization': 'DPoP some-token'
44
+ }
45
+ });
46
+
47
+ assertStatus(res, 401);
48
+ const body = await res.json();
49
+ assert.ok(body.message.includes('DPoP proof'), 'Should mention DPoP proof');
50
+ });
51
+
52
+ it('should reject invalid DPoP proof JWT', async () => {
53
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
54
+ headers: {
55
+ 'Authorization': 'DPoP some-token',
56
+ 'DPoP': 'not-a-valid-jwt'
57
+ }
58
+ });
59
+
60
+ assertStatus(res, 401);
61
+ });
62
+
63
+ it('should reject DPoP proof with wrong type', async () => {
64
+ // Create a JWT that's not a DPoP proof (wrong typ)
65
+ const wrongTypeJwt = await new jose.SignJWT({
66
+ htm: 'GET',
67
+ htu: `${getBaseUrl()}${privatePath}`,
68
+ iat: Math.floor(Date.now() / 1000),
69
+ jti: crypto.randomUUID()
70
+ })
71
+ .setProtectedHeader({ alg: 'ES256', typ: 'JWT', jwk: publicJwk })
72
+ .sign(keyPair.privateKey);
73
+
74
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
75
+ headers: {
76
+ 'Authorization': 'DPoP some-token',
77
+ 'DPoP': wrongTypeJwt
78
+ }
79
+ });
80
+
81
+ assertStatus(res, 401);
82
+ });
83
+
84
+ it('should reject DPoP proof with wrong HTTP method', async () => {
85
+ const dpopProof = await createDpopProof('POST', `${getBaseUrl()}${privatePath}`);
86
+
87
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
88
+ method: 'GET',
89
+ headers: {
90
+ 'Authorization': 'DPoP some-token',
91
+ 'DPoP': dpopProof
92
+ }
93
+ });
94
+
95
+ assertStatus(res, 401);
96
+ });
97
+
98
+ it('should reject DPoP proof with wrong URL', async () => {
99
+ const dpopProof = await createDpopProof('GET', 'https://other-server.example/');
100
+
101
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
102
+ headers: {
103
+ 'Authorization': 'DPoP some-token',
104
+ 'DPoP': dpopProof
105
+ }
106
+ });
107
+
108
+ assertStatus(res, 401);
109
+ });
110
+
111
+ it('should reject expired DPoP proof', async () => {
112
+ // Create a DPoP proof with old iat
113
+ const dpopProof = await new jose.SignJWT({
114
+ htm: 'GET',
115
+ htu: `${getBaseUrl()}${privatePath}`,
116
+ iat: Math.floor(Date.now() / 1000) - 600, // 10 minutes ago
117
+ jti: crypto.randomUUID()
118
+ })
119
+ .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
120
+ .sign(keyPair.privateKey);
121
+
122
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
123
+ headers: {
124
+ 'Authorization': 'DPoP some-token',
125
+ 'DPoP': dpopProof
126
+ }
127
+ });
128
+
129
+ assertStatus(res, 401);
130
+ });
131
+
132
+ it('should reject DPoP proof missing jti', async () => {
133
+ const dpopProof = await new jose.SignJWT({
134
+ htm: 'GET',
135
+ htu: `${getBaseUrl()}${privatePath}`,
136
+ iat: Math.floor(Date.now() / 1000)
137
+ // missing jti
138
+ })
139
+ .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
140
+ .sign(keyPair.privateKey);
141
+
142
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
143
+ headers: {
144
+ 'Authorization': 'DPoP some-token',
145
+ 'DPoP': dpopProof
146
+ }
147
+ });
148
+
149
+ assertStatus(res, 401);
150
+ });
151
+ });
152
+
153
+ describe('Access Token Verification', () => {
154
+ const privatePath = '/oidctest/private/';
155
+
156
+ it('should reject token with invalid issuer (unreachable)', async () => {
157
+ // Create a valid DPoP proof
158
+ const dpopProof = await createDpopProof('GET', `${getBaseUrl()}${privatePath}`);
159
+
160
+ // Create a fake access token with unreachable issuer
161
+ const fakeToken = await new jose.SignJWT({
162
+ webid: 'https://example.com/user#me',
163
+ sub: 'https://example.com/user#me',
164
+ iss: 'https://nonexistent-idp.example.com',
165
+ aud: 'solid',
166
+ iat: Math.floor(Date.now() / 1000),
167
+ exp: Math.floor(Date.now() / 1000) + 3600
168
+ })
169
+ .setProtectedHeader({ alg: 'ES256' })
170
+ .sign(keyPair.privateKey);
171
+
172
+ const res = await fetch(`${getBaseUrl()}${privatePath}`, {
173
+ headers: {
174
+ 'Authorization': `DPoP ${fakeToken}`,
175
+ 'DPoP': dpopProof
176
+ }
177
+ });
178
+
179
+ assertStatus(res, 401);
180
+ });
181
+ });
182
+
183
+ describe('Bearer Token Fallback', () => {
184
+ it('should still accept simple Bearer tokens', async () => {
185
+ // This should work with our simple token system
186
+ const res = await request('/oidctest/public/', { auth: 'oidctest' });
187
+ assertStatus(res, 200);
188
+ });
189
+
190
+ it('should still accept simple Bearer tokens for writes', async () => {
191
+ const res = await request('/oidctest/public/solid-oidc-test.txt', {
192
+ method: 'PUT',
193
+ body: 'test content',
194
+ auth: 'oidctest'
195
+ });
196
+ assertStatus(res, 201);
197
+ });
198
+ });
199
+
200
+ // Helper to create DPoP proofs
201
+ async function createDpopProof(method, uri) {
202
+ return new jose.SignJWT({
203
+ htm: method,
204
+ htu: uri,
205
+ iat: Math.floor(Date.now() / 1000),
206
+ jti: crypto.randomUUID()
207
+ })
208
+ .setProtectedHeader({ alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk })
209
+ .sign(keyPair.privateKey);
210
+ }
211
+ });