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 CHANGED
@@ -40,10 +40,11 @@ const path = __importStar(require("path"));
40
40
  const schema_1 = require("./schema");
41
41
  const express_app_1 = require("./express-app");
42
42
  const program = new commander_1.Command();
43
+ const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
43
44
  program
44
45
  .name('ucpify')
45
46
  .description('Generate and run a UCP-compliant server for merchants')
46
- .version('1.0.0');
47
+ .version(pkg.version);
47
48
  program
48
49
  .command('init')
49
50
  .description('Create a sample merchant configuration file')
@@ -140,6 +141,9 @@ program
140
141
  const app = (0, express_app_1.createExpressApp)(config, { useDb });
141
142
  const port = config.port;
142
143
  const storageType = useDb ? 'SQLite' : 'In-memory';
144
+ const oauthStatus = config.oauth
145
+ ? config.oauth.provider === 'built-in' ? 'Built-in' : `External (${config.oauth.issuer})`
146
+ : 'Disabled';
143
147
  app.listen(port, () => {
144
148
  console.log(`
145
149
  ╔══════════════════════════════════════════════════════════════╗
@@ -149,6 +153,7 @@ program
149
153
  ║ Domain: ${config.domain.padEnd(49)}║
150
154
  ║ Port: ${String(port).padEnd(49)}║
151
155
  ║ Storage: ${storageType.padEnd(49)}║
156
+ ║ OAuth: ${oauthStatus.padEnd(49)}║
152
157
  ╠══════════════════════════════════════════════════════════════╣
153
158
  ║ Endpoints: ║
154
159
  ║ • GET /.well-known/ucp - UCP Profile ║
@@ -160,7 +165,11 @@ program
160
165
  ║ • GET /ucp/v1/orders - List Orders ║
161
166
  ║ • GET /ucp/v1/orders/:id - Get Order ║
162
167
  ║ • GET /ucp/v1/items - Product Catalog ║
163
- ║ • GET /health - Health Check ║
168
+ ║ • GET /health - Health Check ║${config.oauth ? `
169
+ ║ • GET /.well-known/oauth-... - OAuth Metadata ║` : ''}${config.oauth?.provider === 'built-in' ? `
170
+ ║ • GET /oauth2/authorize - Authorization ║
171
+ ║ • POST /oauth2/token - Token Exchange ║
172
+ ║ • POST /oauth2/revoke - Token Revocation ║` : ''}
164
173
  ╚══════════════════════════════════════════════════════════════╝
165
174
 
166
175
  🔗 UCP Profile: http://localhost:${port}/.well-known/ucp
@@ -168,6 +177,7 @@ program
168
177
  🚚 Shipping: ${config.shipping_options.length} options available
169
178
  💳 Payment Handlers: ${config.payment_handlers.length} configured
170
179
  💾 Storage: ${storageType}${useDb ? ' (./data/ucp.db)' : ''}
180
+ 🔐 OAuth: ${oauthStatus}
171
181
  `);
172
182
  });
173
183
  }
@@ -198,10 +208,28 @@ program
198
208
  console.log(` • Products: ${config.items.length}`);
199
209
  console.log(` • Shipping Options: ${config.shipping_options.length}`);
200
210
  console.log(` • Payment Handlers: ${config.payment_handlers.length}`);
211
+ console.log(` • OAuth: ${config.oauth ? config.oauth.provider : 'Disabled'}`);
201
212
  }
202
213
  catch (error) {
203
214
  console.error('❌ Validation failed:', error);
204
215
  process.exit(1);
205
216
  }
206
217
  });
218
+ program
219
+ .command('oauth:add-client')
220
+ .description('Register a new OAuth client (built-in provider only)')
221
+ .argument('<name>', 'Client application name')
222
+ .option('-r, --redirect-uri <uri>', 'Redirect URI', 'http://localhost:3000/callback')
223
+ .action((name, options) => {
224
+ const { OAuthManager } = require('./oauth');
225
+ const oauthManager = new OAuthManager();
226
+ const client = oauthManager.createClient(name, [options.redirectUri]);
227
+ console.log(`\n✅ OAuth client created!\n`);
228
+ console.log(` Client ID: ${client.client_id}`);
229
+ console.log(` Client Secret: ${client.client_secret}`);
230
+ console.log(` Redirect URI: ${options.redirectUri}`);
231
+ console.log(`\n⚠️ Save the client secret. It cannot be retrieved later.`);
232
+ console.log(`\n📝 Add to your merchant-config.json:`);
233
+ console.log(JSON.stringify({ oauth: { provider: 'built-in' } }, null, 2));
234
+ });
207
235
  program.parse();
package/dist/db.js CHANGED
@@ -108,9 +108,44 @@ CREATE TABLE IF NOT EXISTS inventory (
108
108
  updated_at TEXT NOT NULL
109
109
  );
110
110
 
111
+ CREATE TABLE IF NOT EXISTS oauth_clients (
112
+ client_id TEXT PRIMARY KEY,
113
+ client_secret_hash TEXT NOT NULL,
114
+ name TEXT NOT NULL,
115
+ redirect_uris_json TEXT NOT NULL,
116
+ created_at TEXT NOT NULL
117
+ );
118
+
119
+ CREATE TABLE IF NOT EXISTS oauth_codes (
120
+ code TEXT PRIMARY KEY,
121
+ client_id TEXT NOT NULL,
122
+ user_id TEXT NOT NULL,
123
+ scope TEXT NOT NULL,
124
+ redirect_uri TEXT NOT NULL,
125
+ code_challenge TEXT,
126
+ code_challenge_method TEXT,
127
+ expires_at INTEGER NOT NULL,
128
+ used INTEGER NOT NULL DEFAULT 0,
129
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
130
+ );
131
+
132
+ CREATE TABLE IF NOT EXISTS oauth_tokens (
133
+ token TEXT PRIMARY KEY,
134
+ token_type TEXT NOT NULL,
135
+ client_id TEXT NOT NULL,
136
+ user_id TEXT NOT NULL,
137
+ scope TEXT NOT NULL,
138
+ parent_token TEXT,
139
+ expires_at INTEGER NOT NULL,
140
+ revoked INTEGER NOT NULL DEFAULT 0,
141
+ FOREIGN KEY (client_id) REFERENCES oauth_clients(client_id)
142
+ );
143
+
111
144
  CREATE INDEX IF NOT EXISTS idx_line_items_checkout ON line_items(checkout_id);
112
145
  CREATE INDEX IF NOT EXISTS idx_orders_checkout ON orders(checkout_id);
113
146
  CREATE INDEX IF NOT EXISTS idx_checkouts_status ON checkouts(status);
147
+ CREATE INDEX IF NOT EXISTS idx_oauth_tokens_client ON oauth_tokens(client_id);
148
+ CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id);
114
149
  `;
115
150
  exports.db.exec(schema);
116
151
  logger_1.logger.info({ path: config_1.config.databasePath }, 'Database initialized');
@@ -13,6 +13,7 @@ const db_1 = require("./db");
13
13
  const logger_1 = require("./logger");
14
14
  const stripe_1 = require("./stripe");
15
15
  const paypal_1 = require("./paypal");
16
+ const oauth_1 = require("./oauth");
16
17
  // Rate limiters
17
18
  const apiLimiter = (0, express_rate_limit_1.default)({
18
19
  windowMs: 60 * 1000, // 1 minute
@@ -34,6 +35,11 @@ function createExpressApp(config, options) {
34
35
  ? new ucp_server_db_1.UCPServerDB(config)
35
36
  : new ucp_server_1.UCPServer(config);
36
37
  const useDb = options?.useDb ?? false;
38
+ // OAuth setup (built-in or external)
39
+ const oauthEnabled = !!config.oauth;
40
+ const oauthManager = oauthEnabled && config.oauth?.provider === 'built-in'
41
+ ? new oauth_1.OAuthManager()
42
+ : null;
37
43
  // Apply rate limiting to API routes
38
44
  app.use('/ucp/v1/', apiLimiter);
39
45
  // Stripe webhook needs raw body - must come before json parser
@@ -48,7 +54,7 @@ function createExpressApp(config, options) {
48
54
  res.status(400).json({ error: 'Invalid webhook signature' });
49
55
  return;
50
56
  }
51
- logger_1.logger.info({ eventType: event.type, eventId: event.id }, 'Stripe webhook received');
57
+ logger_1.logger.info({ type: event.type }, 'Stripe webhook received');
52
58
  switch (event.type) {
53
59
  case 'payment_intent.succeeded': {
54
60
  const paymentIntent = event.data.object;
@@ -144,6 +150,175 @@ function createExpressApp(config, options) {
144
150
  res.header('Access-Control-Allow-Headers', 'Content-Type, UCP-Agent, Authorization');
145
151
  next();
146
152
  });
153
+ // ─── OAuth 2.0 Identity Linking ─────────────────────────────────────
154
+ // RFC 8414: OAuth Authorization Server Metadata
155
+ app.get('/.well-known/oauth-authorization-server', (_req, res) => {
156
+ if (!oauthEnabled) {
157
+ res.status(404).json({ error: 'OAuth not configured' });
158
+ return;
159
+ }
160
+ if (config.oauth?.provider === 'external') {
161
+ // Proxy the merchant's external OAuth metadata
162
+ res.json({
163
+ issuer: config.oauth.issuer,
164
+ authorization_endpoint: config.oauth.authorization_endpoint,
165
+ token_endpoint: config.oauth.token_endpoint,
166
+ revocation_endpoint: config.oauth.revocation_endpoint,
167
+ jwks_uri: config.oauth.jwks_uri,
168
+ scopes_supported: ['ucp:scopes:checkout_session'],
169
+ response_types_supported: ['code'],
170
+ grant_types_supported: ['authorization_code', 'refresh_token'],
171
+ token_endpoint_auth_methods_supported: ['client_secret_basic'],
172
+ });
173
+ }
174
+ else if (oauthManager) {
175
+ res.json(oauthManager.getServerMetadata(config.domain));
176
+ }
177
+ });
178
+ // Built-in OAuth endpoints (only when provider is "built-in")
179
+ if (oauthManager) {
180
+ // Authorization endpoint - renders consent screen
181
+ app.get('/oauth2/authorize', (req, res) => {
182
+ const { client_id, redirect_uri, scope, state, response_type, code_challenge, code_challenge_method } = req.query;
183
+ if (response_type !== 'code') {
184
+ res.status(400).json({ error: 'unsupported_response_type' });
185
+ return;
186
+ }
187
+ const client = oauthManager.getClient(client_id);
188
+ if (!client) {
189
+ res.status(400).json({ error: 'invalid_client' });
190
+ return;
191
+ }
192
+ if (!client.redirect_uris.includes(redirect_uri)) {
193
+ res.status(400).json({ error: 'invalid_redirect_uri' });
194
+ return;
195
+ }
196
+ // Render a minimal consent page
197
+ res.send(`<!DOCTYPE html>
198
+ <html><head><title>Authorize - ${config.name}</title>
199
+ <style>
200
+ body { font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 20px; }
201
+ h2 { margin-bottom: 4px; }
202
+ p { color: #666; }
203
+ .scope { background: #f0f0f0; padding: 8px 12px; border-radius: 6px; font-family: monospace; }
204
+ button { padding: 10px 24px; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; margin-right: 8px; }
205
+ .allow { background: #000; color: #fff; }
206
+ .deny { background: #e5e5e5; }
207
+ </style></head>
208
+ <body>
209
+ <h2>${config.name}</h2>
210
+ <p><strong>${client.name}</strong> wants to access your account.</p>
211
+ <div class="scope">${scope || 'ucp:scopes:checkout_session'}</div>
212
+ <p>This will allow the app to manage checkout sessions on your behalf.</p>
213
+ <form method="POST" action="/oauth2/authorize">
214
+ <input type="hidden" name="client_id" value="${client_id}">
215
+ <input type="hidden" name="redirect_uri" value="${redirect_uri}">
216
+ <input type="hidden" name="scope" value="${scope || 'ucp:scopes:checkout_session'}">
217
+ <input type="hidden" name="state" value="${state || ''}">
218
+ <input type="hidden" name="code_challenge" value="${code_challenge || ''}">
219
+ <input type="hidden" name="code_challenge_method" value="${code_challenge_method || ''}">
220
+ <input type="hidden" name="user_id" value="user_1">
221
+ <button type="submit" name="action" value="allow" class="allow">Allow</button>
222
+ <button type="submit" name="action" value="deny" class="deny">Deny</button>
223
+ </form>
224
+ </body></html>`);
225
+ });
226
+ // Handle consent form submission
227
+ app.post('/oauth2/authorize', express_1.default.urlencoded({ extended: false }), (req, res) => {
228
+ const { client_id, redirect_uri, scope, state, action, user_id, code_challenge, code_challenge_method } = req.body;
229
+ if (action === 'deny') {
230
+ const url = new URL(redirect_uri);
231
+ url.searchParams.set('error', 'access_denied');
232
+ if (state)
233
+ url.searchParams.set('state', state);
234
+ res.redirect(url.toString());
235
+ return;
236
+ }
237
+ const code = oauthManager.createAuthorizationCode(client_id, user_id, scope, redirect_uri, code_challenge || undefined, code_challenge_method || undefined);
238
+ const url = new URL(redirect_uri);
239
+ url.searchParams.set('code', code);
240
+ if (state)
241
+ url.searchParams.set('state', state);
242
+ res.redirect(url.toString());
243
+ });
244
+ // Token endpoint
245
+ app.post('/oauth2/token', (req, res) => {
246
+ // Client auth via HTTP Basic (RFC 7617)
247
+ const authHeader = req.headers.authorization;
248
+ let clientId = req.body.client_id;
249
+ let clientSecret = req.body.client_secret;
250
+ if (authHeader && authHeader.startsWith('Basic ')) {
251
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
252
+ const [id, secret] = decoded.split(':');
253
+ clientId = id;
254
+ clientSecret = secret;
255
+ }
256
+ if (!oauthManager.authenticateClient(clientId, clientSecret)) {
257
+ res.status(401).json({ error: 'invalid_client' });
258
+ return;
259
+ }
260
+ const grantType = req.body.grant_type;
261
+ if (grantType === 'authorization_code') {
262
+ const result = oauthManager.exchangeCode(req.body.code, clientId, req.body.redirect_uri, req.body.code_verifier);
263
+ if ('error' in result) {
264
+ res.status(400).json(result);
265
+ return;
266
+ }
267
+ res.json({ ...result, token_type: 'Bearer' });
268
+ }
269
+ else if (grantType === 'refresh_token') {
270
+ const result = oauthManager.refreshAccessToken(req.body.refresh_token, clientId);
271
+ if ('error' in result) {
272
+ res.status(400).json(result);
273
+ return;
274
+ }
275
+ res.json({ ...result, token_type: 'Bearer' });
276
+ }
277
+ else {
278
+ res.status(400).json({ error: 'unsupported_grant_type' });
279
+ }
280
+ });
281
+ // Token revocation (RFC 7009)
282
+ app.post('/oauth2/revoke', (req, res) => {
283
+ const authHeader = req.headers.authorization;
284
+ let clientId = req.body.client_id;
285
+ let clientSecret = req.body.client_secret;
286
+ if (authHeader && authHeader.startsWith('Basic ')) {
287
+ const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
288
+ const [id, secret] = decoded.split(':');
289
+ clientId = id;
290
+ clientSecret = secret;
291
+ }
292
+ if (!oauthManager.authenticateClient(clientId, clientSecret)) {
293
+ res.status(401).json({ error: 'invalid_client' });
294
+ return;
295
+ }
296
+ oauthManager.revokeToken(req.body.token);
297
+ // RFC 7009: always respond 200, even if token was invalid
298
+ res.status(200).json({});
299
+ });
300
+ }
301
+ // Bearer token middleware for UCP routes (when OAuth is enabled)
302
+ if (oauthEnabled && oauthManager) {
303
+ app.use('/ucp/v1', (req, res, next) => {
304
+ const authHeader = req.headers.authorization;
305
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
306
+ res.status(401).json({ error: 'missing_token', message: 'Authorization: Bearer <token> required' });
307
+ return;
308
+ }
309
+ const token = authHeader.slice(7);
310
+ const validToken = oauthManager.validateAccessToken(token);
311
+ if (!validToken) {
312
+ res.status(401).json({ error: 'invalid_token', message: 'Token is expired, revoked, or invalid' });
313
+ return;
314
+ }
315
+ // Attach user info to request
316
+ req.oauthUserId = validToken.user_id;
317
+ req.oauthClientId = validToken.client_id;
318
+ req.oauthScope = validToken.scope;
319
+ next();
320
+ });
321
+ }
147
322
  // UCP Discovery endpoint
148
323
  app.get('/.well-known/ucp', (_req, res) => {
149
324
  res.json(ucpServer.getProfile());
@@ -278,6 +453,16 @@ function createExpressApp(config, options) {
278
453
  });
279
454
  // Admin dashboard HTML
280
455
  app.get('/admin', (_req, res) => {
456
+ const statsScript = useDb
457
+ ? `fetch('/admin/stats').then(r=>r.json()).then(d=>{
458
+ document.getElementById('stats').innerHTML = \`
459
+ <div class="card"><div class="stat">\${Object.values(d.checkouts).reduce((a,b)=>a+b,0)}</div><div class="label">Total Checkouts</div></div>
460
+ <div class="card"><div class="stat">\${d.checkouts.completed||0}</div><div class="label">Completed</div></div>
461
+ <div class="card"><div class="stat">\${d.orders.today||0}</div><div class="label">Orders Today</div></div>
462
+ <div class="card"><div class="stat">$\${(d.revenue.total_succeeded/100).toFixed(2)}</div><div class="label">Revenue</div></div>
463
+ \`;
464
+ }).catch(e=>document.getElementById('stats').innerHTML='Error loading stats');`
465
+ : `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>';`;
281
466
  res.send(`<!DOCTYPE html>
282
467
  <html><head><title>UCP Admin</title>
283
468
  <style>
@@ -287,20 +472,12 @@ function createExpressApp(config, options) {
287
472
  .label { color: #666; font-size: 0.9em; }
288
473
  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
289
474
  h1 { color: #333; }
475
+ code { background: #e5e5e5; padding: 2px 6px; border-radius: 3px; font-size: 0.85em; }
290
476
  </style>
291
477
  </head><body>
292
478
  <h1>🛒 UCP Server Admin</h1>
293
479
  <div class="grid" id="stats">Loading...</div>
294
- <script>
295
- fetch('/admin/stats').then(r=>r.json()).then(d=>{
296
- document.getElementById('stats').innerHTML = \`
297
- <div class="card"><div class="stat">\${Object.values(d.checkouts).reduce((a,b)=>a+b,0)}</div><div class="label">Total Checkouts</div></div>
298
- <div class="card"><div class="stat">\${d.checkouts.completed||0}</div><div class="label">Completed</div></div>
299
- <div class="card"><div class="stat">\${d.orders.today||0}</div><div class="label">Orders Today</div></div>
300
- <div class="card"><div class="stat">$\${(d.revenue.total_succeeded/100).toFixed(2)}</div><div class="label">Revenue</div></div>
301
- \`;
302
- }).catch(e=>document.getElementById('stats').innerHTML='Error loading stats');
303
- </script>
480
+ <script>${statsScript}</script>
304
481
  </body></html>`);
305
482
  });
306
483
  // Health check
@@ -0,0 +1,60 @@
1
+ export interface OAuthClient {
2
+ client_id: string;
3
+ client_secret: string;
4
+ client_secret_hash: string;
5
+ name: string;
6
+ redirect_uris: string[];
7
+ created_at: string;
8
+ }
9
+ export interface AuthorizationCode {
10
+ code: string;
11
+ client_id: string;
12
+ user_id: string;
13
+ scope: string;
14
+ redirect_uri: string;
15
+ code_challenge?: string;
16
+ code_challenge_method?: string;
17
+ expires_at: number;
18
+ used: boolean;
19
+ }
20
+ export interface OAuthToken {
21
+ token: string;
22
+ token_type: 'access' | 'refresh';
23
+ client_id: string;
24
+ user_id: string;
25
+ scope: string;
26
+ parent_token?: string;
27
+ expires_at: number;
28
+ revoked: boolean;
29
+ }
30
+ export declare const UCP_SCOPES: readonly ["ucp:scopes:checkout_session"];
31
+ export declare class OAuthManager {
32
+ private clients;
33
+ private codes;
34
+ private accessTokens;
35
+ private refreshTokens;
36
+ createClient(name: string, redirectUris: string[]): OAuthClient;
37
+ getClient(clientId: string): OAuthClient | undefined;
38
+ authenticateClient(clientId: string, clientSecret: string): boolean;
39
+ createAuthorizationCode(clientId: string, userId: string, scope: string, redirectUri: string, codeChallenge?: string, codeChallengeMethod?: string): string;
40
+ exchangeCode(code: string, clientId: string, redirectUri: string, codeVerifier?: string): {
41
+ access_token: string;
42
+ refresh_token: string;
43
+ expires_in: number;
44
+ scope: string;
45
+ } | {
46
+ error: string;
47
+ };
48
+ private createAccessToken;
49
+ private createRefreshToken;
50
+ refreshAccessToken(refreshToken: string, clientId: string): {
51
+ access_token: string;
52
+ expires_in: number;
53
+ scope: string;
54
+ } | {
55
+ error: string;
56
+ };
57
+ validateAccessToken(token: string): OAuthToken | null;
58
+ revokeToken(token: string): boolean;
59
+ getServerMetadata(issuer: string): Record<string, unknown>;
60
+ }
package/dist/oauth.js ADDED
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.OAuthManager = exports.UCP_SCOPES = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const logger_1 = require("./logger");
9
+ // ─── Constants ───────────────────────────────────────────────────────
10
+ exports.UCP_SCOPES = ['ucp:scopes:checkout_session'];
11
+ const ACCESS_TOKEN_TTL = 60 * 60; // 1 hour
12
+ const REFRESH_TOKEN_TTL = 60 * 60 * 24 * 30; // 30 days
13
+ const AUTH_CODE_TTL = 60 * 10; // 10 minutes
14
+ // ─── Helpers ─────────────────────────────────────────────────────────
15
+ function generateToken(bytes = 32) {
16
+ return crypto_1.default.randomBytes(bytes).toString('hex');
17
+ }
18
+ function hashSecret(secret) {
19
+ return crypto_1.default.createHash('sha256').update(secret).digest('hex');
20
+ }
21
+ function nowEpoch() {
22
+ return Math.floor(Date.now() / 1000);
23
+ }
24
+ // ─── OAuth Manager ───────────────────────────────────────────────────
25
+ class OAuthManager {
26
+ clients = new Map();
27
+ codes = new Map();
28
+ accessTokens = new Map();
29
+ refreshTokens = new Map();
30
+ // ── Client Management ──
31
+ createClient(name, redirectUris) {
32
+ const clientId = `ucp_${generateToken(16)}`;
33
+ const clientSecret = `ucp_secret_${generateToken(24)}`;
34
+ const client = {
35
+ client_id: clientId,
36
+ client_secret: clientSecret,
37
+ client_secret_hash: hashSecret(clientSecret),
38
+ name,
39
+ redirect_uris: redirectUris,
40
+ created_at: new Date().toISOString(),
41
+ };
42
+ this.clients.set(clientId, client);
43
+ logger_1.logger.info({ clientId, name }, 'OAuth client created');
44
+ return client;
45
+ }
46
+ getClient(clientId) {
47
+ return this.clients.get(clientId);
48
+ }
49
+ authenticateClient(clientId, clientSecret) {
50
+ const client = this.clients.get(clientId);
51
+ if (!client)
52
+ return false;
53
+ return client.client_secret_hash === hashSecret(clientSecret);
54
+ }
55
+ // ── Authorization Code ──
56
+ createAuthorizationCode(clientId, userId, scope, redirectUri, codeChallenge, codeChallengeMethod) {
57
+ const code = generateToken(32);
58
+ const authCode = {
59
+ code,
60
+ client_id: clientId,
61
+ user_id: userId,
62
+ scope,
63
+ redirect_uri: redirectUri,
64
+ code_challenge: codeChallenge,
65
+ code_challenge_method: codeChallengeMethod,
66
+ expires_at: nowEpoch() + AUTH_CODE_TTL,
67
+ used: false,
68
+ };
69
+ this.codes.set(code, authCode);
70
+ logger_1.logger.info({ clientId, userId, scope }, 'Authorization code created');
71
+ return code;
72
+ }
73
+ exchangeCode(code, clientId, redirectUri, codeVerifier) {
74
+ const authCode = this.codes.get(code);
75
+ if (!authCode)
76
+ return { error: 'invalid_grant' };
77
+ if (authCode.used)
78
+ return { error: 'invalid_grant' };
79
+ if (authCode.client_id !== clientId)
80
+ return { error: 'invalid_grant' };
81
+ if (authCode.redirect_uri !== redirectUri)
82
+ return { error: 'invalid_grant' };
83
+ if (authCode.expires_at < nowEpoch())
84
+ return { error: 'invalid_grant' };
85
+ // PKCE verification
86
+ if (authCode.code_challenge) {
87
+ if (!codeVerifier)
88
+ return { error: 'invalid_grant' };
89
+ const method = authCode.code_challenge_method || 'S256';
90
+ let computed;
91
+ if (method === 'S256') {
92
+ computed = crypto_1.default.createHash('sha256').update(codeVerifier).digest('base64url');
93
+ }
94
+ else {
95
+ computed = codeVerifier;
96
+ }
97
+ if (computed !== authCode.code_challenge)
98
+ return { error: 'invalid_grant' };
99
+ }
100
+ // Mark code as used
101
+ authCode.used = true;
102
+ this.codes.set(code, authCode);
103
+ // Generate tokens
104
+ const accessToken = this.createAccessToken(clientId, authCode.user_id, authCode.scope);
105
+ const refreshToken = this.createRefreshToken(clientId, authCode.user_id, authCode.scope);
106
+ logger_1.logger.info({ clientId, userId: authCode.user_id }, 'Authorization code exchanged for tokens');
107
+ return {
108
+ access_token: accessToken,
109
+ refresh_token: refreshToken,
110
+ expires_in: ACCESS_TOKEN_TTL,
111
+ scope: authCode.scope,
112
+ };
113
+ }
114
+ // ── Token Management ──
115
+ createAccessToken(clientId, userId, scope) {
116
+ const token = generateToken(32);
117
+ this.accessTokens.set(token, {
118
+ token,
119
+ token_type: 'access',
120
+ client_id: clientId,
121
+ user_id: userId,
122
+ scope,
123
+ expires_at: nowEpoch() + ACCESS_TOKEN_TTL,
124
+ revoked: false,
125
+ });
126
+ return token;
127
+ }
128
+ createRefreshToken(clientId, userId, scope) {
129
+ const token = generateToken(32);
130
+ this.refreshTokens.set(token, {
131
+ token,
132
+ token_type: 'refresh',
133
+ client_id: clientId,
134
+ user_id: userId,
135
+ scope,
136
+ expires_at: nowEpoch() + REFRESH_TOKEN_TTL,
137
+ revoked: false,
138
+ });
139
+ return token;
140
+ }
141
+ refreshAccessToken(refreshToken, clientId) {
142
+ const rt = this.refreshTokens.get(refreshToken);
143
+ if (!rt)
144
+ return { error: 'invalid_grant' };
145
+ if (rt.revoked)
146
+ return { error: 'invalid_grant' };
147
+ if (rt.client_id !== clientId)
148
+ return { error: 'invalid_grant' };
149
+ if (rt.expires_at < nowEpoch())
150
+ return { error: 'invalid_grant' };
151
+ const accessToken = this.createAccessToken(clientId, rt.user_id, rt.scope);
152
+ logger_1.logger.info({ clientId, userId: rt.user_id }, 'Access token refreshed');
153
+ return {
154
+ access_token: accessToken,
155
+ expires_in: ACCESS_TOKEN_TTL,
156
+ scope: rt.scope,
157
+ };
158
+ }
159
+ validateAccessToken(token) {
160
+ const t = this.accessTokens.get(token);
161
+ if (!t)
162
+ return null;
163
+ if (t.revoked)
164
+ return null;
165
+ if (t.expires_at < nowEpoch())
166
+ return null;
167
+ return t;
168
+ }
169
+ revokeToken(token) {
170
+ // Check access tokens
171
+ const at = this.accessTokens.get(token);
172
+ if (at) {
173
+ at.revoked = true;
174
+ this.accessTokens.set(token, at);
175
+ logger_1.logger.info({ tokenType: 'access', clientId: at.client_id }, 'Token revoked');
176
+ return true;
177
+ }
178
+ // Check refresh tokens - also revoke all associated access tokens
179
+ const rt = this.refreshTokens.get(token);
180
+ if (rt) {
181
+ rt.revoked = true;
182
+ this.refreshTokens.set(token, rt);
183
+ // Revoke all access tokens for same client+user
184
+ for (const [key, accessToken] of this.accessTokens) {
185
+ if (accessToken.client_id === rt.client_id && accessToken.user_id === rt.user_id) {
186
+ accessToken.revoked = true;
187
+ this.accessTokens.set(key, accessToken);
188
+ }
189
+ }
190
+ logger_1.logger.info({ tokenType: 'refresh', clientId: rt.client_id }, 'Refresh token and associated access tokens revoked');
191
+ return true;
192
+ }
193
+ return false;
194
+ }
195
+ // ── Metadata ──
196
+ getServerMetadata(issuer) {
197
+ return {
198
+ issuer,
199
+ authorization_endpoint: `${issuer}/oauth2/authorize`,
200
+ token_endpoint: `${issuer}/oauth2/token`,
201
+ revocation_endpoint: `${issuer}/oauth2/revoke`,
202
+ scopes_supported: [...exports.UCP_SCOPES],
203
+ response_types_supported: ['code'],
204
+ grant_types_supported: ['authorization_code', 'refresh_token'],
205
+ token_endpoint_auth_methods_supported: ['client_secret_basic'],
206
+ code_challenge_methods_supported: ['S256'],
207
+ service_documentation: `${issuer}/docs/oauth2`,
208
+ };
209
+ }
210
+ }
211
+ exports.OAuthManager = OAuthManager;
package/dist/schema.d.ts CHANGED
@@ -21,6 +21,17 @@ export declare const PaymentHandlerSchema: z.ZodObject<{
21
21
  id: z.ZodString;
22
22
  config: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
23
23
  }, z.core.$strip>;
24
+ export declare const OAuthConfigSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
25
+ provider: z.ZodLiteral<"built-in">;
26
+ }, z.core.$strip>, z.ZodObject<{
27
+ provider: z.ZodLiteral<"external">;
28
+ issuer: z.ZodString;
29
+ authorization_endpoint: z.ZodString;
30
+ token_endpoint: z.ZodString;
31
+ revocation_endpoint: z.ZodOptional<z.ZodString>;
32
+ jwks_uri: z.ZodOptional<z.ZodString>;
33
+ }, z.core.$strip>], "provider">;
34
+ export type OAuthConfig = z.infer<typeof OAuthConfigSchema>;
24
35
  export declare const MerchantConfigSchema: z.ZodObject<{
25
36
  name: z.ZodString;
26
37
  domain: z.ZodString;
@@ -50,6 +61,16 @@ export declare const MerchantConfigSchema: z.ZodObject<{
50
61
  }, z.core.$strip>>>;
51
62
  tax_rate: z.ZodDefault<z.ZodNumber>;
52
63
  port: z.ZodDefault<z.ZodNumber>;
64
+ oauth: z.ZodOptional<z.ZodDiscriminatedUnion<[z.ZodObject<{
65
+ provider: z.ZodLiteral<"built-in">;
66
+ }, z.core.$strip>, z.ZodObject<{
67
+ provider: z.ZodLiteral<"external">;
68
+ issuer: z.ZodString;
69
+ authorization_endpoint: z.ZodString;
70
+ token_endpoint: z.ZodString;
71
+ revocation_endpoint: z.ZodOptional<z.ZodString>;
72
+ jwks_uri: z.ZodOptional<z.ZodString>;
73
+ }, z.core.$strip>], "provider">>;
53
74
  }, z.core.$strip>;
54
75
  export type Item = z.infer<typeof ItemSchema>;
55
76
  export type ShippingOption = z.infer<typeof ShippingOptionSchema>;