diff --git a/.env.example b/.env.example index e5d9af3..24b31e5 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,10 @@ PORT=8080 UBER_CLIENT_ID=your_client_id UBER_CLIENT_SECRET=your_client_secret UBER_REDIRECT_URI=http://localhost:8080/api/v1/auth/uber/callback -UBER_OAUTH_BASE_URL=https://login.uber.com +UBER_OAUTH_BASE_URL=https://auth.uber.com UBER_API_BASE_URL=https://api.uber.com # Sandbox values when testing: -# UBER_OAUTH_BASE_URL=https://sandbox-auth.uber.com +# UBER_OAUTH_BASE_URL=https://sandbox-login.uber.com # UBER_API_BASE_URL=https://test-api.uber.com # SQLite database path diff --git a/docs/developer-portal/02-auth-oauth.md b/docs/developer-portal/02-auth-oauth.md index bace397..92c49ff 100644 --- a/docs/developer-portal/02-auth-oauth.md +++ b/docs/developer-portal/02-auth-oauth.md @@ -6,4 +6,5 @@ Focus: Uber Eats OAuth 2.0 per merchant (multi-client onboarding). - Handle callback and store tokens - Refresh token flow - Token expiry handling policy - +- Sandbox utility for app token generation (`client_credentials`) +- Domain pairing validation to prevent sandbox/production mismatch diff --git a/docs/developer-portal/10-sandbox-testing-audit.md b/docs/developer-portal/10-sandbox-testing-audit.md new file mode 100644 index 0000000..8544e65 --- /dev/null +++ b/docs/developer-portal/10-sandbox-testing-audit.md @@ -0,0 +1,29 @@ +# 10 Sandbox Testing Audit + +Source checked: Uber Eats "Sandbox & Testing" section shared by you. + +## Implemented Now + +- `.env` defaults aligned to production auth/api domains: + - `https://auth.uber.com` + - `https://api.uber.com` +- Sandbox domain guidance aligned: + - `https://sandbox-login.uber.com` + - `https://test-api.uber.com` +- Added `client_credentials` token endpoint for testing utility: + - `POST /api/v1/auth/uber/client-credentials-token` +- Added domain pairing verification endpoint: + - `GET /api/v1/auth/uber/domain-pairing-status` + +## Already Implemented Before + +- Testing-friendly generic passthrough endpoint +- Webhook ingestion endpoint +- Core auth callback and token refresh paths + +## Pending + +- Automated test-store provisioning workflow (depends on Uber support process) +- Webhook signature verification (requires exact official webhook signing spec section) +- Full automated sandbox test suite scripts + diff --git a/docs/developer-portal/10-sandbox-testing.md b/docs/developer-portal/10-sandbox-testing.md index 5c5358e..c4c750e 100644 --- a/docs/developer-portal/10-sandbox-testing.md +++ b/docs/developer-portal/10-sandbox-testing.md @@ -4,8 +4,11 @@ Checklist: - Create a dedicated TESTING app in Uber Developer Dashboard - Use sandbox domains in `.env`: - - `UBER_OAUTH_BASE_URL=https://sandbox-auth.uber.com` + - `UBER_OAUTH_BASE_URL=https://sandbox-login.uber.com` - `UBER_API_BASE_URL=https://test-api.uber.com` +- Ensure domain pairing is valid: + - Testing: `sandbox-login.uber.com -> test-api.uber.com` + - Production: `auth.uber.com -> api.uber.com` - Test OAuth connect flow - Test menu upload/get - Test order lifecycle actions @@ -19,3 +22,9 @@ Validation sequence: 3. Full menu replacement test via PUT 4. Order webhook receipt and lifecycle action tests 5. Error and retry behavior checks + +Troubleshooting quick checks: + +- Verify test credentials belong to TESTING app type +- Token/API domain mismatch is the most common failure +- Sandbox data can reset; do not rely on persistence diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 6ec2fc9..a92c33b 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -61,6 +61,19 @@ } } }, + "/api/v1/auth/uber/client-credentials-token": { + "post": { + "summary": "Generate Uber client credentials token (sandbox/testing utility)", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Token generated" + } + } + } + }, "/api/v1/auth/uber/{merchantId}/refresh-token": { "post": { "summary": "Refresh Uber token for merchant", @@ -84,6 +97,19 @@ } } }, + "/api/v1/auth/uber/domain-pairing-status": { + "get": { + "summary": "Validate Uber auth and API domain pairing", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Domain mapping status" + } + } + } + }, "/api/v1/merchants": { "post": { "summary": "Create or update merchant", diff --git a/postman/Uber_Wrapper.postman_collection.json b/postman/Uber_Wrapper.postman_collection.json index a6dc0e5..b3b8b77 100644 --- a/postman/Uber_Wrapper.postman_collection.json +++ b/postman/Uber_Wrapper.postman_collection.json @@ -83,6 +83,64 @@ } } }, + { + "name": "Check Domain Pairing", + "request": { + "method": "GET", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + } + ], + "url": { + "raw": "{{baseUrl}}/api/v1/auth/uber/domain-pairing-status", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "auth", + "uber", + "domain-pairing-status" + ] + } + } + }, + { + "name": "Client Credentials Token", + "request": { + "method": "POST", + "header": [ + { + "key": "x-api-key", + "value": "{{apiKey}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"scope\": \"eats.order\"\n}" + }, + "url": { + "raw": "{{baseUrl}}/api/v1/auth/uber/client-credentials-token", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "v1", + "auth", + "uber", + "client-credentials-token" + ] + } + } + }, { "name": "Replace Menu (PUT)", "request": { diff --git a/src/config/env.js b/src/config/env.js index 3cd5b8f..bc95778 100644 --- a/src/config/env.js +++ b/src/config/env.js @@ -7,7 +7,7 @@ const envSchema = z.object({ UBER_CLIENT_ID: z.string().min(1), UBER_CLIENT_SECRET: z.string().min(1), UBER_REDIRECT_URI: z.string().url(), - UBER_OAUTH_BASE_URL: z.string().url().default("https://login.uber.com"), + UBER_OAUTH_BASE_URL: z.string().url().default("https://auth.uber.com"), UBER_API_BASE_URL: z.string().url().default("https://api.uber.com"), SQLITE_PATH: z.string().default("./data/uber_wrapper.db"), WRAPPER_API_KEY: z.string().optional() @@ -24,4 +24,3 @@ const env = parsed.data; env.SQLITE_PATH = path.resolve(process.cwd(), env.SQLITE_PATH); module.exports = env; - diff --git a/src/modules/auth/auth.controller.js b/src/modules/auth/auth.controller.js index bd77548..ec88d97 100644 --- a/src/modules/auth/auth.controller.js +++ b/src/modules/auth/auth.controller.js @@ -1,5 +1,11 @@ const { merchantRepository, uberConnectionRepository } = require("../../db/adapter"); -const { buildAuthorizeUrl, exchangeCodeForToken, refreshToken } = require("./auth.service"); +const { + buildAuthorizeUrl, + exchangeCodeForToken, + refreshToken, + getClientCredentialsToken, + getDomainMappingStatus +} = require("./auth.service"); function parseExpiresAt(expiresInSeconds) { if (!expiresInSeconds) { @@ -84,9 +90,32 @@ async function refreshMerchantToken(req, res) { }); } +async function generateClientCredentialsToken(req, res) { + const scope = req.body?.scope || "eats.order"; + const tokenData = await getClientCredentialsToken({ scope }); + return res.json({ + success: true, + message: "Client credentials token generated", + data: tokenData + }); +} + +async function getDomainPairingStatus(req, res) { + const status = getDomainMappingStatus(); + return res.json({ + success: true, + data: status, + message: + status.isValid || status.isLegacy + ? "Domain mapping looks usable" + : "Domain mapping is invalid. Use sandbox-login.uber.com->test-api.uber.com or auth.uber.com->api.uber.com." + }); +} + module.exports = { getAuthorizeUrl, oauthCallback, - refreshMerchantToken + refreshMerchantToken, + generateClientCredentialsToken, + getDomainPairingStatus }; - diff --git a/src/modules/auth/auth.service.js b/src/modules/auth/auth.service.js index a9ab972..09331ec 100644 --- a/src/modules/auth/auth.service.js +++ b/src/modules/auth/auth.service.js @@ -53,9 +53,55 @@ async function refreshToken(refreshToken) { return data; } +async function getClientCredentialsToken({ scope = "eats.order" } = {}) { + const payload = new URLSearchParams({ + client_id: env.UBER_CLIENT_ID, + client_secret: env.UBER_CLIENT_SECRET, + grant_type: "client_credentials", + scope + }); + + const { data } = await uberAuthClient.post("/oauth/v2/token", payload.toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); + + return data; +} + +function hostFromUrl(urlValue) { + return new URL(urlValue).host.toLowerCase(); +} + +function getDomainMappingStatus() { + const authHost = hostFromUrl(env.UBER_OAUTH_BASE_URL); + const apiHost = hostFromUrl(env.UBER_API_BASE_URL); + + const pair = `${authHost}->${apiHost}`; + const validPairs = [ + "sandbox-login.uber.com->test-api.uber.com", + "auth.uber.com->api.uber.com" + ]; + const legacyPairs = ["login.uber.com->api.uber.com"]; + + const isValid = validPairs.includes(pair); + const isLegacy = legacyPairs.includes(pair); + + return { + authBaseUrl: env.UBER_OAUTH_BASE_URL, + apiBaseUrl: env.UBER_API_BASE_URL, + pair, + isValid, + isLegacy, + expectedPairs: validPairs + }; +} + module.exports = { buildAuthorizeUrl, exchangeCodeForToken, - refreshToken + refreshToken, + getClientCredentialsToken, + getDomainMappingStatus }; - diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index bf64ed5..983b500 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -36,6 +36,22 @@ router.get("/uber/authorize-url", asyncHandler(controller.getAuthorizeUrl)); */ 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) + * tags: + * - Auth + * responses: + * 200: + * description: Token generated + */ +router.post( + "/uber/client-credentials-token", + asyncHandler(controller.generateClientCredentialsToken) +); + /** * @openapi * /api/v1/auth/uber/{merchantId}/refresh-token: @@ -55,5 +71,17 @@ router.get("/uber/callback", asyncHandler(controller.oauthCallback)); */ router.post("/uber/:merchantId/refresh-token", asyncHandler(controller.refreshMerchantToken)); -module.exports = router; +/** + * @openapi + * /api/v1/auth/uber/domain-pairing-status: + * get: + * summary: Validate Uber auth and API domain pairing + * tags: + * - Auth + * responses: + * 200: + * description: Domain mapping status + */ +router.get("/uber/domain-pairing-status", asyncHandler(controller.getDomainPairingStatus)); +module.exports = router;