Skip to content

Commit 00fc80b

Browse files
feat: HTTP 402 payment-gated resources via ACL conditions
Add support for acl:condition in WAC authorization evaluation. When a PaymentCondition is present, the server returns 402 Payment Required with payment details from the ACL. Design: fail-closed — unsupported condition types cause the authorization to be skipped entirely, denying access. Closes JavaScriptSolidServer#239
1 parent 8cf97cc commit 00fc80b

5 files changed

Lines changed: 180 additions & 14 deletions

File tree

src/auth/middleware.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,15 @@ export async function authorize(request, reply, options = {}) {
102102
}
103103

104104
// Check WAC permissions
105-
const { allowed, wacAllow } = await checkAccess({
105+
const { allowed, wacAllow, paymentRequired } = await checkAccess({
106106
resourceUrl: checkUrl,
107107
resourcePath: checkPath,
108108
isContainer: checkIsContainer,
109109
agentWebId: webId,
110110
requiredMode
111111
});
112112

113-
return { authorized: allowed, webId, wacAllow, authError };
113+
return { authorized: allowed, webId, wacAllow, authError, paymentRequired };
114114
}
115115

116116
/**

src/server.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -389,10 +389,14 @@ export function createServer(options = {}) {
389389
const requiredMode = needsWrite ? AccessMode.WRITE : AccessMode.READ;
390390

391391
// Run WAC authorization with the correct mode for git operations
392-
const { authorized, webId, wacAllow, authError } = await authorize(request, reply, { requiredMode });
392+
const { authorized, webId, wacAllow, authError, paymentRequired } = await authorize(request, reply, { requiredMode });
393393
request.webId = webId;
394394
request.wacAllow = wacAllow;
395395

396+
if (paymentRequired) {
397+
return reply.code(402).send({ type: 'PaymentRequired', ...paymentRequired });
398+
}
399+
396400
if (!authorized) {
397401
const message = needsWrite ? 'Write access required for push' : 'Read access required for clone';
398402
reply.header('WAC-Allow', wacAllow);
@@ -444,7 +448,7 @@ export function createServer(options = {}) {
444448
return;
445449
}
446450

447-
const { authorized, webId, wacAllow, authError } = await authorize(request, reply);
451+
const { authorized, webId, wacAllow, authError, paymentRequired } = await authorize(request, reply);
448452

449453
// Store webId and wacAllow on request for handlers to use
450454
request.webId = webId;
@@ -453,6 +457,14 @@ export function createServer(options = {}) {
453457
// Set WAC-Allow header for all responses (handlers may override)
454458
reply.header('WAC-Allow', wacAllow);
455459

460+
// Handle payment-gated resources
461+
if (paymentRequired) {
462+
return reply.code(402).send({
463+
type: 'PaymentRequired',
464+
...paymentRequired
465+
});
466+
}
467+
456468
if (!authorized) {
457469
return handleUnauthorized(request, reply, webId !== null, wacAllow, authError);
458470
}

src/wac/checker.js

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export async function checkAccess({
3737

3838
// Check authorizations
3939
// Note: For default ACLs, we check if the ACL's default rules apply to the actual resource URL
40-
const allowed = checkAuthorizations(
40+
const result = checkAuthorizations(
4141
authorizations,
4242
resourceUrl, // Use actual resource URL, not the ACL container URL
4343
agentWebId,
@@ -48,7 +48,7 @@ export async function checkAccess({
4848
// Calculate WAC-Allow header
4949
const wacAllow = calculateWacAllow(authorizations, resourceUrl, agentWebId, isDefault);
5050

51-
return { allowed, wacAllow };
51+
return { allowed: result.allowed, wacAllow, paymentRequired: result.paymentRequired || null };
5252
}
5353

5454
/**
@@ -125,6 +125,9 @@ function getParentPath(path) {
125125
/**
126126
* Check if any authorization grants the required mode
127127
*/
128+
// Supported condition types
129+
const SUPPORTED_CONDITIONS = ['PaymentCondition', 'https://webacl.org/ns#PaymentCondition'];
130+
128131
function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode, isDefault) {
129132
for (const auth of authorizations) {
130133
// For default ACLs, check if auth has default rules and matches target
@@ -144,17 +147,29 @@ function checkAuthorizations(authorizations, targetUrl, agentWebId, requiredMode
144147
if (!agentAuthorized) continue;
145148

146149
// Check if mode is granted
147-
if (auth.modes.includes(requiredMode)) {
148-
return true;
150+
const modeGranted = auth.modes.includes(requiredMode) ||
151+
(requiredMode === AccessMode.APPEND && auth.modes.includes(AccessMode.WRITE));
152+
if (!modeGranted) continue;
153+
154+
// Check conditions (fail-closed)
155+
if (auth.conditions && auth.conditions.length > 0) {
156+
// Fail-closed: skip this auth if any condition type is unsupported
157+
const unsupported = auth.conditions.find(c => !SUPPORTED_CONDITIONS.includes(c.type));
158+
if (unsupported) continue;
159+
160+
// Check payment condition
161+
const paymentCondition = auth.conditions.find(c =>
162+
c.type === 'PaymentCondition' || c.type === 'https://webacl.org/ns#PaymentCondition'
163+
);
164+
if (paymentCondition) {
165+
return { allowed: false, paymentRequired: paymentCondition };
166+
}
149167
}
150168

151-
// Write implies Append
152-
if (requiredMode === AccessMode.APPEND && auth.modes.includes(AccessMode.WRITE)) {
153-
return true;
154-
}
169+
return { allowed: true };
155170
}
156171

157-
return false;
172+
return { allowed: false };
158173
}
159174

160175
/**

src/wac/parser.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ function parseAuthorization(node, aclUrl) {
133133
agents: [], // Specific WebIDs
134134
agentClasses: [], // Agent classes (public, authenticated)
135135
agentGroups: [], // Groups
136-
modes: [] // Access modes
136+
modes: [], // Access modes
137+
conditions: [] // Access conditions (e.g. PaymentCondition)
137138
};
138139

139140
// Parse accessTo - resolve relative URLs
@@ -157,9 +158,26 @@ function parseAuthorization(node, aclUrl) {
157158
// Parse modes
158159
auth.modes = parseUriArray(node['acl:mode'] || node['mode']).map(normalizeMode);
159160

161+
// Parse conditions
162+
auth.conditions = parseConditions(node['acl:condition'] || node['condition']);
163+
160164
return auth;
161165
}
162166

167+
/**
168+
* Parse conditions from an authorization node
169+
*/
170+
function parseConditions(value) {
171+
if (!value) return [];
172+
const values = Array.isArray(value) ? value : [value];
173+
return values.map(v => {
174+
if (typeof v !== 'object' || v === null) return null;
175+
const type = v['@type'] || v.type;
176+
if (!type) return null;
177+
return { ...v, type };
178+
}).filter(Boolean);
179+
}
180+
163181
/**
164182
* Parse a value that could be a URI, @id object, or array of either
165183
*/

test/wac.test.js

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,3 +335,124 @@ describe('WAC Integration', () => {
335335
});
336336
});
337337
});
338+
339+
describe('WAC Conditions', () => {
340+
describe('parseAcl with conditions', () => {
341+
it('should parse a PaymentCondition', async () => {
342+
const acl = {
343+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
344+
'@graph': [{
345+
'@id': '#paid',
346+
'@type': 'acl:Authorization',
347+
'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' },
348+
'acl:accessTo': { '@id': 'https://alice.example/premium/article.jsonld' },
349+
'acl:mode': [{ '@id': 'acl:Read' }],
350+
'acl:condition': {
351+
'@type': 'PaymentCondition',
352+
'amount': '1000',
353+
'currency': 'sats'
354+
}
355+
}]
356+
};
357+
358+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/premium/.acl');
359+
360+
assert.strictEqual(auths.length, 1);
361+
assert.strictEqual(auths[0].conditions.length, 1);
362+
assert.strictEqual(auths[0].conditions[0].type, 'PaymentCondition');
363+
assert.strictEqual(auths[0].conditions[0].amount, '1000');
364+
assert.strictEqual(auths[0].conditions[0].currency, 'sats');
365+
});
366+
367+
it('should parse multiple conditions', async () => {
368+
const acl = {
369+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
370+
'@graph': [{
371+
'@id': '#restricted',
372+
'@type': 'acl:Authorization',
373+
'acl:agent': { '@id': 'https://bob.example/#me' },
374+
'acl:accessTo': { '@id': 'https://alice.example/resource' },
375+
'acl:mode': [{ '@id': 'acl:Read' }],
376+
'acl:condition': [
377+
{ '@type': 'PaymentCondition', 'amount': '500', 'currency': 'sats' },
378+
{ '@type': 'ClientCondition', 'client': 'https://trusted.app/pane.js' }
379+
]
380+
}]
381+
};
382+
383+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
384+
385+
assert.strictEqual(auths[0].conditions.length, 2);
386+
assert.strictEqual(auths[0].conditions[0].type, 'PaymentCondition');
387+
assert.strictEqual(auths[0].conditions[1].type, 'ClientCondition');
388+
});
389+
390+
it('should parse authorization without conditions', async () => {
391+
const acl = {
392+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
393+
'@graph': [{
394+
'@id': '#public',
395+
'@type': 'acl:Authorization',
396+
'acl:agentClass': { '@id': 'http://xmlns.com/foaf/0.1/Agent' },
397+
'acl:accessTo': { '@id': 'https://alice.example/public/' },
398+
'acl:mode': [{ '@id': 'acl:Read' }]
399+
}]
400+
};
401+
402+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
403+
404+
assert.strictEqual(auths[0].conditions.length, 0);
405+
});
406+
});
407+
408+
describe('fail-closed conditions', () => {
409+
it('should parse unsupported condition types', async () => {
410+
const acl = {
411+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
412+
'@graph': [{
413+
'@id': '#restricted',
414+
'@type': 'acl:Authorization',
415+
'acl:agent': { '@id': 'https://bob.example/#me' },
416+
'acl:accessTo': { '@id': 'https://alice.example/resource' },
417+
'acl:mode': [{ '@id': 'acl:Read' }],
418+
'acl:condition': {
419+
'@type': 'UnknownFutureCondition',
420+
'foo': 'bar'
421+
}
422+
}]
423+
};
424+
425+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/.acl');
426+
427+
assert.strictEqual(auths[0].conditions.length, 1);
428+
assert.strictEqual(auths[0].conditions[0].type, 'UnknownFutureCondition');
429+
});
430+
431+
it('should parse PaymentCondition with all fields', async () => {
432+
const acl = {
433+
'@context': { 'acl': 'http://www.w3.org/ns/auth/acl#' },
434+
'@graph': [{
435+
'@id': '#paid',
436+
'@type': 'acl:Authorization',
437+
'acl:agentClass': { '@id': 'acl:AuthenticatedAgent' },
438+
'acl:accessTo': { '@id': 'https://alice.example/premium/article.jsonld' },
439+
'acl:mode': [{ '@id': 'acl:Read' }],
440+
'acl:condition': {
441+
'@type': 'PaymentCondition',
442+
'amount': '1000',
443+
'currency': 'sats',
444+
'protocol': 'lightning'
445+
}
446+
}]
447+
};
448+
449+
const auths = await parseAcl(JSON.stringify(acl), 'https://alice.example/premium/.acl');
450+
const condition = auths[0].conditions[0];
451+
452+
assert.strictEqual(condition.type, 'PaymentCondition');
453+
assert.strictEqual(condition.amount, '1000');
454+
assert.strictEqual(condition.currency, 'sats');
455+
assert.strictEqual(condition.protocol, 'lightning');
456+
});
457+
});
458+
});

0 commit comments

Comments
 (0)