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

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": {
"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",

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",
"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",

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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()
});

View File

@ -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
});
}

View File

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