ucpify 1.0.1 → 1.2.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/dist/cli.js +30 -2
- package/dist/db.js +35 -0
- package/dist/express-app.js +188 -11
- package/dist/oauth.d.ts +60 -0
- package/dist/oauth.js +211 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +16 -1
- package/dist/ucp-server-db.d.ts +4 -0
- package/dist/ucp-server-db.js +5 -0
- package/dist/ucp-server.d.ts +6 -0
- package/dist/ucp-server.js +64 -14
- package/package.json +8 -3
- package/src/cli.ts +32 -2
- package/src/db.ts +35 -0
- package/src/express-app.ts +231 -23
- package/src/oauth.ts +270 -0
- package/src/schema.ts +18 -0
- package/src/ucp-server-db.ts +10 -5
- package/src/ucp-server.ts +92 -44
- package/vitest.config.ts +7 -0
package/src/express-app.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { isDbHealthy, db } from './db';
|
|
|
7
7
|
import { logger } from './logger';
|
|
8
8
|
import { constructWebhookEvent, isStripeConfigured } from './stripe';
|
|
9
9
|
import { getPayPalOrder, capturePayPalOrder } from './paypal';
|
|
10
|
+
import { OAuthManager } from './oauth';
|
|
10
11
|
|
|
11
12
|
type UCPServerType = UCPServer | UCPServerDB;
|
|
12
13
|
|
|
@@ -29,11 +30,17 @@ const checkoutLimiter = rateLimit({
|
|
|
29
30
|
|
|
30
31
|
export function createExpressApp(config: MerchantConfig, options?: { useDb?: boolean }): express.Application {
|
|
31
32
|
const app = express();
|
|
32
|
-
const ucpServer: UCPServerType = options?.useDb
|
|
33
|
-
? new UCPServerDB(config)
|
|
33
|
+
const ucpServer: UCPServerType = options?.useDb
|
|
34
|
+
? new UCPServerDB(config)
|
|
34
35
|
: new UCPServer(config);
|
|
35
36
|
const useDb = options?.useDb ?? false;
|
|
36
37
|
|
|
38
|
+
// OAuth setup (built-in or external)
|
|
39
|
+
const oauthEnabled = !!config.oauth;
|
|
40
|
+
const oauthManager = oauthEnabled && config.oauth?.provider === 'built-in'
|
|
41
|
+
? new OAuthManager()
|
|
42
|
+
: null;
|
|
43
|
+
|
|
37
44
|
// Apply rate limiting to API routes
|
|
38
45
|
app.use('/ucp/v1/', apiLimiter);
|
|
39
46
|
|
|
@@ -51,14 +58,14 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
51
58
|
return;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
logger.info({
|
|
61
|
+
logger.info({ type: event.type }, 'Stripe webhook received');
|
|
55
62
|
|
|
56
63
|
switch (event.type) {
|
|
57
64
|
case 'payment_intent.succeeded': {
|
|
58
65
|
const paymentIntent = event.data.object as { id: string; metadata?: { checkout_id?: string; order_id?: string } };
|
|
59
66
|
const checkoutId = paymentIntent.metadata?.checkout_id;
|
|
60
67
|
const orderId = paymentIntent.metadata?.order_id;
|
|
61
|
-
|
|
68
|
+
|
|
62
69
|
if (checkoutId && useDb) {
|
|
63
70
|
db.prepare(`UPDATE checkouts SET payment_status = 'succeeded', updated_at = ? WHERE id = ?`)
|
|
64
71
|
.run(new Date().toISOString(), checkoutId);
|
|
@@ -73,16 +80,16 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
73
80
|
const paymentIntent = event.data.object as { id: string; metadata?: { checkout_id?: string; order_id?: string }; last_payment_error?: { message?: string } };
|
|
74
81
|
const checkoutId = paymentIntent.metadata?.checkout_id;
|
|
75
82
|
const orderId = paymentIntent.metadata?.order_id;
|
|
76
|
-
|
|
83
|
+
|
|
77
84
|
if (checkoutId && useDb) {
|
|
78
85
|
db.prepare(`UPDATE checkouts SET payment_status = 'failed', updated_at = ? WHERE id = ?`)
|
|
79
86
|
.run(new Date().toISOString(), checkoutId);
|
|
80
87
|
if (orderId) {
|
|
81
88
|
db.prepare(`UPDATE orders SET payment_status = 'failed' WHERE id = ?`).run(orderId);
|
|
82
89
|
}
|
|
83
|
-
logger.error({
|
|
90
|
+
logger.error({
|
|
84
91
|
checkoutId, orderId, paymentIntentId: paymentIntent.id,
|
|
85
|
-
error: paymentIntent.last_payment_error?.message
|
|
92
|
+
error: paymentIntent.last_payment_error?.message
|
|
86
93
|
}, 'Payment failed');
|
|
87
94
|
}
|
|
88
95
|
break;
|
|
@@ -107,7 +114,7 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
107
114
|
// Get the checkout ID from the order
|
|
108
115
|
const orderDetails = await getPayPalOrder(resource.id);
|
|
109
116
|
const checkoutId = orderDetails?.customId;
|
|
110
|
-
|
|
117
|
+
|
|
111
118
|
if (checkoutId && useDb) {
|
|
112
119
|
const now = new Date().toISOString();
|
|
113
120
|
db.prepare(`UPDATE checkouts SET payment_status = 'succeeded', updated_at = ? WHERE paypal_order_id = ?`)
|
|
@@ -158,6 +165,204 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
158
165
|
next();
|
|
159
166
|
});
|
|
160
167
|
|
|
168
|
+
// ─── OAuth 2.0 Identity Linking ─────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
// RFC 8414: OAuth Authorization Server Metadata
|
|
171
|
+
app.get('/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
|
|
172
|
+
if (!oauthEnabled) {
|
|
173
|
+
res.status(404).json({ error: 'OAuth not configured' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (config.oauth?.provider === 'external') {
|
|
178
|
+
// Proxy the merchant's external OAuth metadata
|
|
179
|
+
res.json({
|
|
180
|
+
issuer: config.oauth.issuer,
|
|
181
|
+
authorization_endpoint: config.oauth.authorization_endpoint,
|
|
182
|
+
token_endpoint: config.oauth.token_endpoint,
|
|
183
|
+
revocation_endpoint: config.oauth.revocation_endpoint,
|
|
184
|
+
jwks_uri: config.oauth.jwks_uri,
|
|
185
|
+
scopes_supported: ['ucp:scopes:checkout_session'],
|
|
186
|
+
response_types_supported: ['code'],
|
|
187
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
188
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
|
189
|
+
});
|
|
190
|
+
} else if (oauthManager) {
|
|
191
|
+
res.json(oauthManager.getServerMetadata(config.domain));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Built-in OAuth endpoints (only when provider is "built-in")
|
|
196
|
+
if (oauthManager) {
|
|
197
|
+
// Authorization endpoint - renders consent screen
|
|
198
|
+
app.get('/oauth2/authorize', (req: Request, res: Response) => {
|
|
199
|
+
const { client_id, redirect_uri, scope, state, response_type, code_challenge, code_challenge_method } = req.query;
|
|
200
|
+
|
|
201
|
+
if (response_type !== 'code') {
|
|
202
|
+
res.status(400).json({ error: 'unsupported_response_type' });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const client = oauthManager.getClient(client_id as string);
|
|
207
|
+
if (!client) {
|
|
208
|
+
res.status(400).json({ error: 'invalid_client' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!client.redirect_uris.includes(redirect_uri as string)) {
|
|
213
|
+
res.status(400).json({ error: 'invalid_redirect_uri' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Render a minimal consent page
|
|
218
|
+
res.send(`<!DOCTYPE html>
|
|
219
|
+
<html><head><title>Authorize - ${config.name}</title>
|
|
220
|
+
<style>
|
|
221
|
+
body { font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 20px; }
|
|
222
|
+
h2 { margin-bottom: 4px; }
|
|
223
|
+
p { color: #666; }
|
|
224
|
+
.scope { background: #f0f0f0; padding: 8px 12px; border-radius: 6px; font-family: monospace; }
|
|
225
|
+
button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; margin-right: 8px; }
|
|
226
|
+
.allow { background: #000; color: #fff; }
|
|
227
|
+
.deny { background: #e5e5e5; }
|
|
228
|
+
</style></head>
|
|
229
|
+
<body>
|
|
230
|
+
<h2>${config.name}</h2>
|
|
231
|
+
<p><strong>${client.name}</strong> wants to access your account.</p>
|
|
232
|
+
<div class="scope">${scope || 'ucp:scopes:checkout_session'}</div>
|
|
233
|
+
<p>This will allow the app to manage checkout sessions on your behalf.</p>
|
|
234
|
+
<form method="POST" action="/oauth2/authorize">
|
|
235
|
+
<input type="hidden" name="client_id" value="${client_id}">
|
|
236
|
+
<input type="hidden" name="redirect_uri" value="${redirect_uri}">
|
|
237
|
+
<input type="hidden" name="scope" value="${scope || 'ucp:scopes:checkout_session'}">
|
|
238
|
+
<input type="hidden" name="state" value="${state || ''}">
|
|
239
|
+
<input type="hidden" name="code_challenge" value="${code_challenge || ''}">
|
|
240
|
+
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ''}">
|
|
241
|
+
<input type="hidden" name="user_id" value="user_1">
|
|
242
|
+
<button type="submit" name="action" value="allow" class="allow">Allow</button>
|
|
243
|
+
<button type="submit" name="action" value="deny" class="deny">Deny</button>
|
|
244
|
+
</form>
|
|
245
|
+
</body></html>`);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Handle consent form submission
|
|
249
|
+
app.post('/oauth2/authorize', express.urlencoded({ extended: false }), (req: Request, res: Response) => {
|
|
250
|
+
const { client_id, redirect_uri, scope, state, action, user_id, code_challenge, code_challenge_method } = req.body;
|
|
251
|
+
|
|
252
|
+
if (action === 'deny') {
|
|
253
|
+
const url = new URL(redirect_uri);
|
|
254
|
+
url.searchParams.set('error', 'access_denied');
|
|
255
|
+
if (state) url.searchParams.set('state', state);
|
|
256
|
+
res.redirect(url.toString());
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const code = oauthManager.createAuthorizationCode(
|
|
261
|
+
client_id, user_id, scope,
|
|
262
|
+
redirect_uri,
|
|
263
|
+
code_challenge || undefined,
|
|
264
|
+
code_challenge_method || undefined,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const url = new URL(redirect_uri);
|
|
268
|
+
url.searchParams.set('code', code);
|
|
269
|
+
if (state) url.searchParams.set('state', state);
|
|
270
|
+
res.redirect(url.toString());
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Token endpoint
|
|
274
|
+
app.post('/oauth2/token', (req: Request, res: Response) => {
|
|
275
|
+
// Client auth via HTTP Basic (RFC 7617)
|
|
276
|
+
const authHeader = req.headers.authorization;
|
|
277
|
+
let clientId = req.body.client_id;
|
|
278
|
+
let clientSecret = req.body.client_secret;
|
|
279
|
+
|
|
280
|
+
if (authHeader && authHeader.startsWith('Basic ')) {
|
|
281
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
282
|
+
const [id, secret] = decoded.split(':');
|
|
283
|
+
clientId = id;
|
|
284
|
+
clientSecret = secret;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!oauthManager.authenticateClient(clientId, clientSecret)) {
|
|
288
|
+
res.status(401).json({ error: 'invalid_client' });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const grantType = req.body.grant_type;
|
|
293
|
+
|
|
294
|
+
if (grantType === 'authorization_code') {
|
|
295
|
+
const result = oauthManager.exchangeCode(
|
|
296
|
+
req.body.code,
|
|
297
|
+
clientId,
|
|
298
|
+
req.body.redirect_uri,
|
|
299
|
+
req.body.code_verifier,
|
|
300
|
+
);
|
|
301
|
+
if ('error' in result) {
|
|
302
|
+
res.status(400).json(result);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
res.json({ ...result, token_type: 'Bearer' });
|
|
306
|
+
} else if (grantType === 'refresh_token') {
|
|
307
|
+
const result = oauthManager.refreshAccessToken(req.body.refresh_token, clientId);
|
|
308
|
+
if ('error' in result) {
|
|
309
|
+
res.status(400).json(result);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
res.json({ ...result, token_type: 'Bearer' });
|
|
313
|
+
} else {
|
|
314
|
+
res.status(400).json({ error: 'unsupported_grant_type' });
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Token revocation (RFC 7009)
|
|
319
|
+
app.post('/oauth2/revoke', (req: Request, res: Response) => {
|
|
320
|
+
const authHeader = req.headers.authorization;
|
|
321
|
+
let clientId = req.body.client_id;
|
|
322
|
+
let clientSecret = req.body.client_secret;
|
|
323
|
+
|
|
324
|
+
if (authHeader && authHeader.startsWith('Basic ')) {
|
|
325
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
|
|
326
|
+
const [id, secret] = decoded.split(':');
|
|
327
|
+
clientId = id;
|
|
328
|
+
clientSecret = secret;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!oauthManager.authenticateClient(clientId, clientSecret)) {
|
|
332
|
+
res.status(401).json({ error: 'invalid_client' });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
oauthManager.revokeToken(req.body.token);
|
|
337
|
+
// RFC 7009: always respond 200, even if token was invalid
|
|
338
|
+
res.status(200).json({});
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Bearer token middleware for UCP routes (when OAuth is enabled)
|
|
343
|
+
if (oauthEnabled && oauthManager) {
|
|
344
|
+
app.use('/ucp/v1', (req: Request, res: Response, next: NextFunction) => {
|
|
345
|
+
const authHeader = req.headers.authorization;
|
|
346
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
347
|
+
res.status(401).json({ error: 'missing_token', message: 'Authorization: Bearer <token> required' });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const token = authHeader.slice(7);
|
|
352
|
+
const validToken = oauthManager.validateAccessToken(token);
|
|
353
|
+
if (!validToken) {
|
|
354
|
+
res.status(401).json({ error: 'invalid_token', message: 'Token is expired, revoked, or invalid' });
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Attach user info to request
|
|
359
|
+
(req as any).oauthUserId = validToken.user_id;
|
|
360
|
+
(req as any).oauthClientId = validToken.client_id;
|
|
361
|
+
(req as any).oauthScope = validToken.scope;
|
|
362
|
+
next();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
161
366
|
// UCP Discovery endpoint
|
|
162
367
|
app.get('/.well-known/ucp', (_req: Request, res: Response) => {
|
|
163
368
|
res.json(ucpServer.getProfile());
|
|
@@ -267,17 +472,17 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
267
472
|
const checkoutStats = db.prepare(`
|
|
268
473
|
SELECT status, COUNT(*) as count FROM checkouts GROUP BY status
|
|
269
474
|
`).all() as { status: string; count: number }[];
|
|
270
|
-
|
|
475
|
+
|
|
271
476
|
const orderStats = db.prepare(`
|
|
272
477
|
SELECT payment_status, payment_provider, COUNT(*) as count
|
|
273
478
|
FROM orders GROUP BY payment_status, payment_provider
|
|
274
479
|
`).all() as { payment_status: string; payment_provider: string; count: number }[];
|
|
275
|
-
|
|
480
|
+
|
|
276
481
|
const revenueResult = db.prepare(`
|
|
277
482
|
SELECT SUM(json_extract(totals_json, '$[?(@.type=="total")].amount')) as total
|
|
278
483
|
FROM orders WHERE payment_status = 'succeeded'
|
|
279
484
|
`).get() as { total: number | null };
|
|
280
|
-
|
|
485
|
+
|
|
281
486
|
const todayOrders = db.prepare(`
|
|
282
487
|
SELECT COUNT(*) as count FROM orders
|
|
283
488
|
WHERE date(created_at) = date('now')
|
|
@@ -302,6 +507,17 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
302
507
|
|
|
303
508
|
// Admin dashboard HTML
|
|
304
509
|
app.get('/admin', (_req: Request, res: Response) => {
|
|
510
|
+
const statsScript = useDb
|
|
511
|
+
? `fetch('/admin/stats').then(r=>r.json()).then(d=>{
|
|
512
|
+
document.getElementById('stats').innerHTML = \`
|
|
513
|
+
<div class="card"><div class="stat">\${Object.values(d.checkouts).reduce((a,b)=>a+b,0)}</div><div class="label">Total Checkouts</div></div>
|
|
514
|
+
<div class="card"><div class="stat">\${d.checkouts.completed||0}</div><div class="label">Completed</div></div>
|
|
515
|
+
<div class="card"><div class="stat">\${d.orders.today||0}</div><div class="label">Orders Today</div></div>
|
|
516
|
+
<div class="card"><div class="stat">$\${(d.revenue.total_succeeded/100).toFixed(2)}</div><div class="label">Revenue</div></div>
|
|
517
|
+
\`;
|
|
518
|
+
}).catch(e=>document.getElementById('stats').innerHTML='Error loading stats');`
|
|
519
|
+
: `document.getElementById('stats').innerHTML = '<div class="card" style="grid-column:1/-1"><div class="label">Running in <strong>in-memory mode</strong>. Stats require <code>--db</code> mode.</div><div style="margin-top:8px;font-size:0.85em;color:#999">Restart with: <code>ucpify serve config.json</code> (DB is on by default)</div></div>';`;
|
|
520
|
+
|
|
305
521
|
res.send(`<!DOCTYPE html>
|
|
306
522
|
<html><head><title>UCP Admin</title>
|
|
307
523
|
<style>
|
|
@@ -311,20 +527,12 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
311
527
|
.label { color: #666; font-size: 0.9em; }
|
|
312
528
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
|
|
313
529
|
h1 { color: #333; }
|
|
530
|
+
code { background: #e5e5e5; padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }
|
|
314
531
|
</style>
|
|
315
532
|
</head><body>
|
|
316
533
|
<h1>🛒 UCP Server Admin</h1>
|
|
317
534
|
<div class="grid" id="stats">Loading...</div>
|
|
318
|
-
<script>
|
|
319
|
-
fetch('/admin/stats').then(r=>r.json()).then(d=>{
|
|
320
|
-
document.getElementById('stats').innerHTML = \`
|
|
321
|
-
<div class="card"><div class="stat">\${Object.values(d.checkouts).reduce((a,b)=>a+b,0)}</div><div class="label">Total Checkouts</div></div>
|
|
322
|
-
<div class="card"><div class="stat">\${d.checkouts.completed||0}</div><div class="label">Completed</div></div>
|
|
323
|
-
<div class="card"><div class="stat">\${d.orders.today||0}</div><div class="label">Orders Today</div></div>
|
|
324
|
-
<div class="card"><div class="stat">$\${(d.revenue.total_succeeded/100).toFixed(2)}</div><div class="label">Revenue</div></div>
|
|
325
|
-
\`;
|
|
326
|
-
}).catch(e=>document.getElementById('stats').innerHTML='Error loading stats');
|
|
327
|
-
</script>
|
|
535
|
+
<script>${statsScript}</script>
|
|
328
536
|
</body></html>`);
|
|
329
537
|
});
|
|
330
538
|
|
|
@@ -332,8 +540,8 @@ fetch('/admin/stats').then(r=>r.json()).then(d=>{
|
|
|
332
540
|
app.get('/health', (_req: Request, res: Response) => {
|
|
333
541
|
const dbHealthy = isDbHealthy();
|
|
334
542
|
const status = dbHealthy ? 'ok' : 'degraded';
|
|
335
|
-
res.status(dbHealthy ? 200 : 503).json({
|
|
336
|
-
status,
|
|
543
|
+
res.status(dbHealthy ? 200 : 503).json({
|
|
544
|
+
status,
|
|
337
545
|
ucp_version: UCP_VERSION,
|
|
338
546
|
database: dbHealthy ? 'connected' : 'disconnected',
|
|
339
547
|
timestamp: new Date().toISOString(),
|
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { logger } from './logger';
|
|
3
|
+
|
|
4
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface OAuthClient {
|
|
7
|
+
client_id: string;
|
|
8
|
+
client_secret: string;
|
|
9
|
+
client_secret_hash: string;
|
|
10
|
+
name: string;
|
|
11
|
+
redirect_uris: string[];
|
|
12
|
+
created_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface AuthorizationCode {
|
|
16
|
+
code: string;
|
|
17
|
+
client_id: string;
|
|
18
|
+
user_id: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
redirect_uri: string;
|
|
21
|
+
code_challenge?: string;
|
|
22
|
+
code_challenge_method?: string;
|
|
23
|
+
expires_at: number;
|
|
24
|
+
used: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface OAuthToken {
|
|
28
|
+
token: string;
|
|
29
|
+
token_type: 'access' | 'refresh';
|
|
30
|
+
client_id: string;
|
|
31
|
+
user_id: string;
|
|
32
|
+
scope: string;
|
|
33
|
+
parent_token?: string;
|
|
34
|
+
expires_at: number;
|
|
35
|
+
revoked: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Constants ───────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export const UCP_SCOPES = ['ucp:scopes:checkout_session'] as const;
|
|
41
|
+
const ACCESS_TOKEN_TTL = 60 * 60; // 1 hour
|
|
42
|
+
const REFRESH_TOKEN_TTL = 60 * 60 * 24 * 30; // 30 days
|
|
43
|
+
const AUTH_CODE_TTL = 60 * 10; // 10 minutes
|
|
44
|
+
|
|
45
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function generateToken(bytes = 32): string {
|
|
48
|
+
return crypto.randomBytes(bytes).toString('hex');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hashSecret(secret: string): string {
|
|
52
|
+
return crypto.createHash('sha256').update(secret).digest('hex');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nowEpoch(): number {
|
|
56
|
+
return Math.floor(Date.now() / 1000);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── OAuth Manager ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export class OAuthManager {
|
|
62
|
+
private clients: Map<string, OAuthClient> = new Map();
|
|
63
|
+
private codes: Map<string, AuthorizationCode> = new Map();
|
|
64
|
+
private accessTokens: Map<string, OAuthToken> = new Map();
|
|
65
|
+
private refreshTokens: Map<string, OAuthToken> = new Map();
|
|
66
|
+
|
|
67
|
+
// ── Client Management ──
|
|
68
|
+
|
|
69
|
+
createClient(name: string, redirectUris: string[]): OAuthClient {
|
|
70
|
+
const clientId = `ucp_${generateToken(16)}`;
|
|
71
|
+
const clientSecret = `ucp_secret_${generateToken(24)}`;
|
|
72
|
+
const client: OAuthClient = {
|
|
73
|
+
client_id: clientId,
|
|
74
|
+
client_secret: clientSecret,
|
|
75
|
+
client_secret_hash: hashSecret(clientSecret),
|
|
76
|
+
name,
|
|
77
|
+
redirect_uris: redirectUris,
|
|
78
|
+
created_at: new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
this.clients.set(clientId, client);
|
|
81
|
+
logger.info({ clientId, name }, 'OAuth client created');
|
|
82
|
+
return client;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getClient(clientId: string): OAuthClient | undefined {
|
|
86
|
+
return this.clients.get(clientId);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
authenticateClient(clientId: string, clientSecret: string): boolean {
|
|
90
|
+
const client = this.clients.get(clientId);
|
|
91
|
+
if (!client) return false;
|
|
92
|
+
return client.client_secret_hash === hashSecret(clientSecret);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Authorization Code ──
|
|
96
|
+
|
|
97
|
+
createAuthorizationCode(
|
|
98
|
+
clientId: string,
|
|
99
|
+
userId: string,
|
|
100
|
+
scope: string,
|
|
101
|
+
redirectUri: string,
|
|
102
|
+
codeChallenge?: string,
|
|
103
|
+
codeChallengeMethod?: string,
|
|
104
|
+
): string {
|
|
105
|
+
const code = generateToken(32);
|
|
106
|
+
const authCode: AuthorizationCode = {
|
|
107
|
+
code,
|
|
108
|
+
client_id: clientId,
|
|
109
|
+
user_id: userId,
|
|
110
|
+
scope,
|
|
111
|
+
redirect_uri: redirectUri,
|
|
112
|
+
code_challenge: codeChallenge,
|
|
113
|
+
code_challenge_method: codeChallengeMethod,
|
|
114
|
+
expires_at: nowEpoch() + AUTH_CODE_TTL,
|
|
115
|
+
used: false,
|
|
116
|
+
};
|
|
117
|
+
this.codes.set(code, authCode);
|
|
118
|
+
logger.info({ clientId, userId, scope }, 'Authorization code created');
|
|
119
|
+
return code;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
exchangeCode(
|
|
123
|
+
code: string,
|
|
124
|
+
clientId: string,
|
|
125
|
+
redirectUri: string,
|
|
126
|
+
codeVerifier?: string,
|
|
127
|
+
): { access_token: string; refresh_token: string; expires_in: number; scope: string } | { error: string } {
|
|
128
|
+
const authCode = this.codes.get(code);
|
|
129
|
+
|
|
130
|
+
if (!authCode) return { error: 'invalid_grant' };
|
|
131
|
+
if (authCode.used) return { error: 'invalid_grant' };
|
|
132
|
+
if (authCode.client_id !== clientId) return { error: 'invalid_grant' };
|
|
133
|
+
if (authCode.redirect_uri !== redirectUri) return { error: 'invalid_grant' };
|
|
134
|
+
if (authCode.expires_at < nowEpoch()) return { error: 'invalid_grant' };
|
|
135
|
+
|
|
136
|
+
// PKCE verification
|
|
137
|
+
if (authCode.code_challenge) {
|
|
138
|
+
if (!codeVerifier) return { error: 'invalid_grant' };
|
|
139
|
+
const method = authCode.code_challenge_method || 'S256';
|
|
140
|
+
let computed: string;
|
|
141
|
+
if (method === 'S256') {
|
|
142
|
+
computed = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
|
143
|
+
} else {
|
|
144
|
+
computed = codeVerifier;
|
|
145
|
+
}
|
|
146
|
+
if (computed !== authCode.code_challenge) return { error: 'invalid_grant' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Mark code as used
|
|
150
|
+
authCode.used = true;
|
|
151
|
+
this.codes.set(code, authCode);
|
|
152
|
+
|
|
153
|
+
// Generate tokens
|
|
154
|
+
const accessToken = this.createAccessToken(clientId, authCode.user_id, authCode.scope);
|
|
155
|
+
const refreshToken = this.createRefreshToken(clientId, authCode.user_id, authCode.scope);
|
|
156
|
+
|
|
157
|
+
logger.info({ clientId, userId: authCode.user_id }, 'Authorization code exchanged for tokens');
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
access_token: accessToken,
|
|
161
|
+
refresh_token: refreshToken,
|
|
162
|
+
expires_in: ACCESS_TOKEN_TTL,
|
|
163
|
+
scope: authCode.scope,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Token Management ──
|
|
168
|
+
|
|
169
|
+
private createAccessToken(clientId: string, userId: string, scope: string): string {
|
|
170
|
+
const token = generateToken(32);
|
|
171
|
+
this.accessTokens.set(token, {
|
|
172
|
+
token,
|
|
173
|
+
token_type: 'access',
|
|
174
|
+
client_id: clientId,
|
|
175
|
+
user_id: userId,
|
|
176
|
+
scope,
|
|
177
|
+
expires_at: nowEpoch() + ACCESS_TOKEN_TTL,
|
|
178
|
+
revoked: false,
|
|
179
|
+
});
|
|
180
|
+
return token;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private createRefreshToken(clientId: string, userId: string, scope: string): string {
|
|
184
|
+
const token = generateToken(32);
|
|
185
|
+
this.refreshTokens.set(token, {
|
|
186
|
+
token,
|
|
187
|
+
token_type: 'refresh',
|
|
188
|
+
client_id: clientId,
|
|
189
|
+
user_id: userId,
|
|
190
|
+
scope,
|
|
191
|
+
expires_at: nowEpoch() + REFRESH_TOKEN_TTL,
|
|
192
|
+
revoked: false,
|
|
193
|
+
});
|
|
194
|
+
return token;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
refreshAccessToken(
|
|
198
|
+
refreshToken: string,
|
|
199
|
+
clientId: string,
|
|
200
|
+
): { access_token: string; expires_in: number; scope: string } | { error: string } {
|
|
201
|
+
const rt = this.refreshTokens.get(refreshToken);
|
|
202
|
+
if (!rt) return { error: 'invalid_grant' };
|
|
203
|
+
if (rt.revoked) return { error: 'invalid_grant' };
|
|
204
|
+
if (rt.client_id !== clientId) return { error: 'invalid_grant' };
|
|
205
|
+
if (rt.expires_at < nowEpoch()) return { error: 'invalid_grant' };
|
|
206
|
+
|
|
207
|
+
const accessToken = this.createAccessToken(clientId, rt.user_id, rt.scope);
|
|
208
|
+
logger.info({ clientId, userId: rt.user_id }, 'Access token refreshed');
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
access_token: accessToken,
|
|
212
|
+
expires_in: ACCESS_TOKEN_TTL,
|
|
213
|
+
scope: rt.scope,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
validateAccessToken(token: string): OAuthToken | null {
|
|
218
|
+
const t = this.accessTokens.get(token);
|
|
219
|
+
if (!t) return null;
|
|
220
|
+
if (t.revoked) return null;
|
|
221
|
+
if (t.expires_at < nowEpoch()) return null;
|
|
222
|
+
return t;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
revokeToken(token: string): boolean {
|
|
226
|
+
// Check access tokens
|
|
227
|
+
const at = this.accessTokens.get(token);
|
|
228
|
+
if (at) {
|
|
229
|
+
at.revoked = true;
|
|
230
|
+
this.accessTokens.set(token, at);
|
|
231
|
+
logger.info({ tokenType: 'access', clientId: at.client_id }, 'Token revoked');
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Check refresh tokens - also revoke all associated access tokens
|
|
236
|
+
const rt = this.refreshTokens.get(token);
|
|
237
|
+
if (rt) {
|
|
238
|
+
rt.revoked = true;
|
|
239
|
+
this.refreshTokens.set(token, rt);
|
|
240
|
+
// Revoke all access tokens for same client+user
|
|
241
|
+
for (const [key, accessToken] of this.accessTokens) {
|
|
242
|
+
if (accessToken.client_id === rt.client_id && accessToken.user_id === rt.user_id) {
|
|
243
|
+
accessToken.revoked = true;
|
|
244
|
+
this.accessTokens.set(key, accessToken);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
logger.info({ tokenType: 'refresh', clientId: rt.client_id }, 'Refresh token and associated access tokens revoked');
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Metadata ──
|
|
255
|
+
|
|
256
|
+
getServerMetadata(issuer: string): Record<string, unknown> {
|
|
257
|
+
return {
|
|
258
|
+
issuer,
|
|
259
|
+
authorization_endpoint: `${issuer}/oauth2/authorize`,
|
|
260
|
+
token_endpoint: `${issuer}/oauth2/token`,
|
|
261
|
+
revocation_endpoint: `${issuer}/oauth2/revoke`,
|
|
262
|
+
scopes_supported: [...UCP_SCOPES],
|
|
263
|
+
response_types_supported: ['code'],
|
|
264
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
265
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic'],
|
|
266
|
+
code_challenge_methods_supported: ['S256'],
|
|
267
|
+
service_documentation: `${issuer}/docs/oauth2`,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -30,6 +30,23 @@ export const PaymentHandlerSchema = z.object({
|
|
|
30
30
|
config: z.record(z.string(), z.unknown()).optional(),
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
// OAuth Configuration Schema
|
|
34
|
+
export const OAuthConfigSchema = z.discriminatedUnion('provider', [
|
|
35
|
+
z.object({
|
|
36
|
+
provider: z.literal('built-in'),
|
|
37
|
+
}),
|
|
38
|
+
z.object({
|
|
39
|
+
provider: z.literal('external'),
|
|
40
|
+
issuer: z.string().url(),
|
|
41
|
+
authorization_endpoint: z.string().url(),
|
|
42
|
+
token_endpoint: z.string().url(),
|
|
43
|
+
revocation_endpoint: z.string().url().optional(),
|
|
44
|
+
jwks_uri: z.string().url().optional(),
|
|
45
|
+
}),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
export type OAuthConfig = z.infer<typeof OAuthConfigSchema>;
|
|
49
|
+
|
|
33
50
|
// Merchant Configuration Schema
|
|
34
51
|
export const MerchantConfigSchema = z.object({
|
|
35
52
|
name: z.string(),
|
|
@@ -42,6 +59,7 @@ export const MerchantConfigSchema = z.object({
|
|
|
42
59
|
payment_handlers: z.array(PaymentHandlerSchema).default([]),
|
|
43
60
|
tax_rate: z.number().min(0).max(1).default(0), // e.g., 0.08 for 8%
|
|
44
61
|
port: z.number().default(3000),
|
|
62
|
+
oauth: OAuthConfigSchema.optional(),
|
|
45
63
|
});
|
|
46
64
|
|
|
47
65
|
export type Item = z.infer<typeof ItemSchema>;
|