feat: align oauth flows with uber auth docs and add client-credentials token caching

This commit is contained in:
MOHAN 2026-03-29 17:42:43 +05:30
parent fccabf449a
commit 042dc1686e
12 changed files with 394 additions and 30 deletions

View File

@ -8,3 +8,8 @@ Focus: Uber Eats OAuth 2.0 per merchant (multi-client onboarding).
- Token expiry handling policy - Token expiry handling policy
- Sandbox utility for app token generation (`client_credentials`) - Sandbox utility for app token generation (`client_credentials`)
- Domain pairing validation to prevent sandbox/production mismatch - 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

View File

@ -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

View File

@ -63,7 +63,7 @@
}, },
"/api/v1/auth/uber/client-credentials-token": { "/api/v1/auth/uber/client-credentials-token": {
"post": { "post": {
"summary": "Generate Uber client credentials token (sandbox/testing utility)", "summary": "Get cached or live Uber client credentials token",
"tags": [ "tags": [
"Auth" "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": { "/api/v1/auth/uber/{merchantId}/refresh-token": {
"post": { "post": {
"summary": "Refresh Uber token for merchant", "summary": "Refresh Uber token for merchant",

View File

@ -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", "name": "Client Credentials Token",
"request": { "request": {
@ -124,7 +149,7 @@
], ],
"body": { "body": {
"mode": "raw", "mode": "raw",
"raw": "{\n \"scope\": \"eats.order\"\n}" "raw": "{\n \"scope\": \"eats.store eats.order\"\n}"
}, },
"url": { "url": {
"raw": "{{baseUrl}}/api/v1/auth/uber/client-credentials-token", "raw": "{{baseUrl}}/api/v1/auth/uber/client-credentials-token",
@ -190,7 +215,7 @@
], ],
"body": { "body": {
"mode": "raw", "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": { "url": {
"raw": "{{baseUrl}}/api/v1/uber/request", "raw": "{{baseUrl}}/api/v1/uber/request",

View File

@ -9,6 +9,7 @@ module.exports = {
merchantRepository: repositories.merchantRepository, merchantRepository: repositories.merchantRepository,
uberConnectionRepository: repositories.uberConnectionRepository, uberConnectionRepository: repositories.uberConnectionRepository,
webhookRepository: repositories.webhookRepository, webhookRepository: repositories.webhookRepository,
apiLogRepository: repositories.apiLogRepository apiLogRepository: repositories.apiLogRepository,
appTokenRepository: repositories.appTokenRepository,
tokenRequestLogRepository: repositories.tokenRequestLogRepository
}; };

View File

@ -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 = { module.exports = {
merchantRepository, merchantRepository,
uberConnectionRepository, uberConnectionRepository,
webhookRepository, webhookRepository,
apiLogRepository apiLogRepository,
appTokenRepository,
tokenRequestLogRepository
}; };

View File

@ -65,6 +65,28 @@ function initSchema() {
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY(merchant_id) REFERENCES merchants(id) 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, db,
initSchema initSchema
}; };

View File

@ -3,9 +3,11 @@ const {
buildAuthorizeUrl, buildAuthorizeUrl,
exchangeCodeForToken, exchangeCodeForToken,
refreshToken, refreshToken,
getClientCredentialsToken, getCachedClientCredentialsToken,
getDomainMappingStatus getDomainMappingStatus,
AUTH_SCOPES
} = require("./auth.service"); } = require("./auth.service");
const { tokenRequestLogRepository } = require("../../db/adapter");
function parseExpiresAt(expiresInSeconds) { function parseExpiresAt(expiresInSeconds) {
if (!expiresInSeconds) { if (!expiresInSeconds) {
@ -91,11 +93,11 @@ async function refreshMerchantToken(req, res) {
} }
async function generateClientCredentialsToken(req, res) { async function generateClientCredentialsToken(req, res) {
const scope = req.body?.scope || "eats.order"; const scope = req.body?.scope || `${AUTH_SCOPES.STORE} ${AUTH_SCOPES.ORDER}`;
const tokenData = await getClientCredentialsToken({ scope }); const tokenData = await getCachedClientCredentialsToken({ scope });
return res.json({ return res.json({
success: true, success: true,
message: "Client credentials token generated", message: "Client credentials token available",
data: tokenData 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 = { module.exports = {
getAuthorizeUrl, getAuthorizeUrl,
oauthCallback, oauthCallback,
refreshMerchantToken, refreshMerchantToken,
generateClientCredentialsToken, generateClientCredentialsToken,
getDomainPairingStatus getDomainPairingStatus,
getAuthCapabilities
}; };

View File

@ -1,12 +1,27 @@
const axios = require("axios"); const axios = require("axios");
const env = require("../../config/env"); 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({ const uberAuthClient = axios.create({
baseURL: env.UBER_OAUTH_BASE_URL, baseURL: env.UBER_OAUTH_BASE_URL,
timeout: 20000 timeout: 20000
}); });
function buildAuthorizeUrl({ state, scope = "eats.store eats.order" }) { function buildAuthorizeUrl({ state, scope = AUTH_SCOPES.POS_PROVISIONING }) {
const params = new URLSearchParams({ const params = new URLSearchParams({
client_id: env.UBER_CLIENT_ID, client_id: env.UBER_CLIENT_ID,
response_type: "code", response_type: "code",
@ -70,6 +85,60 @@ async function getClientCredentialsToken({ scope = "eats.order" } = {}) {
return data; 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) { function hostFromUrl(urlValue) {
return new URL(urlValue).host.toLowerCase(); return new URL(urlValue).host.toLowerCase();
} }
@ -103,5 +172,8 @@ module.exports = {
exchangeCodeForToken, exchangeCodeForToken,
refreshToken, refreshToken,
getClientCredentialsToken, getClientCredentialsToken,
getDomainMappingStatus getDomainMappingStatus,
getCachedClientCredentialsToken,
AUTH_SCOPES,
AUTH_GRANT_TYPES
}; };

View File

@ -2,9 +2,11 @@ const { z } = require("zod");
const proxyService = require("./proxy.service"); const proxyService = require("./proxy.service");
const genericSchema = z.object({ 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"), method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("GET"),
path: z.string().min(1), path: z.string().min(1),
authMode: z.enum(["app", "merchant"]).optional(),
scopes: z.string().optional(),
query: z.record(z.string(), z.any()).optional(), query: z.record(z.string(), z.any()).optional(),
body: z.any().optional() body: z.any().optional()
}); });

View File

@ -2,6 +2,7 @@ const axios = require("axios");
const env = require("../../config/env"); const env = require("../../config/env");
const uberEndpoints = require("../../config/uberEndpoints"); const uberEndpoints = require("../../config/uberEndpoints");
const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter"); const { uberConnectionRepository, apiLogRepository } = require("../../db/adapter");
const { getCachedClientCredentialsToken, AUTH_SCOPES } = require("../auth/auth.service");
const uberApiClient = axios.create({ const uberApiClient = axios.create({
baseURL: env.UBER_API_BASE_URL, baseURL: env.UBER_API_BASE_URL,
@ -16,17 +17,44 @@ function interpolatePath(pathTemplate, params = {}) {
return output; return output;
} }
function buildAuthHeader(connection) { function buildAuthHeader(tokenType, accessToken) {
return `${connection.token_type || "Bearer"} ${connection.access_token}`; return `${tokenType || "Bearer"} ${accessToken}`;
} }
async function callUberApi({ merchantId, method, uberPath, query, body, wrapperRoute }) { function getMerchantConnectionToken(merchantId) {
const connection = uberConnectionRepository.findByMerchantId(merchantId); const connection = uberConnectionRepository.findByMerchantId(merchantId);
if (!connection || connection.status !== "active") { 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; error.status = 404;
throw error; 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 { try {
const response = await uberApiClient.request({ const response = await uberApiClient.request({
@ -35,7 +63,7 @@ async function callUberApi({ merchantId, method, uberPath, query, body, wrapperR
params: query, params: query,
data: body, data: body,
headers: { headers: {
Authorization: buildAuthHeader(connection), Authorization: buildAuthHeader(resolvedAuth.tokenType, resolvedAuth.accessToken),
"Content-Type": "application/json" "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({ return callUberApi({
merchantId, merchantId,
method, method,
uberPath: path, uberPath: path,
query, query,
body, 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", method: "POST",
uberPath, uberPath,
body: payload, 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", method: "PUT",
uberPath, uberPath,
body: payload, 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, merchantId,
method: "GET", method: "GET",
uberPath, 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", method: "GET",
uberPath, uberPath,
query, 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", method: "POST",
uberPath, uberPath,
body: payload || {}, 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", method: "PUT",
uberPath, uberPath,
body: payload, body: payload,
wrapperRoute: "/api/v1/uber/stores/hours" wrapperRoute: "/api/v1/uber/stores/hours",
authMode: "app",
scopes: AUTH_SCOPES.STORE
}); });
} }

View File

@ -40,7 +40,7 @@ router.get("/uber/callback", asyncHandler(controller.oauthCallback));
* @openapi * @openapi
* /api/v1/auth/uber/client-credentials-token: * /api/v1/auth/uber/client-credentials-token:
* post: * post:
* summary: Generate Uber client credentials token (sandbox/testing utility) * summary: Get cached or live Uber client credentials token
* tags: * tags:
* - Auth * - Auth
* responses: * responses:
@ -52,6 +52,19 @@ router.post(
asyncHandler(controller.generateClientCredentialsToken) 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 * @openapi
* /api/v1/auth/uber/{merchantId}/refresh-token: * /api/v1/auth/uber/{merchantId}/refresh-token: