From 042dc1686e1aaadac169bab405a3c40d1ec7a149 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Sun, 29 Mar 2026 17:42:43 +0530 Subject: [PATCH] feat: align oauth flows with uber auth docs and add client-credentials token caching --- docs/developer-portal/02-auth-oauth.md | 5 + .../02-authentication-audit.md | 30 +++++ docs/openapi/openapi.json | 15 ++- postman/Uber_Wrapper.postman_collection.json | 29 ++++- src/db/adapter.js | 5 +- src/db/repositories.js | 113 +++++++++++++++++- src/db/sqlite.js | 23 +++- src/modules/auth/auth.controller.js | 41 ++++++- src/modules/auth/auth.service.js | 76 +++++++++++- src/modules/proxy/proxy.controller.js | 4 +- src/modules/proxy/proxy.service.js | 68 +++++++++-- src/routes/auth.routes.js | 15 ++- 12 files changed, 394 insertions(+), 30 deletions(-) create mode 100644 docs/developer-portal/02-authentication-audit.md diff --git a/docs/developer-portal/02-auth-oauth.md b/docs/developer-portal/02-auth-oauth.md index 92c49ff..698d3ae 100644 --- a/docs/developer-portal/02-auth-oauth.md +++ b/docs/developer-portal/02-auth-oauth.md @@ -8,3 +8,8 @@ Focus: Uber Eats OAuth 2.0 per merchant (multi-client onboarding). - Token expiry handling policy - Sandbox utility for app token generation (`client_credentials`) - Domain pairing validation to prevent sandbox/production mismatch + +Grant usage policy: + +- `client_credentials`: Store/Menu/Order/Reporting regular operations +- `authorization_code` (`eats.pos_provisioning`): store discovery/integration activation diff --git a/docs/developer-portal/02-authentication-audit.md b/docs/developer-portal/02-authentication-audit.md new file mode 100644 index 0000000..8adfecb --- /dev/null +++ b/docs/developer-portal/02-authentication-audit.md @@ -0,0 +1,30 @@ +# 02 Authentication Audit + +Source checked: Uber Eats "Authentication" section shared by you. + +## Implemented Now + +- OAuth endpoints aligned to `auth.uber.com` in environment defaults. +- Authorization URL default scope changed to `eats.pos_provisioning` (authorization_code flow use case). +- Added cached `client_credentials` token retrieval to reduce token churn. +- Added token request tracking and soft guard near rate limit (`100/hour`). +- Added domain pairing status endpoint: + - `GET /api/v1/auth/uber/domain-pairing-status` +- Added auth capabilities endpoint listing grant types/scopes/rate metadata: + - `GET /api/v1/auth/uber/capabilities` +- Updated proxy auth model: + - default regular API calls use app-level `client_credentials` token + - optional `authMode=merchant` for merchant OAuth token calls + +## Existing From Earlier + +- Authorization code callback exchange +- Merchant token refresh route +- Manual merchant connection storage + +## Pending / Needs More Official Docs + +- Exact endpoint-by-endpoint scope mapping table enforcement in code +- Full activation/provisioning flow routes (beyond auth callback) +- Token revocation handling if Uber publishes endpoint/process details + diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index a92c33b..d75d8ac 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -63,7 +63,7 @@ }, "/api/v1/auth/uber/client-credentials-token": { "post": { - "summary": "Generate Uber client credentials token (sandbox/testing utility)", + "summary": "Get cached or live Uber client credentials token", "tags": [ "Auth" ], @@ -74,6 +74,19 @@ } } }, + "/api/v1/auth/uber/capabilities": { + "get": { + "summary": "Get Uber auth grants, scopes, and token-rate-limit metadata", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Auth capability details" + } + } + } + }, "/api/v1/auth/uber/{merchantId}/refresh-token": { "post": { "summary": "Refresh Uber token for merchant", diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index b3b8b77..03d83a7 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -108,6 +108,31 @@ } } }, + { + "name": "Get Auth Capabilities", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseUrl}}/api/v1/auth/uber/capabilities", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "auth", + "uber", + "capabilities" + ] + } + } + }, { "name": "Client Credentials Token", "request": { @@ -124,7 +149,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"scope\": \"eats.order\"\n}" + "raw": "{\n \"scope\": \"eats.store eats.order\"\n}" }, "url": { "raw": "{{baseUrl}}/api/v1/auth/uber/client-credentials-token", @@ -190,7 +215,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"merchantId\": \"{{merchantId}}\",\n \"method\": \"GET\",\n \"path\": \"/v1/eats/stores/{{storeId}}/orders\"\n}" + "raw": "{\n \"method\": \"GET\",\n \"path\": \"/v1/eats/stores/{{storeId}}/orders\",\n \"authMode\": \"app\",\n \"scopes\": \"eats.order\"\n}" }, "url": { "raw": "{{baseUrl}}/api/v1/uber/request", diff --git a/src/db/adapter.js b/src/db/adapter.js index 07b8438..d652ef1 100644 --- a/src/db/adapter.js +++ b/src/db/adapter.js @@ -9,6 +9,7 @@ module.exports = { merchantRepository: repositories.merchantRepository, uberConnectionRepository: repositories.uberConnectionRepository, webhookRepository: repositories.webhookRepository, - apiLogRepository: repositories.apiLogRepository + apiLogRepository: repositories.apiLogRepository, + appTokenRepository: repositories.appTokenRepository, + tokenRequestLogRepository: repositories.tokenRequestLogRepository }; - diff --git a/src/db/repositories.js b/src/db/repositories.js index 72eb22f..51517c7 100644 --- a/src/db/repositories.js +++ b/src/db/repositories.js @@ -191,9 +191,120 @@ const apiLogRepository = { } }; +const appTokenRepository = { + findValid({ provider, grantType, scope, minValiditySeconds = 120 }) { + const row = db + .prepare( + ` + SELECT * + FROM app_tokens + WHERE provider = ? + AND grant_type = ? + AND scope = ? + LIMIT 1 + ` + ) + .get(provider, grantType, scope); + + if (!row) { + return null; + } + + const minExpiry = Date.now() + minValiditySeconds * 1000; + const expiresAtMs = new Date(row.expires_at).getTime(); + if (Number.isNaN(expiresAtMs) || expiresAtMs <= minExpiry) { + return null; + } + + return row; + }, + + upsert({ provider, grantType, scope, accessToken, tokenType, expiresAt }) { + const existing = db + .prepare( + ` + SELECT * FROM app_tokens + WHERE provider = ? AND grant_type = ? AND scope = ? + LIMIT 1 + ` + ) + .get(provider, grantType, scope); + const timestamp = nowIso(); + const row = { + id: existing?.id || uuidv4(), + provider, + grant_type: grantType, + scope, + access_token: accessToken, + token_type: tokenType || "Bearer", + expires_at: expiresAt, + created_at: existing?.created_at || timestamp, + updated_at: timestamp + }; + + db.prepare( + ` + INSERT INTO app_tokens ( + id, provider, grant_type, scope, access_token, token_type, expires_at, created_at, updated_at + ) + VALUES ( + @id, @provider, @grant_type, @scope, @access_token, @token_type, @expires_at, @created_at, @updated_at + ) + ON CONFLICT(provider, grant_type, scope) DO UPDATE SET + access_token = excluded.access_token, + token_type = excluded.token_type, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + ` + ).run(row); + + return db + .prepare( + ` + SELECT * + FROM app_tokens + WHERE provider = ? AND grant_type = ? AND scope = ? + LIMIT 1 + ` + ) + .get(provider, grantType, scope); + } +}; + +const tokenRequestLogRepository = { + insert({ provider, grantType }) { + db.prepare( + ` + INSERT INTO token_request_logs ( + id, provider, grant_type, requested_at + ) + VALUES (?, ?, ?, ?) + ` + ).run(uuidv4(), provider, grantType, nowIso()); + }, + + countInLastHour({ provider, grantType }) { + const sinceIso = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const row = db + .prepare( + ` + SELECT COUNT(*) AS total + FROM token_request_logs + WHERE provider = ? + AND grant_type = ? + AND requested_at >= ? + ` + ) + .get(provider, grantType, sinceIso); + return row?.total || 0; + } +}; + module.exports = { merchantRepository, uberConnectionRepository, webhookRepository, - apiLogRepository + apiLogRepository, + appTokenRepository, + tokenRequestLogRepository }; diff --git a/src/db/sqlite.js b/src/db/sqlite.js index 8b15b5d..017a11d 100644 --- a/src/db/sqlite.js +++ b/src/db/sqlite.js @@ -65,6 +65,28 @@ function initSchema() { created_at TEXT NOT NULL, FOREIGN KEY(merchant_id) REFERENCES merchants(id) ); + + CREATE TABLE IF NOT EXISTS app_tokens ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + grant_type TEXT NOT NULL, + scope TEXT NOT NULL, + access_token TEXT NOT NULL, + token_type TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_app_tokens_provider_grant_scope + ON app_tokens(provider, grant_type, scope); + + CREATE TABLE IF NOT EXISTS token_request_logs ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + grant_type TEXT NOT NULL, + requested_at TEXT NOT NULL + ); `); } @@ -72,4 +94,3 @@ module.exports = { db, initSchema }; - diff --git a/src/modules/auth/auth.controller.js b/src/modules/auth/auth.controller.js index ec88d97..b0bc3dd 100644 --- a/src/modules/auth/auth.controller.js +++ b/src/modules/auth/auth.controller.js @@ -3,9 +3,11 @@ const { buildAuthorizeUrl, exchangeCodeForToken, refreshToken, - getClientCredentialsToken, - getDomainMappingStatus + getCachedClientCredentialsToken, + getDomainMappingStatus, + AUTH_SCOPES } = require("./auth.service"); +const { tokenRequestLogRepository } = require("../../db/adapter"); function parseExpiresAt(expiresInSeconds) { if (!expiresInSeconds) { @@ -91,11 +93,11 @@ async function refreshMerchantToken(req, res) { } async function generateClientCredentialsToken(req, res) { - const scope = req.body?.scope || "eats.order"; - const tokenData = await getClientCredentialsToken({ scope }); + const scope = req.body?.scope || `${AUTH_SCOPES.STORE} ${AUTH_SCOPES.ORDER}`; + const tokenData = await getCachedClientCredentialsToken({ scope }); return res.json({ success: true, - message: "Client credentials token generated", + message: "Client credentials token available", data: tokenData }); } @@ -112,10 +114,37 @@ async function getDomainPairingStatus(req, res) { }); } +async function getAuthCapabilities(req, res) { + const requestsLastHour = tokenRequestLogRepository.countInLastHour({ + provider: "uber", + grantType: "client_credentials" + }); + return res.json({ + success: true, + data: { + tokenEndpoint: "https://auth.uber.com/oauth/v2/token", + authorizeEndpoint: "https://auth.uber.com/oauth/v2/authorize", + grantTypes: { + client_credentials: [ + AUTH_SCOPES.STORE, + AUTH_SCOPES.STORE_STATUS_WRITE, + AUTH_SCOPES.ORDER, + AUTH_SCOPES.STORE_ORDERS_READ, + AUTH_SCOPES.REPORT + ], + authorization_code: [AUTH_SCOPES.POS_PROVISIONING] + }, + clientCredentialsRateLimitPerHour: 100, + observedClientCredentialsRequestsLastHour: requestsLastHour + } + }); +} + module.exports = { getAuthorizeUrl, oauthCallback, refreshMerchantToken, generateClientCredentialsToken, - getDomainPairingStatus + getDomainPairingStatus, + getAuthCapabilities }; diff --git a/src/modules/auth/auth.service.js b/src/modules/auth/auth.service.js index 09331ec..e45ceec 100644 --- a/src/modules/auth/auth.service.js +++ b/src/modules/auth/auth.service.js @@ -1,12 +1,27 @@ const axios = require("axios"); const env = require("../../config/env"); +const { appTokenRepository, tokenRequestLogRepository } = require("../../db/adapter"); + +const AUTH_GRANT_TYPES = { + CLIENT_CREDENTIALS: "client_credentials", + AUTHORIZATION_CODE: "authorization_code" +}; + +const AUTH_SCOPES = { + STORE: "eats.store", + STORE_STATUS_WRITE: "eats.store.status.write", + ORDER: "eats.order", + STORE_ORDERS_READ: "eats.store.orders.read", + REPORT: "eats.report", + POS_PROVISIONING: "eats.pos_provisioning" +}; const uberAuthClient = axios.create({ baseURL: env.UBER_OAUTH_BASE_URL, timeout: 20000 }); -function buildAuthorizeUrl({ state, scope = "eats.store eats.order" }) { +function buildAuthorizeUrl({ state, scope = AUTH_SCOPES.POS_PROVISIONING }) { const params = new URLSearchParams({ client_id: env.UBER_CLIENT_ID, response_type: "code", @@ -70,6 +85,60 @@ async function getClientCredentialsToken({ scope = "eats.order" } = {}) { return data; } +function parseExpiresAt(expiresInSeconds) { + const seconds = Number(expiresInSeconds || 0); + if (!seconds || Number.isNaN(seconds)) { + return new Date(Date.now() + 60 * 60 * 1000).toISOString(); + } + return new Date(Date.now() + seconds * 1000).toISOString(); +} + +async function getCachedClientCredentialsToken({ scope }) { + const normalizedScope = (scope || AUTH_SCOPES.ORDER).trim().split(/\s+/).sort().join(" "); + const cached = appTokenRepository.findValid({ + provider: "uber", + grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS, + scope: normalizedScope + }); + if (cached) { + return { + access_token: cached.access_token, + token_type: cached.token_type, + scope: cached.scope, + expires_in: Math.max(0, Math.floor((new Date(cached.expires_at).getTime() - Date.now()) / 1000)), + source: "cache" + }; + } + + const requestsLastHour = tokenRequestLogRepository.countInLastHour({ + provider: "uber", + grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS + }); + if (requestsLastHour >= 95) { + const error = new Error( + "Client credentials token request rate is approaching the Uber limit (100/hour). Reuse cached tokens." + ); + error.status = 429; + throw error; + } + + const fresh = await getClientCredentialsToken({ scope: normalizedScope }); + const expiresAt = parseExpiresAt(fresh.expires_in); + appTokenRepository.upsert({ + provider: "uber", + grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS, + scope: normalizedScope, + accessToken: fresh.access_token, + tokenType: fresh.token_type || "Bearer", + expiresAt + }); + tokenRequestLogRepository.insert({ + provider: "uber", + grantType: AUTH_GRANT_TYPES.CLIENT_CREDENTIALS + }); + return { ...fresh, source: "live" }; +} + function hostFromUrl(urlValue) { return new URL(urlValue).host.toLowerCase(); } @@ -103,5 +172,8 @@ module.exports = { exchangeCodeForToken, refreshToken, getClientCredentialsToken, - getDomainMappingStatus + getDomainMappingStatus, + getCachedClientCredentialsToken, + AUTH_SCOPES, + AUTH_GRANT_TYPES }; diff --git a/src/modules/proxy/proxy.controller.js b/src/modules/proxy/proxy.controller.js index 0e32be4..33791c9 100644 --- a/src/modules/proxy/proxy.controller.js +++ b/src/modules/proxy/proxy.controller.js @@ -2,9 +2,11 @@ const { z } = require("zod"); const proxyService = require("./proxy.service"); const genericSchema = z.object({ - merchantId: z.string().min(1), + merchantId: z.string().min(1).optional(), method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"), path: z.string().min(1), + authMode: z.enum(["app", "merchant"]).optional(), + scopes: z.string().optional(), query: z.record(z.string(), z.any()).optional(), body: z.any().optional() }); diff --git a/src/modules/proxy/proxy.service.js b/src/modules/proxy/proxy.service.js index 4080a94..ff69f14 100644 --- a/src/modules/proxy/proxy.service.js +++ b/src/modules/proxy/proxy.service.js @@ -2,6 +2,7 @@ const axios = require("axios"); const env = require("../../config/env"); const uberEndpoints = require("../../config/uberEndpoints"); const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter"); +const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service"); const uberApiClient = axios.create({ baseURL: env.UBER_API_BASE_URL, @@ -16,17 +17,44 @@ function interpolatePath(pathTemplate, params = {}) { return output; } -function buildAuthHeader(connection) { - return `${connection.token_type || "Bearer"} ${connection.access_token}`; +function buildAuthHeader(tokenType, accessToken) { + return `${tokenType || "Bearer"} ${accessToken}`; } -async function callUberApi({ merchantId, method, uberPath, query, body, wrapperRoute }) { +function getMerchantConnectionToken(merchantId) { const connection = uberConnectionRepository.findByMerchantId(merchantId); if (!connection || connection.status !== "active") { - const error = new Error("Active Uber connection not found for merchant"); + const error = new Error("Active Uber merchant OAuth connection not found"); error.status = 404; throw error; } + return { + tokenType: connection.token_type || "Bearer", + accessToken: connection.access_token + }; +} + +async function resolveAuthToken({ authMode = "app", merchantId, scopes }) { + if (authMode === "merchant") { + if (!merchantId) { + const error = new Error("merchantId is required when authMode=merchant"); + error.status = 400; + throw error; + } + return getMerchantConnectionToken(merchantId); + } + + const tokenData = await getCachedClientCredentialsToken({ + scope: scopes || AUTH_SCOPES.ORDER + }); + return { + tokenType: tokenData.token_type || "Bearer", + accessToken: tokenData.access_token + }; +} + +async function callUberApi({ merchantId, method, uberPath, query, body, wrapperRoute, authMode, scopes }) { + const resolvedAuth = await resolveAuthToken({ authMode, merchantId, scopes }); try { const response = await uberApiClient.request({ @@ -35,7 +63,7 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR params: query, data: body, headers: { - Authorization: buildAuthHeader(connection), + Authorization: buildAuthHeader(resolvedAuth.tokenType, resolvedAuth.accessToken), "Content-Type": "application/json" } }); @@ -72,14 +100,16 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR } } -async function genericProxy({ merchantId, method, path, query, body }) { +async function genericProxy({ merchantId, method, path, query, body, authMode = "app", scopes }) { return callUberApi({ merchantId, method, uberPath: path, query, body, - wrapperRoute: "/api/v1/uber/request" + wrapperRoute: "/api/v1/uber/request", + authMode, + scopes }); } @@ -90,7 +120,9 @@ async function menuUpsert({ merchantId, storeId, payload }) { method: "POST", uberPath, body: payload, - wrapperRoute: "/api/v1/uber/menu/upsert" + wrapperRoute: "/api/v1/uber/menu/upsert", + authMode: "app", + scopes: AUTH_SCOPES.STORE }); } @@ -101,7 +133,9 @@ async function menuReplace({ merchantId, storeId, payload }) { method: "PUT", uberPath, body: payload, - wrapperRoute: "/api/v1/uber/menu/replace" + wrapperRoute: "/api/v1/uber/menu/replace", + authMode: "app", + scopes: AUTH_SCOPES.STORE }); } @@ -111,7 +145,9 @@ async function menuGet({ merchantId, storeId }) { merchantId, method: "GET", uberPath, - wrapperRoute: "/api/v1/uber/menu" + wrapperRoute: "/api/v1/uber/menu", + authMode: "app", + scopes: AUTH_SCOPES.STORE }); } @@ -122,7 +158,9 @@ async function ordersList({ merchantId, storeId, query }) { method: "GET", uberPath, query, - wrapperRoute: "/api/v1/uber/orders" + wrapperRoute: "/api/v1/uber/orders", + authMode: "app", + scopes: AUTH_SCOPES.ORDER }); } @@ -146,7 +184,9 @@ async function orderAction({ merchantId, orderId, action, payload }) { method: "POST", uberPath, body: payload || {}, - wrapperRoute: "/api/v1/uber/orders/:orderId/action" + wrapperRoute: "/api/v1/uber/orders/:orderId/action", + authMode: "app", + scopes: AUTH_SCOPES.ORDER }); } @@ -157,7 +197,9 @@ async function updateStoreHours({ merchantId, storeId, payload }) { method: "PUT", uberPath, body: payload, - wrapperRoute: "/api/v1/uber/stores/hours" + wrapperRoute: "/api/v1/uber/stores/hours", + authMode: "app", + scopes: AUTH_SCOPES.STORE }); } diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 983b500..7d7a03b 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -40,7 +40,7 @@ router.get("/uber/callback", asyncHandler(controller.oauthCallback)); * @openapi * /api/v1/auth/uber/client-credentials-token: * post: - * summary: Generate Uber client credentials token (sandbox/testing utility) + * summary: Get cached or live Uber client credentials token * tags: * - Auth * responses: @@ -52,6 +52,19 @@ router.post( asyncHandler(controller.generateClientCredentialsToken) ); +/** + * @openapi + * /api/v1/auth/uber/capabilities: + * get: + * summary: Get Uber auth grants, scopes, and token-rate-limit metadata + * tags: + * - Auth + * responses: + * 200: + * description: Auth capability details + */ +router.get("/uber/capabilities", asyncHandler(controller.getAuthCapabilities)); + /** * @openapi * /api/v1/auth/uber/{merchantId}/refresh-token: