@ixo/ucan 1.0.0 → 1.0.1
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/.turbo/turbo-build.log +1 -1
- package/README.md +171 -117
- package/docs/FLOW.md +275 -0
- package/docs/examples/CLIENT.md +407 -0
- package/docs/examples/SERVER.md +414 -0
- package/package.json +3 -3
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# Server Example
|
|
2
|
+
|
|
3
|
+
Complete Express.js server using `@ixo/ucan` for authorization.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install express @ixo/ucan
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Full Example
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import express, { Request, Response } from 'express';
|
|
15
|
+
import {
|
|
16
|
+
createUCANValidator,
|
|
17
|
+
createIxoDIDResolver,
|
|
18
|
+
defineCapability,
|
|
19
|
+
Schema,
|
|
20
|
+
generateKeypair,
|
|
21
|
+
createDelegation,
|
|
22
|
+
serializeDelegation,
|
|
23
|
+
parseSigner,
|
|
24
|
+
SupportedDID,
|
|
25
|
+
} from '@ixo/ucan';
|
|
26
|
+
|
|
27
|
+
const app = express();
|
|
28
|
+
app.use(express.json());
|
|
29
|
+
|
|
30
|
+
// =============================================================================
|
|
31
|
+
// Configuration
|
|
32
|
+
// =============================================================================
|
|
33
|
+
|
|
34
|
+
// Server identity (use did:key for simplicity, or did:ixo for production)
|
|
35
|
+
const SERVER_DID = 'did:ixo:ixo1abc...'; // Your server's DID
|
|
36
|
+
const ROOT_DID = 'did:ixo:ixo1admin...'; // Admin who can delegate
|
|
37
|
+
const ROOT_PRIVATE_KEY = 'MgCY...'; // Admin's private key
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Define Capabilities
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Capability with caveat validation (limit)
|
|
45
|
+
*
|
|
46
|
+
* The `derives` function enforces attenuation:
|
|
47
|
+
* - A delegation with limit: 100 can create sub-delegations with limit ≤ 100
|
|
48
|
+
* - An invocation can only request up to the delegated limit
|
|
49
|
+
*/
|
|
50
|
+
const EmployeesRead = defineCapability({
|
|
51
|
+
can: 'employees/read',
|
|
52
|
+
protocol: 'myapp:',
|
|
53
|
+
nb: { limit: Schema.integer().optional() },
|
|
54
|
+
derives: (claimed, delegated) => {
|
|
55
|
+
const claimedLimit = claimed.nb?.limit ?? Infinity;
|
|
56
|
+
const delegatedLimit = delegated.nb?.limit ?? Infinity;
|
|
57
|
+
|
|
58
|
+
if (claimedLimit > delegatedLimit) {
|
|
59
|
+
return {
|
|
60
|
+
error: new Error(
|
|
61
|
+
`Cannot request limit=${claimedLimit}, delegation only allows limit=${delegatedLimit}`
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { ok: {} };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Simple capability without caveats
|
|
71
|
+
*/
|
|
72
|
+
const EmployeesWrite = defineCapability({
|
|
73
|
+
can: 'employees/write',
|
|
74
|
+
protocol: 'myapp:',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// Initialize Validator
|
|
79
|
+
// =============================================================================
|
|
80
|
+
|
|
81
|
+
let validator: Awaited<ReturnType<typeof createUCANValidator>>;
|
|
82
|
+
|
|
83
|
+
async function initializeServer() {
|
|
84
|
+
// Create DID resolver for did:ixo
|
|
85
|
+
const didResolver = createIxoDIDResolver({
|
|
86
|
+
indexerUrl: 'https://blocksync.ixo.earth/graphql',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Create validator (async to resolve non-did:key DIDs at startup)
|
|
90
|
+
validator = await createUCANValidator({
|
|
91
|
+
serverDid: SERVER_DID,
|
|
92
|
+
rootIssuers: [ROOT_DID],
|
|
93
|
+
didResolver,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
console.log('Server DID:', SERVER_DID);
|
|
97
|
+
console.log('Root Issuer:', ROOT_DID);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// =============================================================================
|
|
101
|
+
// Helper Functions
|
|
102
|
+
// =============================================================================
|
|
103
|
+
|
|
104
|
+
function buildResourceUri(serverDid: string): `myapp:${string}` {
|
|
105
|
+
return `myapp:${serverDid}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function buildEmployees(limit: number) {
|
|
109
|
+
return Array.from({ length: limit }, (_, i) => ({
|
|
110
|
+
id: i + 1,
|
|
111
|
+
name: `Employee ${i + 1}`,
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// =============================================================================
|
|
116
|
+
// Routes
|
|
117
|
+
// =============================================================================
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Health check
|
|
121
|
+
*/
|
|
122
|
+
app.get('/health', (_req, res) => {
|
|
123
|
+
res.json({ status: 'ok' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Server info and available capabilities
|
|
128
|
+
*/
|
|
129
|
+
app.get('/info', (_req, res) => {
|
|
130
|
+
res.json({
|
|
131
|
+
serverDid: SERVER_DID,
|
|
132
|
+
rootIssuers: [ROOT_DID],
|
|
133
|
+
capabilities: {
|
|
134
|
+
'employees/read': {
|
|
135
|
+
description: 'Read employee data',
|
|
136
|
+
caveats: { limit: 'Maximum number of employees to return' },
|
|
137
|
+
},
|
|
138
|
+
'employees/write': {
|
|
139
|
+
description: 'Write employee data',
|
|
140
|
+
caveats: null,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Protected route - requires valid UCAN invocation
|
|
148
|
+
*
|
|
149
|
+
* Send: POST /protected
|
|
150
|
+
* Body: { "invocation": "<base64 CAR>" }
|
|
151
|
+
*/
|
|
152
|
+
app.post('/protected', async (req: Request, res: Response) => {
|
|
153
|
+
const invocationBase64 = req.body?.invocation;
|
|
154
|
+
|
|
155
|
+
if (!invocationBase64) {
|
|
156
|
+
res.status(400).json({
|
|
157
|
+
error: 'Missing invocation in request body',
|
|
158
|
+
hint: 'Send { "invocation": "<base64 CAR>" }',
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Validate with capability (includes caveat validation!)
|
|
164
|
+
const result = await validator.validate(
|
|
165
|
+
invocationBase64,
|
|
166
|
+
EmployeesRead,
|
|
167
|
+
buildResourceUri(SERVER_DID)
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
res.status(403).json({
|
|
172
|
+
error: 'Unauthorized',
|
|
173
|
+
details: result.error,
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Extract limit from validated capability
|
|
179
|
+
const limit = (result.capability?.nb?.limit as number) ?? 10;
|
|
180
|
+
|
|
181
|
+
res.json({
|
|
182
|
+
message: 'Access granted!',
|
|
183
|
+
invoker: result.invoker,
|
|
184
|
+
capability: result.capability,
|
|
185
|
+
employees: buildEmployees(limit),
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create delegation for a user
|
|
191
|
+
*
|
|
192
|
+
* Send: POST /delegate
|
|
193
|
+
* Body: {
|
|
194
|
+
* "audience": "did:key:z6Mk...",
|
|
195
|
+
* "capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }],
|
|
196
|
+
* "expiration": 1735689600 // Optional, defaults to 24h
|
|
197
|
+
* }
|
|
198
|
+
*/
|
|
199
|
+
app.post('/delegate', async (req: Request, res: Response) => {
|
|
200
|
+
const { audience, capabilities, expiration } = req.body;
|
|
201
|
+
|
|
202
|
+
if (!audience) {
|
|
203
|
+
res.status(400).json({
|
|
204
|
+
error: 'Missing audience DID',
|
|
205
|
+
hint: 'Send { "audience": "did:key:..." }',
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!capabilities || !Array.isArray(capabilities)) {
|
|
211
|
+
res.status(400).json({
|
|
212
|
+
error: 'Missing or invalid capabilities',
|
|
213
|
+
hint: 'Send { "capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }] }',
|
|
214
|
+
});
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const rootSigner = parseSigner(ROOT_PRIVATE_KEY, ROOT_DID as SupportedDID);
|
|
220
|
+
|
|
221
|
+
// Build full capabilities with resource URI
|
|
222
|
+
const fullCapabilities = capabilities.map((cap: any) => ({
|
|
223
|
+
can: cap.can,
|
|
224
|
+
with: buildResourceUri(SERVER_DID),
|
|
225
|
+
nb: cap.nb,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
// Default expiration: 24 hours
|
|
229
|
+
const exp = expiration ?? Math.floor(Date.now() / 1000) + 86400;
|
|
230
|
+
|
|
231
|
+
const delegation = await createDelegation({
|
|
232
|
+
issuer: rootSigner,
|
|
233
|
+
audience,
|
|
234
|
+
capabilities: fullCapabilities as any,
|
|
235
|
+
expiration: exp,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const serialized = await serializeDelegation(delegation);
|
|
239
|
+
|
|
240
|
+
res.json({
|
|
241
|
+
success: true,
|
|
242
|
+
delegation: serialized,
|
|
243
|
+
details: {
|
|
244
|
+
cid: delegation.cid.toString(),
|
|
245
|
+
issuer: delegation.issuer.did(),
|
|
246
|
+
audience: delegation.audience.did(),
|
|
247
|
+
expiration: exp,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
} catch (error: any) {
|
|
251
|
+
res.status(500).json({
|
|
252
|
+
error: 'Failed to create delegation',
|
|
253
|
+
details: error.message,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// =============================================================================
|
|
259
|
+
// Start Server
|
|
260
|
+
// =============================================================================
|
|
261
|
+
|
|
262
|
+
initializeServer().then(() => {
|
|
263
|
+
const PORT = process.env.PORT || 3000;
|
|
264
|
+
app.listen(PORT, () => {
|
|
265
|
+
console.log(`Server listening on http://localhost:${PORT}`);
|
|
266
|
+
console.log('\nEndpoints:');
|
|
267
|
+
console.log(' GET /health - Health check');
|
|
268
|
+
console.log(' GET /info - Server info');
|
|
269
|
+
console.log(' POST /protected - Protected endpoint (requires invocation)');
|
|
270
|
+
console.log(' POST /delegate - Create delegation for a user');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Testing with cURL
|
|
276
|
+
|
|
277
|
+
### 1. Get server info
|
|
278
|
+
|
|
279
|
+
```bash
|
|
280
|
+
curl http://localhost:3000/info
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### 2. Create a delegation
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
curl -X POST http://localhost:3000/delegate \
|
|
287
|
+
-H "Content-Type: application/json" \
|
|
288
|
+
-d '{
|
|
289
|
+
"audience": "did:key:z6MkUserKey...",
|
|
290
|
+
"capabilities": [{ "can": "employees/read", "nb": { "limit": 50 } }]
|
|
291
|
+
}'
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
### 3. Use the delegation (invocation)
|
|
295
|
+
|
|
296
|
+
The client needs to create and sign an invocation. See [CLIENT.md](./CLIENT.md) for how to do this.
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
curl -X POST http://localhost:3000/protected \
|
|
300
|
+
-H "Content-Type: application/json" \
|
|
301
|
+
-d '{ "invocation": "<base64 CAR from client>" }'
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Validation Results
|
|
305
|
+
|
|
306
|
+
### Success Response
|
|
307
|
+
|
|
308
|
+
```json
|
|
309
|
+
{
|
|
310
|
+
"message": "Access granted!",
|
|
311
|
+
"invoker": "did:key:z6MkUserKey...",
|
|
312
|
+
"capability": {
|
|
313
|
+
"can": "employees/read",
|
|
314
|
+
"with": "myapp:did:ixo:ixo1abc...",
|
|
315
|
+
"nb": { "limit": 25 }
|
|
316
|
+
},
|
|
317
|
+
"employees": [...]
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Error Responses
|
|
322
|
+
|
|
323
|
+
**Missing invocation:**
|
|
324
|
+
```json
|
|
325
|
+
{
|
|
326
|
+
"error": "Missing invocation in request body",
|
|
327
|
+
"hint": "Send { \"invocation\": \"<base64 CAR>\" }"
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Invalid signature:**
|
|
332
|
+
```json
|
|
333
|
+
{
|
|
334
|
+
"error": "Unauthorized",
|
|
335
|
+
"details": { "code": "INVALID_SIGNATURE", "message": "..." }
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Caveat violation:**
|
|
340
|
+
```json
|
|
341
|
+
{
|
|
342
|
+
"error": "Unauthorized",
|
|
343
|
+
"details": {
|
|
344
|
+
"code": "CAVEAT_VIOLATION",
|
|
345
|
+
"message": "Cannot request limit=100, delegation only allows limit=50"
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Replay attack:**
|
|
351
|
+
```json
|
|
352
|
+
{
|
|
353
|
+
"error": "Unauthorized",
|
|
354
|
+
"details": { "code": "REPLAY", "message": "Invocation has already been used" }
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
## Using with Other Frameworks
|
|
359
|
+
|
|
360
|
+
The validator is framework-agnostic. Here's how to use it with other frameworks:
|
|
361
|
+
|
|
362
|
+
### Fastify
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
fastify.post('/protected', async (request, reply) => {
|
|
366
|
+
const result = await validator.validate(
|
|
367
|
+
request.body.invocation,
|
|
368
|
+
EmployeesRead,
|
|
369
|
+
'myapp:server'
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (!result.ok) {
|
|
373
|
+
return reply.code(403).send({ error: result.error });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { employees: [...] };
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
### Hono
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
app.post('/protected', async (c) => {
|
|
384
|
+
const { invocation } = await c.req.json();
|
|
385
|
+
const result = await validator.validate(invocation, EmployeesRead, 'myapp:server');
|
|
386
|
+
|
|
387
|
+
if (!result.ok) {
|
|
388
|
+
return c.json({ error: result.error }, 403);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return c.json({ employees: [...] });
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
### NestJS
|
|
396
|
+
|
|
397
|
+
```typescript
|
|
398
|
+
@Controller('employees')
|
|
399
|
+
export class EmployeesController {
|
|
400
|
+
constructor(private readonly ucanService: UCANService) {}
|
|
401
|
+
|
|
402
|
+
@Post()
|
|
403
|
+
async getEmployees(@Body('invocation') invocation: string) {
|
|
404
|
+
const result = await this.ucanService.validate(invocation, EmployeesRead);
|
|
405
|
+
|
|
406
|
+
if (!result.ok) {
|
|
407
|
+
throw new ForbiddenException(result.error);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return { employees: [...] };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
```
|
|
414
|
+
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ixo/ucan",
|
|
3
3
|
"description": "UCAN authorization for any service - built on ucanto",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"private": false,
|
|
6
6
|
"publishConfig": {
|
|
7
7
|
"access": "public"
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"ts-jest": "^29.2.5",
|
|
45
45
|
"tsx": "^4.7.0",
|
|
46
46
|
"typescript": "^5.5.4",
|
|
47
|
-
"@ixo/eslint-config": "0.0.0",
|
|
48
47
|
"@ixo/jest-config": "0.0.0",
|
|
49
|
-
"@ixo/typescript-config": "0.0.0"
|
|
48
|
+
"@ixo/typescript-config": "0.0.0",
|
|
49
|
+
"@ixo/eslint-config": "0.0.0"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@ucanto/client": "9.0.2",
|