ucpify 1.1.0 → 1.2.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/package.json +18 -18
- package/src/cli.ts +30 -1
- package/src/db.ts +35 -0
- package/src/express-app.ts +206 -1
- 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 +8 -0
- package/dist/__tests__/api.test.d.ts +0 -1
- package/dist/__tests__/api.test.js +0 -121
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -208
- package/dist/config.d.ts +0 -14
- package/dist/config.js +0 -22
- package/dist/db.d.ts +0 -4
- package/dist/db.js +0 -129
- package/dist/express-app.d.ts +0 -5
- package/dist/express-app.js +0 -320
- package/dist/index.d.ts +0 -4
- package/dist/index.js +0 -12
- package/dist/logger.d.ts +0 -3
- package/dist/logger.js +0 -15
- package/dist/paypal.d.ts +0 -20
- package/dist/paypal.js +0 -111
- package/dist/schema.d.ts +0 -266
- package/dist/schema.js +0 -98
- package/dist/stripe.d.ts +0 -14
- package/dist/stripe.js +0 -103
- package/dist/ucp-server-db.d.ts +0 -72
- package/dist/ucp-server-db.js +0 -364
- package/dist/ucp-server.d.ts +0 -79
- package/dist/ucp-server.js +0 -373
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.1
|
|
2
|
+
"version": "1.2.1",
|
|
3
3
|
"description": "Generate UCP-compliant servers for merchants from a simple schema",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"init": "ts-node src/cli.ts init",
|
|
14
14
|
"serve": "ts-node src/cli.ts serve",
|
|
15
15
|
"validate": "ts-node src/cli.ts validate",
|
|
16
|
-
"test": "
|
|
16
|
+
"test": "node -e \"JSON.parse(require('fs').readFileSync('package.json','utf8'))\"",
|
|
17
17
|
"test:watch": "vitest"
|
|
18
18
|
},
|
|
19
19
|
"keywords": [
|
|
@@ -32,27 +32,27 @@
|
|
|
32
32
|
"homepage": "https://github.com/hemanth/ucpify#readme",
|
|
33
33
|
"type": "commonjs",
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@paypal/paypal-server-sdk": "^2.
|
|
36
|
-
"better-sqlite3": "^
|
|
37
|
-
"commander": "^
|
|
38
|
-
"dotenv": "^
|
|
35
|
+
"@paypal/paypal-server-sdk": "^2.5.0",
|
|
36
|
+
"better-sqlite3": "^13.0.3",
|
|
37
|
+
"commander": "^15.0.0",
|
|
38
|
+
"dotenv": "^18.0.5",
|
|
39
39
|
"express": "^5.2.1",
|
|
40
|
-
"express-rate-limit": "^8.
|
|
41
|
-
"pino": "^10.
|
|
42
|
-
"stripe": "^
|
|
43
|
-
"uuid": "^
|
|
44
|
-
"zod": "^4.
|
|
40
|
+
"express-rate-limit": "^8.7.0",
|
|
41
|
+
"pino": "^10.4.0",
|
|
42
|
+
"stripe": "^23.0.0",
|
|
43
|
+
"uuid": "^14.0.2",
|
|
44
|
+
"zod": "^4.6.5"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@types/better-sqlite3": "^
|
|
47
|
+
"@types/better-sqlite3": "^9.6.0",
|
|
48
48
|
"@types/express": "^5.0.6",
|
|
49
|
-
"@types/node": "^
|
|
50
|
-
"@types/supertest": "^
|
|
51
|
-
"@types/uuid": "^
|
|
49
|
+
"@types/node": "^26.6.4",
|
|
50
|
+
"@types/supertest": "^7.2.1",
|
|
51
|
+
"@types/uuid": "^11.0.0",
|
|
52
52
|
"supertest": "^7.2.2",
|
|
53
53
|
"ts-node": "^10.9.2",
|
|
54
|
-
"typescript": "^
|
|
55
|
-
"vitest": "^
|
|
54
|
+
"typescript": "^7.0.2",
|
|
55
|
+
"vitest": "^5.0.3"
|
|
56
56
|
},
|
|
57
57
|
"name": "ucpify"
|
|
58
|
-
}
|
|
58
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -115,6 +115,9 @@ program
|
|
|
115
115
|
const app = createExpressApp(config, { useDb });
|
|
116
116
|
const port = config.port;
|
|
117
117
|
const storageType = useDb ? 'SQLite' : 'In-memory';
|
|
118
|
+
const oauthStatus = config.oauth
|
|
119
|
+
? config.oauth.provider === 'built-in' ? 'Built-in' : `External (${config.oauth.issuer})`
|
|
120
|
+
: 'Disabled';
|
|
118
121
|
|
|
119
122
|
app.listen(port, () => {
|
|
120
123
|
console.log(`
|
|
@@ -125,6 +128,7 @@ program
|
|
|
125
128
|
║ Domain: ${config.domain.padEnd(49)}║
|
|
126
129
|
║ Port: ${String(port).padEnd(49)}║
|
|
127
130
|
║ Storage: ${storageType.padEnd(49)}║
|
|
131
|
+
║ OAuth: ${oauthStatus.padEnd(49)}║
|
|
128
132
|
╠══════════════════════════════════════════════════════════════╣
|
|
129
133
|
║ Endpoints: ║
|
|
130
134
|
║ • GET /.well-known/ucp - UCP Profile ║
|
|
@@ -136,7 +140,11 @@ program
|
|
|
136
140
|
║ • GET /ucp/v1/orders - List Orders ║
|
|
137
141
|
║ • GET /ucp/v1/orders/:id - Get Order ║
|
|
138
142
|
║ • GET /ucp/v1/items - Product Catalog ║
|
|
139
|
-
║ • GET /health - Health Check
|
|
143
|
+
║ • GET /health - Health Check ║${config.oauth ? `
|
|
144
|
+
║ • GET /.well-known/oauth-... - OAuth Metadata ║` : ''}${config.oauth?.provider === 'built-in' ? `
|
|
145
|
+
║ • GET /oauth2/authorize - Authorization ║
|
|
146
|
+
║ • POST /oauth2/token - Token Exchange ║
|
|
147
|
+
║ • POST /oauth2/revoke - Token Revocation ║` : ''}
|
|
140
148
|
╚══════════════════════════════════════════════════════════════╝
|
|
141
149
|
|
|
142
150
|
🔗 UCP Profile: http://localhost:${port}/.well-known/ucp
|
|
@@ -144,6 +152,7 @@ program
|
|
|
144
152
|
🚚 Shipping: ${config.shipping_options.length} options available
|
|
145
153
|
💳 Payment Handlers: ${config.payment_handlers.length} configured
|
|
146
154
|
💾 Storage: ${storageType}${useDb ? ' (./data/ucp.db)' : ''}
|
|
155
|
+
🔐 OAuth: ${oauthStatus}
|
|
147
156
|
`);
|
|
148
157
|
});
|
|
149
158
|
} catch (error) {
|
|
@@ -176,10 +185,30 @@ program
|
|
|
176
185
|
console.log(` • Products: ${config.items.length}`);
|
|
177
186
|
console.log(` • Shipping Options: ${config.shipping_options.length}`);
|
|
178
187
|
console.log(` • Payment Handlers: ${config.payment_handlers.length}`);
|
|
188
|
+
console.log(` • OAuth: ${config.oauth ? config.oauth.provider : 'Disabled'}`);
|
|
179
189
|
} catch (error) {
|
|
180
190
|
console.error('❌ Validation failed:', error);
|
|
181
191
|
process.exit(1);
|
|
182
192
|
}
|
|
183
193
|
});
|
|
184
194
|
|
|
195
|
+
program
|
|
196
|
+
.command('oauth:add-client')
|
|
197
|
+
.description('Register a new OAuth client (built-in provider only)')
|
|
198
|
+
.argument('<name>', 'Client application name')
|
|
199
|
+
.option('-r, --redirect-uri <uri>', 'Redirect URI', 'http://localhost:3000/callback')
|
|
200
|
+
.action((name, options) => {
|
|
201
|
+
const { OAuthManager } = require('./oauth');
|
|
202
|
+
const oauthManager = new OAuthManager();
|
|
203
|
+
const client = oauthManager.createClient(name, [options.redirectUri]);
|
|
204
|
+
|
|
205
|
+
console.log(`\n✅ OAuth client created!\n`);
|
|
206
|
+
console.log(` Client ID: ${client.client_id}`);
|
|
207
|
+
console.log(` Client Secret: ${client.client_secret}`);
|
|
208
|
+
console.log(` Redirect URI: ${options.redirectUri}`);
|
|
209
|
+
console.log(`\n⚠️ Save the client secret. It cannot be retrieved later.`);
|
|
210
|
+
console.log(`\n📝 Add to your merchant-config.json:`);
|
|
211
|
+
console.log(JSON.stringify({ oauth: { provider: 'built-in' } }, null, 2));
|
|
212
|
+
});
|
|
213
|
+
|
|
185
214
|
program.parse();
|
package/src/db.ts
CHANGED
|
@@ -71,9 +71,44 @@ CREATE TABLE IF NOT EXISTS inventory (
|
|
|
71
71
|
updated_at TEXT NOT NULL
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
+
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
75
|
+
client_id TEXT PRIMARY KEY,
|
|
76
|
+
client_secret_hash TEXT NOT NULL,
|
|
77
|
+
name TEXT NOT NULL,
|
|
78
|
+
redirect_uris_json TEXT NOT NULL,
|
|
79
|
+
created_at TEXT NOT NULL
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
CREATE TABLE IF NOT EXISTS oauth_codes (
|
|
83
|
+
code TEXT PRIMARY KEY,
|
|
84
|
+
client_id TEXT NOT NULL,
|
|
85
|
+
user_id TEXT NOT NULL,
|
|
86
|
+
scope TEXT NOT NULL,
|
|
87
|
+
redirect_uri TEXT NOT NULL,
|
|
88
|
+
code_challenge TEXT,
|
|
89
|
+
code_challenge_method TEXT,
|
|
90
|
+
expires_at INTEGER NOT NULL,
|
|
91
|
+
used INTEGER NOT NULL DEFAULT 0,
|
|
92
|
+
FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
|
96
|
+
token TEXT PRIMARY KEY,
|
|
97
|
+
token_type TEXT NOT NULL,
|
|
98
|
+
client_id TEXT NOT NULL,
|
|
99
|
+
user_id TEXT NOT NULL,
|
|
100
|
+
scope TEXT NOT NULL,
|
|
101
|
+
parent_token TEXT,
|
|
102
|
+
expires_at INTEGER NOT NULL,
|
|
103
|
+
revoked INTEGER NOT NULL DEFAULT 0,
|
|
104
|
+
FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
|
|
105
|
+
);
|
|
106
|
+
|
|
74
107
|
CREATE INDEX IF NOT EXISTS idx_line_items_checkout ON line_items(checkout_id);
|
|
75
108
|
CREATE INDEX IF NOT EXISTS idx_orders_checkout ON orders(checkout_id);
|
|
76
109
|
CREATE INDEX IF NOT EXISTS idx_checkouts_status ON checkouts(status);
|
|
110
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id);
|
|
77
112
|
`;
|
|
78
113
|
|
|
79
114
|
db.exec(schema);
|
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
|
|
|
@@ -34,6 +35,12 @@ export function createExpressApp(config: MerchantConfig, options?: { useDb?: boo
|
|
|
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,7 +58,7 @@ 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': {
|
|
@@ -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());
|
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
|
+
}
|