@eudi-verify/server 0.1.0
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/LICENSE +184 -0
- package/README.md +258 -0
- package/dist/index.d.ts +835 -0
- package/dist/index.js +968 -0
- package/package.json +48 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var TERMINAL_STATUSES = [
|
|
3
|
+
"verified",
|
|
4
|
+
"rejected",
|
|
5
|
+
"expired",
|
|
6
|
+
"cancelled",
|
|
7
|
+
"error"
|
|
8
|
+
];
|
|
9
|
+
function isTerminalStatus(status) {
|
|
10
|
+
return TERMINAL_STATUSES.includes(status);
|
|
11
|
+
}
|
|
12
|
+
function sessionToDTO(session) {
|
|
13
|
+
const dto = {
|
|
14
|
+
id: session.id,
|
|
15
|
+
status: session.status,
|
|
16
|
+
createdAt: session.createdAt.toISOString(),
|
|
17
|
+
expiresAt: session.expiresAt.toISOString()
|
|
18
|
+
};
|
|
19
|
+
if (session.qrUrl) dto.qrUrl = session.qrUrl;
|
|
20
|
+
if (session.token) dto.token = session.token;
|
|
21
|
+
if (session.claims) dto.claims = session.claims;
|
|
22
|
+
if (session.error) dto.error = session.error;
|
|
23
|
+
return dto;
|
|
24
|
+
}
|
|
25
|
+
var TOKEN_VERSION = "eudi_v1";
|
|
26
|
+
var DEFAULT_SESSION_TTL_MS = 5 * 60 * 1e3;
|
|
27
|
+
var DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1e3;
|
|
28
|
+
|
|
29
|
+
// src/store.ts
|
|
30
|
+
var MemoryKVStore = class {
|
|
31
|
+
store = /* @__PURE__ */ new Map();
|
|
32
|
+
cleanupInterval = null;
|
|
33
|
+
/**
|
|
34
|
+
* Create a new in-memory store.
|
|
35
|
+
* @param cleanupIntervalMs - How often to run expired key cleanup (default: 60s)
|
|
36
|
+
*/
|
|
37
|
+
constructor(cleanupIntervalMs = 6e4) {
|
|
38
|
+
if (cleanupIntervalMs > 0) {
|
|
39
|
+
this.cleanupInterval = setInterval(() => {
|
|
40
|
+
this.cleanup();
|
|
41
|
+
}, cleanupIntervalMs);
|
|
42
|
+
if (this.cleanupInterval.unref) {
|
|
43
|
+
this.cleanupInterval.unref();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async get(key) {
|
|
48
|
+
const entry = this.store.get(key);
|
|
49
|
+
if (!entry) {
|
|
50
|
+
return void 0;
|
|
51
|
+
}
|
|
52
|
+
if (entry.expiresAt !== null && Date.now() > entry.expiresAt) {
|
|
53
|
+
this.store.delete(key);
|
|
54
|
+
return void 0;
|
|
55
|
+
}
|
|
56
|
+
return entry.value;
|
|
57
|
+
}
|
|
58
|
+
async set(key, value, ttlMs) {
|
|
59
|
+
const expiresAt = ttlMs ? Date.now() + ttlMs : null;
|
|
60
|
+
this.store.set(key, { value, expiresAt });
|
|
61
|
+
}
|
|
62
|
+
async delete(key) {
|
|
63
|
+
return this.store.delete(key);
|
|
64
|
+
}
|
|
65
|
+
async has(key) {
|
|
66
|
+
const value = await this.get(key);
|
|
67
|
+
return value !== void 0;
|
|
68
|
+
}
|
|
69
|
+
async getAndDelete(key) {
|
|
70
|
+
const value = await this.get(key);
|
|
71
|
+
if (value !== void 0) {
|
|
72
|
+
this.store.delete(key);
|
|
73
|
+
}
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
async clear() {
|
|
77
|
+
this.store.clear();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Remove expired entries. Called automatically on interval.
|
|
81
|
+
*/
|
|
82
|
+
cleanup() {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
for (const [key, entry] of this.store) {
|
|
85
|
+
if (entry.expiresAt !== null && now > entry.expiresAt) {
|
|
86
|
+
this.store.delete(key);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Stop the cleanup interval. Call when disposing the store.
|
|
92
|
+
*/
|
|
93
|
+
dispose() {
|
|
94
|
+
if (this.cleanupInterval) {
|
|
95
|
+
clearInterval(this.cleanupInterval);
|
|
96
|
+
this.cleanupInterval = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Get the current number of entries (for testing/monitoring).
|
|
101
|
+
*/
|
|
102
|
+
get size() {
|
|
103
|
+
return this.store.size;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
var KEY_PREFIX = {
|
|
107
|
+
SESSION: "session:",
|
|
108
|
+
TOKEN: "token:",
|
|
109
|
+
RATE_LIMIT: "rate:"
|
|
110
|
+
};
|
|
111
|
+
function sessionKey(sessionId) {
|
|
112
|
+
return `${KEY_PREFIX.SESSION}${sessionId}`;
|
|
113
|
+
}
|
|
114
|
+
function tokenKey(tokenId) {
|
|
115
|
+
return `${KEY_PREFIX.TOKEN}${tokenId}`;
|
|
116
|
+
}
|
|
117
|
+
function rateLimitKey(ip) {
|
|
118
|
+
return `${KEY_PREFIX.RATE_LIMIT}${ip}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/engine.ts
|
|
122
|
+
var MockEngine = class {
|
|
123
|
+
name = "mock";
|
|
124
|
+
mode = "demo";
|
|
125
|
+
config;
|
|
126
|
+
constructor(config = {}) {
|
|
127
|
+
this.config = {
|
|
128
|
+
verificationDelayMs: config.verificationDelayMs ?? 1e3,
|
|
129
|
+
successRate: config.successRate ?? 1,
|
|
130
|
+
defaultClaims: config.defaultClaims ?? {}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async createSession(config) {
|
|
134
|
+
const requestedClaims = Object.keys(config.request).filter(
|
|
135
|
+
(k) => config.request[k] === true
|
|
136
|
+
);
|
|
137
|
+
const qrUrl = this.buildMockQrUrl(config.sessionId, config.baseUrl, requestedClaims);
|
|
138
|
+
return {
|
|
139
|
+
qrUrl,
|
|
140
|
+
engineData: {
|
|
141
|
+
requestedClaims,
|
|
142
|
+
createdAt: Date.now()
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async parseCallback(rawBody) {
|
|
147
|
+
const params = new URLSearchParams(rawBody);
|
|
148
|
+
const response = params.get("response");
|
|
149
|
+
const sessionId = params.get("session_id") || params.get("state");
|
|
150
|
+
if (!response || !sessionId) {
|
|
151
|
+
throw new Error("Invalid callback: missing response or session_id");
|
|
152
|
+
}
|
|
153
|
+
return { sessionId, response };
|
|
154
|
+
}
|
|
155
|
+
async handleCallback(data, session) {
|
|
156
|
+
if (this.config.verificationDelayMs > 0) {
|
|
157
|
+
await this.delay(this.config.verificationDelayMs);
|
|
158
|
+
}
|
|
159
|
+
const shouldSucceed = Math.random() < this.config.successRate;
|
|
160
|
+
if (!shouldSucceed) {
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
error: "Simulated verification failure",
|
|
164
|
+
status: "rejected"
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const claims = this.generateMockClaims(session.request);
|
|
168
|
+
return {
|
|
169
|
+
success: true,
|
|
170
|
+
claims,
|
|
171
|
+
status: "verified"
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
async getAuthorizationRequest(session) {
|
|
175
|
+
return JSON.stringify({
|
|
176
|
+
type: "mock_authorization_request",
|
|
177
|
+
sessionId: session.id,
|
|
178
|
+
request: session.request
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
async cancelSession(_session) {
|
|
182
|
+
}
|
|
183
|
+
buildMockQrUrl(sessionId, baseUrl, claims) {
|
|
184
|
+
const params = new URLSearchParams({
|
|
185
|
+
session_id: sessionId,
|
|
186
|
+
claims: claims.join(","),
|
|
187
|
+
mock: "true"
|
|
188
|
+
});
|
|
189
|
+
return `${baseUrl}/mock-wallet?${params.toString()}`;
|
|
190
|
+
}
|
|
191
|
+
generateMockClaims(request) {
|
|
192
|
+
const claims = { ...this.config.defaultClaims };
|
|
193
|
+
if (request.age_over_18) claims.age_over_18 = true;
|
|
194
|
+
if (request.age_over_21) claims.age_over_21 = true;
|
|
195
|
+
if (request.nationality) claims.nationality = "LU";
|
|
196
|
+
if (request.given_name) claims.given_name = "Max";
|
|
197
|
+
if (request.family_name) claims.family_name = "Mustermann";
|
|
198
|
+
if (request.birth_date) claims.birth_date = "1990-01-15";
|
|
199
|
+
return claims;
|
|
200
|
+
}
|
|
201
|
+
delay(ms) {
|
|
202
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// src/engines/openeudi.ts
|
|
207
|
+
var OpenEudiEngine = class {
|
|
208
|
+
name = "openeudi";
|
|
209
|
+
mode;
|
|
210
|
+
config;
|
|
211
|
+
constructor(options) {
|
|
212
|
+
this.mode = options.mode;
|
|
213
|
+
this.config = {
|
|
214
|
+
mode: options.mode,
|
|
215
|
+
baseUrl: options.baseUrl,
|
|
216
|
+
sessionTtlMs: options.sessionTtlMs ?? 5 * 60 * 1e3,
|
|
217
|
+
demoClaims: options.demoClaims ?? {
|
|
218
|
+
age_over_18: true,
|
|
219
|
+
age_over_21: true,
|
|
220
|
+
nationality: "LU",
|
|
221
|
+
given_name: "Jean",
|
|
222
|
+
family_name: "Dupont",
|
|
223
|
+
birth_date: "1985-03-15"
|
|
224
|
+
},
|
|
225
|
+
demoDelayMs: options.demoDelayMs ?? 0
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async initialize() {
|
|
229
|
+
if (this.mode === "demo") {
|
|
230
|
+
console.warn(
|
|
231
|
+
"[OpenEudiEngine] Running in DEMO mode. Credentials are simulated. Do NOT use in production."
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
async createSession(config) {
|
|
236
|
+
const nonce = this.generateNonce();
|
|
237
|
+
const requestedClaims = Object.keys(config.request).filter(
|
|
238
|
+
(k) => config.request[k] === true
|
|
239
|
+
);
|
|
240
|
+
const engineData = {
|
|
241
|
+
nonce,
|
|
242
|
+
requestedClaims,
|
|
243
|
+
createdAt: Date.now()
|
|
244
|
+
};
|
|
245
|
+
const qrUrl = this.buildAuthorizationRequestUrl(config, nonce);
|
|
246
|
+
return { qrUrl, engineData };
|
|
247
|
+
}
|
|
248
|
+
async parseCallback(rawBody) {
|
|
249
|
+
const params = new URLSearchParams(rawBody);
|
|
250
|
+
const response = params.get("response");
|
|
251
|
+
const state = params.get("state") || params.get("session_id");
|
|
252
|
+
if (!response || !state) {
|
|
253
|
+
throw new Error("Invalid callback: missing response or state");
|
|
254
|
+
}
|
|
255
|
+
return { sessionId: state, response };
|
|
256
|
+
}
|
|
257
|
+
async handleCallback(data, session) {
|
|
258
|
+
if (this.config.demoDelayMs > 0) {
|
|
259
|
+
await this.delay(this.config.demoDelayMs);
|
|
260
|
+
}
|
|
261
|
+
if (this.mode === "demo") {
|
|
262
|
+
return this.handleDemoCallback(data, session);
|
|
263
|
+
}
|
|
264
|
+
return this.handleProductionCallback(data, session);
|
|
265
|
+
}
|
|
266
|
+
async getAuthorizationRequest(session) {
|
|
267
|
+
const engineData = session._engineData;
|
|
268
|
+
const nonce = engineData?.nonce ?? this.generateNonce();
|
|
269
|
+
if (this.mode === "demo") {
|
|
270
|
+
return JSON.stringify({
|
|
271
|
+
type: "authorization_request",
|
|
272
|
+
response_type: "vp_token",
|
|
273
|
+
client_id: this.config.baseUrl,
|
|
274
|
+
redirect_uri: `${this.config.baseUrl}/callback`,
|
|
275
|
+
state: session.id,
|
|
276
|
+
nonce,
|
|
277
|
+
presentation_definition: this.buildPresentationDefinition(session.request),
|
|
278
|
+
mode: "demo"
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return JSON.stringify({
|
|
282
|
+
type: "authorization_request",
|
|
283
|
+
response_type: "vp_token",
|
|
284
|
+
client_id: this.config.baseUrl,
|
|
285
|
+
redirect_uri: `${this.config.baseUrl}/callback`,
|
|
286
|
+
state: session.id,
|
|
287
|
+
nonce,
|
|
288
|
+
presentation_definition: this.buildPresentationDefinition(session.request)
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
async cancelSession(_session) {
|
|
292
|
+
}
|
|
293
|
+
async shutdown() {
|
|
294
|
+
}
|
|
295
|
+
handleDemoCallback(_data, session) {
|
|
296
|
+
const engineData = session._engineData;
|
|
297
|
+
const requestedClaims = engineData?.requestedClaims ?? Object.keys(session.request);
|
|
298
|
+
const claims = this.generateDemoClaims(requestedClaims);
|
|
299
|
+
return {
|
|
300
|
+
success: true,
|
|
301
|
+
claims,
|
|
302
|
+
status: "verified"
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
handleProductionCallback(_data, _session) {
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
error: "Production mode not yet implemented",
|
|
309
|
+
status: "error"
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
buildAuthorizationRequestUrl(config, nonce) {
|
|
313
|
+
if (this.mode === "demo") {
|
|
314
|
+
const params2 = new URLSearchParams({
|
|
315
|
+
client_id: this.config.baseUrl,
|
|
316
|
+
response_type: "vp_token",
|
|
317
|
+
state: config.sessionId,
|
|
318
|
+
nonce,
|
|
319
|
+
redirect_uri: `${this.config.baseUrl}/callback`,
|
|
320
|
+
mode: "demo"
|
|
321
|
+
});
|
|
322
|
+
return `openid4vp://authorize?${params2.toString()}`;
|
|
323
|
+
}
|
|
324
|
+
const params = new URLSearchParams({
|
|
325
|
+
client_id: this.config.baseUrl,
|
|
326
|
+
request_uri: `${config.baseUrl}/request/${config.sessionId}`
|
|
327
|
+
});
|
|
328
|
+
return `openid4vp://authorize?${params.toString()}`;
|
|
329
|
+
}
|
|
330
|
+
buildPresentationDefinition(request) {
|
|
331
|
+
const requestedClaims = Object.keys(request).filter((k) => request[k] === true);
|
|
332
|
+
const inputDescriptors = requestedClaims.map((claim) => ({
|
|
333
|
+
id: claim,
|
|
334
|
+
name: this.getClaimDisplayName(claim),
|
|
335
|
+
purpose: `Verify ${this.getClaimDisplayName(claim).toLowerCase()}`,
|
|
336
|
+
constraints: {
|
|
337
|
+
fields: [
|
|
338
|
+
{
|
|
339
|
+
path: [`$.${claim}`, `$.vc.credentialSubject.${claim}`]
|
|
340
|
+
}
|
|
341
|
+
]
|
|
342
|
+
}
|
|
343
|
+
}));
|
|
344
|
+
return {
|
|
345
|
+
id: `eudi-verify-${Date.now()}`,
|
|
346
|
+
name: "EUDI Verification Request",
|
|
347
|
+
purpose: "Identity verification",
|
|
348
|
+
input_descriptors: inputDescriptors
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
getClaimDisplayName(claim) {
|
|
352
|
+
const names = {
|
|
353
|
+
age_over_18: "Age over 18",
|
|
354
|
+
age_over_21: "Age over 21",
|
|
355
|
+
nationality: "Nationality",
|
|
356
|
+
given_name: "Given name",
|
|
357
|
+
family_name: "Family name",
|
|
358
|
+
birth_date: "Birth date"
|
|
359
|
+
};
|
|
360
|
+
return names[claim] ?? claim;
|
|
361
|
+
}
|
|
362
|
+
generateDemoClaims(requestedClaims) {
|
|
363
|
+
const claims = {};
|
|
364
|
+
const defaults = this.config.demoClaims;
|
|
365
|
+
for (const claim of requestedClaims) {
|
|
366
|
+
if (claim === "age_over_18" && defaults.age_over_18 !== void 0) {
|
|
367
|
+
claims.age_over_18 = defaults.age_over_18;
|
|
368
|
+
} else if (claim === "age_over_21" && defaults.age_over_21 !== void 0) {
|
|
369
|
+
claims.age_over_21 = defaults.age_over_21;
|
|
370
|
+
} else if (claim === "nationality" && defaults.nationality) {
|
|
371
|
+
claims.nationality = defaults.nationality;
|
|
372
|
+
} else if (claim === "given_name" && defaults.given_name) {
|
|
373
|
+
claims.given_name = defaults.given_name;
|
|
374
|
+
} else if (claim === "family_name" && defaults.family_name) {
|
|
375
|
+
claims.family_name = defaults.family_name;
|
|
376
|
+
} else if (claim === "birth_date" && defaults.birth_date) {
|
|
377
|
+
claims.birth_date = defaults.birth_date;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return claims;
|
|
381
|
+
}
|
|
382
|
+
generateNonce() {
|
|
383
|
+
const array = new Uint8Array(16);
|
|
384
|
+
crypto.getRandomValues(array);
|
|
385
|
+
return Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
386
|
+
}
|
|
387
|
+
delay(ms) {
|
|
388
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
// src/token.ts
|
|
393
|
+
import { createHmac, timingSafeEqual, randomUUID } from "crypto";
|
|
394
|
+
function createTokenService(config) {
|
|
395
|
+
const { secret, store, keyId = "k1", ttlMs = DEFAULT_TOKEN_TTL_MS } = config;
|
|
396
|
+
if (secret.length < 32) {
|
|
397
|
+
throw new Error("Token secret must be at least 32 characters");
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
async mint(sessionId, claims) {
|
|
401
|
+
const tokenId = randomUUID();
|
|
402
|
+
const exp = Math.floor((Date.now() + ttlMs) / 1e3);
|
|
403
|
+
const claimsHash = hashClaims(claims, secret);
|
|
404
|
+
const payload = {
|
|
405
|
+
sid: sessionId,
|
|
406
|
+
kid: keyId,
|
|
407
|
+
exp,
|
|
408
|
+
hash: claimsHash
|
|
409
|
+
};
|
|
410
|
+
const payloadB64 = base64UrlEncode(JSON.stringify(payload));
|
|
411
|
+
const signature = createSignature(payloadB64, secret);
|
|
412
|
+
const token = `${TOKEN_VERSION}.${payloadB64}.${signature}`;
|
|
413
|
+
const tokenData = {
|
|
414
|
+
sessionId,
|
|
415
|
+
claims,
|
|
416
|
+
createdAt: Date.now()
|
|
417
|
+
};
|
|
418
|
+
await store.set(tokenKey(tokenId), tokenData, ttlMs);
|
|
419
|
+
const fullPayload = { ...payload, tid: tokenId };
|
|
420
|
+
const fullPayloadB64 = base64UrlEncode(JSON.stringify(fullPayload));
|
|
421
|
+
const fullSignature = createSignature(fullPayloadB64, secret);
|
|
422
|
+
return `${TOKEN_VERSION}.${fullPayloadB64}.${fullSignature}`;
|
|
423
|
+
},
|
|
424
|
+
async verify(token) {
|
|
425
|
+
const parsed = parseToken(token);
|
|
426
|
+
if (!parsed) {
|
|
427
|
+
return { valid: false, error: "invalid_token" };
|
|
428
|
+
}
|
|
429
|
+
const { version, payloadB64, signature, payload } = parsed;
|
|
430
|
+
if (version !== TOKEN_VERSION) {
|
|
431
|
+
return { valid: false, error: "invalid_token" };
|
|
432
|
+
}
|
|
433
|
+
if (!payload.tid || !payload.sid || !payload.exp || !payload.hash) {
|
|
434
|
+
return { valid: false, error: "invalid_token" };
|
|
435
|
+
}
|
|
436
|
+
const expectedSignature = createSignature(payloadB64, secret);
|
|
437
|
+
if (!constantTimeCompare(signature, expectedSignature)) {
|
|
438
|
+
return { valid: false, error: "invalid_signature" };
|
|
439
|
+
}
|
|
440
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
441
|
+
if (payload.exp <= nowSec) {
|
|
442
|
+
await store.delete(tokenKey(payload.tid));
|
|
443
|
+
return { valid: false, error: "expired" };
|
|
444
|
+
}
|
|
445
|
+
const storedData = await store.getAndDelete(
|
|
446
|
+
tokenKey(payload.tid)
|
|
447
|
+
);
|
|
448
|
+
if (!storedData) {
|
|
449
|
+
const currentSec = Math.floor(Date.now() / 1e3);
|
|
450
|
+
if (payload.exp <= currentSec) {
|
|
451
|
+
return { valid: false, error: "expired" };
|
|
452
|
+
}
|
|
453
|
+
return { valid: false, error: "already_consumed" };
|
|
454
|
+
}
|
|
455
|
+
if (storedData.sessionId !== payload.sid) {
|
|
456
|
+
return { valid: false, error: "invalid_token" };
|
|
457
|
+
}
|
|
458
|
+
const expectedHash = hashClaims(storedData.claims, secret);
|
|
459
|
+
if (payload.hash !== expectedHash) {
|
|
460
|
+
return { valid: false, error: "invalid_token" };
|
|
461
|
+
}
|
|
462
|
+
return { valid: true, claims: storedData.claims };
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function parseToken(token) {
|
|
467
|
+
const parts = token.split(".");
|
|
468
|
+
if (parts.length !== 3) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
const [version, payloadB64, signature] = parts;
|
|
472
|
+
try {
|
|
473
|
+
const payloadJson = base64UrlDecode(payloadB64);
|
|
474
|
+
const payload = JSON.parse(payloadJson);
|
|
475
|
+
return { version, payloadB64, signature, payload };
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function createSignature(data, secret) {
|
|
481
|
+
return createHmac("sha256", secret).update(data).digest("base64url");
|
|
482
|
+
}
|
|
483
|
+
function constantTimeCompare(a, b) {
|
|
484
|
+
if (a.length !== b.length) {
|
|
485
|
+
const dummy = Buffer.alloc(a.length);
|
|
486
|
+
timingSafeEqual(dummy, dummy);
|
|
487
|
+
return false;
|
|
488
|
+
}
|
|
489
|
+
const bufA = Buffer.from(a);
|
|
490
|
+
const bufB = Buffer.from(b);
|
|
491
|
+
return timingSafeEqual(bufA, bufB);
|
|
492
|
+
}
|
|
493
|
+
function hashClaims(claims, secret) {
|
|
494
|
+
const sorted = JSON.stringify(claims, Object.keys(claims).sort());
|
|
495
|
+
return createHmac("sha256", secret).update(sorted).digest("base64url").slice(0, 16);
|
|
496
|
+
}
|
|
497
|
+
function base64UrlEncode(str) {
|
|
498
|
+
return Buffer.from(str).toString("base64url");
|
|
499
|
+
}
|
|
500
|
+
function base64UrlDecode(str) {
|
|
501
|
+
return Buffer.from(str, "base64url").toString("utf-8");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/rate-limit.ts
|
|
505
|
+
function createRateLimiter(config) {
|
|
506
|
+
const { maxRequests = 10, windowMs = 6e4, store } = config;
|
|
507
|
+
async function getOrCreateWindow(ip) {
|
|
508
|
+
const key = rateLimitKey(ip);
|
|
509
|
+
const now = Date.now();
|
|
510
|
+
const existing = await store.get(key);
|
|
511
|
+
if (existing && now - existing.windowStart < windowMs) {
|
|
512
|
+
return existing;
|
|
513
|
+
}
|
|
514
|
+
const data = {
|
|
515
|
+
count: 0,
|
|
516
|
+
windowStart: now
|
|
517
|
+
};
|
|
518
|
+
await store.set(key, data, windowMs);
|
|
519
|
+
return data;
|
|
520
|
+
}
|
|
521
|
+
async function incrementWindow(ip) {
|
|
522
|
+
const key = rateLimitKey(ip);
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
const existing = await store.get(key);
|
|
525
|
+
if (existing && now - existing.windowStart < windowMs) {
|
|
526
|
+
const updated = {
|
|
527
|
+
count: existing.count + 1,
|
|
528
|
+
windowStart: existing.windowStart
|
|
529
|
+
};
|
|
530
|
+
const remainingTtl = windowMs - (now - existing.windowStart);
|
|
531
|
+
await store.set(key, updated, remainingTtl);
|
|
532
|
+
} else {
|
|
533
|
+
const data = {
|
|
534
|
+
count: 1,
|
|
535
|
+
windowStart: now
|
|
536
|
+
};
|
|
537
|
+
await store.set(key, data, windowMs);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
async check(ip) {
|
|
542
|
+
const window = await getOrCreateWindow(ip);
|
|
543
|
+
const remaining = Math.max(0, maxRequests - window.count);
|
|
544
|
+
const allowed = window.count < maxRequests;
|
|
545
|
+
const result = {
|
|
546
|
+
allowed,
|
|
547
|
+
remaining,
|
|
548
|
+
limit: maxRequests
|
|
549
|
+
};
|
|
550
|
+
if (!allowed) {
|
|
551
|
+
const elapsed = Date.now() - window.windowStart;
|
|
552
|
+
result.retryAfter = Math.ceil((windowMs - elapsed) / 1e3);
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
},
|
|
556
|
+
async consume(ip) {
|
|
557
|
+
await incrementWindow(ip);
|
|
558
|
+
},
|
|
559
|
+
async checkAndConsume(ip) {
|
|
560
|
+
const window = await getOrCreateWindow(ip);
|
|
561
|
+
const wouldBeCount = window.count + 1;
|
|
562
|
+
const allowed = wouldBeCount <= maxRequests;
|
|
563
|
+
if (allowed) {
|
|
564
|
+
await incrementWindow(ip);
|
|
565
|
+
}
|
|
566
|
+
const remaining = Math.max(0, maxRequests - wouldBeCount);
|
|
567
|
+
const result = {
|
|
568
|
+
allowed,
|
|
569
|
+
remaining: allowed ? remaining : Math.max(0, maxRequests - window.count),
|
|
570
|
+
limit: maxRequests
|
|
571
|
+
};
|
|
572
|
+
if (!allowed) {
|
|
573
|
+
const elapsed = Date.now() - window.windowStart;
|
|
574
|
+
result.retryAfter = Math.ceil((windowMs - elapsed) / 1e3);
|
|
575
|
+
}
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/handlers.ts
|
|
582
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
583
|
+
function createVerifierHandlers(config) {
|
|
584
|
+
const {
|
|
585
|
+
engine,
|
|
586
|
+
store,
|
|
587
|
+
baseUrl,
|
|
588
|
+
mode,
|
|
589
|
+
sessionTtlMs = DEFAULT_SESSION_TTL_MS,
|
|
590
|
+
tokenSecret,
|
|
591
|
+
tokenKeyId,
|
|
592
|
+
rateLimit: rateLimitConfig,
|
|
593
|
+
allowedOrigins = []
|
|
594
|
+
} = config;
|
|
595
|
+
const tokenService = createTokenService({
|
|
596
|
+
secret: tokenSecret,
|
|
597
|
+
keyId: tokenKeyId,
|
|
598
|
+
ttlMs: sessionTtlMs,
|
|
599
|
+
store
|
|
600
|
+
});
|
|
601
|
+
const rateLimiter = rateLimitConfig ? createRateLimiter({
|
|
602
|
+
maxRequests: rateLimitConfig.maxRequests,
|
|
603
|
+
windowMs: rateLimitConfig.windowMs,
|
|
604
|
+
store
|
|
605
|
+
}) : null;
|
|
606
|
+
function modeHeader() {
|
|
607
|
+
return { "X-Eudi-Mode": mode };
|
|
608
|
+
}
|
|
609
|
+
function logDemoWarning() {
|
|
610
|
+
if (mode === "demo") {
|
|
611
|
+
console.warn(
|
|
612
|
+
"[eudi-verify] WARNING: Running in demo mode. Credentials are simulated. Do not use in production."
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
function checkOrigin(origin) {
|
|
617
|
+
if (allowedOrigins.length === 0) return true;
|
|
618
|
+
if (!origin) return false;
|
|
619
|
+
return allowedOrigins.includes(origin);
|
|
620
|
+
}
|
|
621
|
+
async function getStoredSession(sessionId) {
|
|
622
|
+
const session = await store.get(sessionKey(sessionId));
|
|
623
|
+
if (!session) return null;
|
|
624
|
+
if (!isTerminalStatus(session.status) && /* @__PURE__ */ new Date() > new Date(session.expiresAt)) {
|
|
625
|
+
const expired = { ...session, status: "expired" };
|
|
626
|
+
await store.set(sessionKey(sessionId), expired);
|
|
627
|
+
return expired;
|
|
628
|
+
}
|
|
629
|
+
return session;
|
|
630
|
+
}
|
|
631
|
+
return {
|
|
632
|
+
async createSession(ctx) {
|
|
633
|
+
logDemoWarning();
|
|
634
|
+
if (!checkOrigin(ctx.origin)) {
|
|
635
|
+
return {
|
|
636
|
+
status: 403,
|
|
637
|
+
headers: modeHeader(),
|
|
638
|
+
body: {
|
|
639
|
+
error: "forbidden",
|
|
640
|
+
message: "Origin not allowed"
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
if (rateLimiter) {
|
|
645
|
+
const rateResult = await rateLimiter.checkAndConsume(ctx.ip);
|
|
646
|
+
if (!rateResult.allowed) {
|
|
647
|
+
return {
|
|
648
|
+
status: 429,
|
|
649
|
+
headers: {
|
|
650
|
+
...modeHeader(),
|
|
651
|
+
"Retry-After": String(rateResult.retryAfter ?? 60)
|
|
652
|
+
},
|
|
653
|
+
body: {
|
|
654
|
+
error: "rate_limited",
|
|
655
|
+
message: "Too many requests, please retry later"
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const input = ctx.body;
|
|
661
|
+
if (!input?.request || typeof input.request !== "object") {
|
|
662
|
+
return {
|
|
663
|
+
status: 400,
|
|
664
|
+
headers: modeHeader(),
|
|
665
|
+
body: {
|
|
666
|
+
error: "bad_request",
|
|
667
|
+
message: "Invalid verification request"
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const sessionId = randomUUID2();
|
|
672
|
+
const now = /* @__PURE__ */ new Date();
|
|
673
|
+
const expiresAt = new Date(now.getTime() + sessionTtlMs);
|
|
674
|
+
try {
|
|
675
|
+
const engineResult = await engine.createSession({
|
|
676
|
+
sessionId,
|
|
677
|
+
request: input.request,
|
|
678
|
+
baseUrl,
|
|
679
|
+
ttlMs: sessionTtlMs
|
|
680
|
+
});
|
|
681
|
+
const session = {
|
|
682
|
+
id: sessionId,
|
|
683
|
+
status: "pending",
|
|
684
|
+
request: input.request,
|
|
685
|
+
qrUrl: engineResult.qrUrl,
|
|
686
|
+
createdAt: now,
|
|
687
|
+
expiresAt,
|
|
688
|
+
_engineData: engineResult.engineData
|
|
689
|
+
};
|
|
690
|
+
await store.set(sessionKey(sessionId), session, sessionTtlMs);
|
|
691
|
+
return {
|
|
692
|
+
status: 201,
|
|
693
|
+
headers: modeHeader(),
|
|
694
|
+
body: sessionToDTO(session)
|
|
695
|
+
};
|
|
696
|
+
} catch (err) {
|
|
697
|
+
console.error("[eudi-verify] createSession error:", err);
|
|
698
|
+
return {
|
|
699
|
+
status: 500,
|
|
700
|
+
headers: modeHeader(),
|
|
701
|
+
body: {
|
|
702
|
+
error: "internal_error",
|
|
703
|
+
message: "Failed to create session"
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
},
|
|
708
|
+
async getSession(ctx) {
|
|
709
|
+
const { sessionId } = ctx.params;
|
|
710
|
+
if (!sessionId) {
|
|
711
|
+
return {
|
|
712
|
+
status: 400,
|
|
713
|
+
headers: modeHeader(),
|
|
714
|
+
body: {
|
|
715
|
+
error: "bad_request",
|
|
716
|
+
message: "Missing session ID"
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
const session = await getStoredSession(sessionId);
|
|
721
|
+
if (!session) {
|
|
722
|
+
return {
|
|
723
|
+
status: 404,
|
|
724
|
+
headers: modeHeader(),
|
|
725
|
+
body: {
|
|
726
|
+
error: "not_found",
|
|
727
|
+
message: "Session not found"
|
|
728
|
+
}
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
status: 200,
|
|
733
|
+
headers: modeHeader(),
|
|
734
|
+
body: sessionToDTO(session)
|
|
735
|
+
};
|
|
736
|
+
},
|
|
737
|
+
async cancelSession(ctx) {
|
|
738
|
+
const { sessionId } = ctx.params;
|
|
739
|
+
if (!sessionId) {
|
|
740
|
+
return {
|
|
741
|
+
status: 400,
|
|
742
|
+
headers: modeHeader(),
|
|
743
|
+
body: {
|
|
744
|
+
error: "bad_request",
|
|
745
|
+
message: "Missing session ID"
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
const session = await getStoredSession(sessionId);
|
|
750
|
+
if (!session) {
|
|
751
|
+
return {
|
|
752
|
+
status: 404,
|
|
753
|
+
headers: modeHeader(),
|
|
754
|
+
body: {
|
|
755
|
+
error: "not_found",
|
|
756
|
+
message: "Session not found"
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (isTerminalStatus(session.status)) {
|
|
761
|
+
return {
|
|
762
|
+
status: 409,
|
|
763
|
+
headers: modeHeader(),
|
|
764
|
+
body: {
|
|
765
|
+
error: "conflict",
|
|
766
|
+
message: `Session already in terminal state: ${session.status}`
|
|
767
|
+
}
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
const cancelled = { ...session, status: "cancelled" };
|
|
771
|
+
if (engine.cancelSession) {
|
|
772
|
+
try {
|
|
773
|
+
await engine.cancelSession(session);
|
|
774
|
+
} catch (err) {
|
|
775
|
+
console.error("[eudi-verify] cancelSession engine error:", err);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
await store.set(sessionKey(sessionId), cancelled, sessionTtlMs);
|
|
779
|
+
return {
|
|
780
|
+
status: 200,
|
|
781
|
+
headers: modeHeader(),
|
|
782
|
+
body: sessionToDTO(cancelled)
|
|
783
|
+
};
|
|
784
|
+
},
|
|
785
|
+
async verifyToken(ctx) {
|
|
786
|
+
const input = ctx.body;
|
|
787
|
+
if (!input?.token || typeof input.token !== "string") {
|
|
788
|
+
return {
|
|
789
|
+
status: 400,
|
|
790
|
+
headers: modeHeader(),
|
|
791
|
+
body: {
|
|
792
|
+
error: "bad_request",
|
|
793
|
+
message: "Missing or invalid token"
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
const result = await tokenService.verify(input.token);
|
|
798
|
+
return {
|
|
799
|
+
status: 200,
|
|
800
|
+
headers: modeHeader(),
|
|
801
|
+
body: result
|
|
802
|
+
};
|
|
803
|
+
},
|
|
804
|
+
async handleCallback(ctx) {
|
|
805
|
+
logDemoWarning();
|
|
806
|
+
if (!ctx.rawBody) {
|
|
807
|
+
return {
|
|
808
|
+
status: 400,
|
|
809
|
+
headers: modeHeader(),
|
|
810
|
+
body: {
|
|
811
|
+
error: "bad_request",
|
|
812
|
+
message: "Missing callback body"
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
let callbackData;
|
|
817
|
+
try {
|
|
818
|
+
callbackData = await engine.parseCallback(ctx.rawBody);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
console.error("[eudi-verify] parseCallback error:", err);
|
|
821
|
+
return {
|
|
822
|
+
status: 400,
|
|
823
|
+
headers: modeHeader(),
|
|
824
|
+
body: {
|
|
825
|
+
error: "bad_request",
|
|
826
|
+
message: "Invalid callback format"
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const session = await getStoredSession(callbackData.sessionId);
|
|
831
|
+
if (!session) {
|
|
832
|
+
return {
|
|
833
|
+
status: 400,
|
|
834
|
+
headers: modeHeader(),
|
|
835
|
+
body: {
|
|
836
|
+
error: "bad_request",
|
|
837
|
+
message: "Session not found for callback"
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
if (isTerminalStatus(session.status)) {
|
|
842
|
+
return {
|
|
843
|
+
status: 200,
|
|
844
|
+
headers: modeHeader(),
|
|
845
|
+
body: { status: "ok" }
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
try {
|
|
849
|
+
const result = await engine.handleCallback(callbackData, session);
|
|
850
|
+
const updated = {
|
|
851
|
+
...session,
|
|
852
|
+
status: result.status,
|
|
853
|
+
claims: result.claims,
|
|
854
|
+
error: result.error
|
|
855
|
+
};
|
|
856
|
+
if (result.success && result.claims) {
|
|
857
|
+
const token = await tokenService.mint(session.id, result.claims);
|
|
858
|
+
updated.token = token;
|
|
859
|
+
}
|
|
860
|
+
await store.set(sessionKey(session.id), updated, sessionTtlMs);
|
|
861
|
+
return {
|
|
862
|
+
status: 200,
|
|
863
|
+
headers: modeHeader(),
|
|
864
|
+
body: { status: "ok" }
|
|
865
|
+
};
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.error("[eudi-verify] handleCallback error:", err);
|
|
868
|
+
const errorSession = {
|
|
869
|
+
...session,
|
|
870
|
+
status: "error",
|
|
871
|
+
error: "Verification failed"
|
|
872
|
+
};
|
|
873
|
+
await store.set(sessionKey(session.id), errorSession, sessionTtlMs);
|
|
874
|
+
return {
|
|
875
|
+
status: 200,
|
|
876
|
+
headers: modeHeader(),
|
|
877
|
+
body: { status: "ok" }
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
},
|
|
881
|
+
async getRequest(ctx) {
|
|
882
|
+
const { requestId } = ctx.params;
|
|
883
|
+
if (!requestId) {
|
|
884
|
+
return {
|
|
885
|
+
status: 400,
|
|
886
|
+
headers: modeHeader(),
|
|
887
|
+
body: {
|
|
888
|
+
error: "bad_request",
|
|
889
|
+
message: "Missing request ID"
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
const session = await getStoredSession(requestId);
|
|
894
|
+
if (!session) {
|
|
895
|
+
return {
|
|
896
|
+
status: 404,
|
|
897
|
+
headers: {
|
|
898
|
+
...modeHeader(),
|
|
899
|
+
"Content-Type": "application/json"
|
|
900
|
+
},
|
|
901
|
+
body: {
|
|
902
|
+
error: "not_found",
|
|
903
|
+
message: "Request not found"
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
if (!engine.getAuthorizationRequest) {
|
|
908
|
+
return {
|
|
909
|
+
status: 501,
|
|
910
|
+
headers: {
|
|
911
|
+
...modeHeader(),
|
|
912
|
+
"Content-Type": "application/json"
|
|
913
|
+
},
|
|
914
|
+
body: {
|
|
915
|
+
error: "not_implemented",
|
|
916
|
+
message: "PAR not supported by this engine"
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
try {
|
|
921
|
+
const jwt = await engine.getAuthorizationRequest(session);
|
|
922
|
+
return {
|
|
923
|
+
status: 200,
|
|
924
|
+
headers: {
|
|
925
|
+
...modeHeader(),
|
|
926
|
+
"Content-Type": "application/oauth-authz-req+jwt"
|
|
927
|
+
},
|
|
928
|
+
body: jwt
|
|
929
|
+
};
|
|
930
|
+
} catch (err) {
|
|
931
|
+
console.error("[eudi-verify] getRequest error:", err);
|
|
932
|
+
return {
|
|
933
|
+
status: 500,
|
|
934
|
+
headers: {
|
|
935
|
+
...modeHeader(),
|
|
936
|
+
"Content-Type": "application/json"
|
|
937
|
+
},
|
|
938
|
+
body: {
|
|
939
|
+
error: "internal_error",
|
|
940
|
+
message: "Failed to generate authorization request"
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// src/index.ts
|
|
949
|
+
var VERSION = "0.1.0";
|
|
950
|
+
export {
|
|
951
|
+
DEFAULT_SESSION_TTL_MS,
|
|
952
|
+
DEFAULT_TOKEN_TTL_MS,
|
|
953
|
+
KEY_PREFIX,
|
|
954
|
+
MemoryKVStore,
|
|
955
|
+
MockEngine,
|
|
956
|
+
OpenEudiEngine,
|
|
957
|
+
TERMINAL_STATUSES,
|
|
958
|
+
TOKEN_VERSION,
|
|
959
|
+
VERSION,
|
|
960
|
+
createRateLimiter,
|
|
961
|
+
createTokenService,
|
|
962
|
+
createVerifierHandlers,
|
|
963
|
+
isTerminalStatus,
|
|
964
|
+
rateLimitKey,
|
|
965
|
+
sessionKey,
|
|
966
|
+
sessionToDTO,
|
|
967
|
+
tokenKey
|
|
968
|
+
};
|