๐ฏ Salesforce - Authentication & CRM Data
๐ Overviewโ
This document covers the complete Salesforce integration including OAuth 2.0 authentication with JWT state tokens and CRM data export with custom pagination for contacts, opportunities (deals), and notes.
Source Files:
- Auth Controller:
external/Integrations/Salesforce/Controllers/authController.js - Export Controller:
external/Integrations/Salesforce/Controllers/exportController.js - Pagination:
external/Integrations/Salesforce/Controllers/pagination.js - Provider:
external/Integrations/Salesforce/Providers/api.js - Model:
external/Integrations/Salesforce/Models/keys.js
Salesforce API: https://login.salesforce.com/services/oauth2/, https://{instance}.salesforce.com/services/data/v48.0/
๐ Part 1: Authenticationโ
OAuth 2.0 Flow Diagramโ
sequenceDiagram
participant User
participant DC as DashClicks
participant JWT as JWT Handler
participant DB as MongoDB
participant SF as Salesforce
User->>DC: GET /auth/login?forward_url=...
DC->>DB: Check existing token
alt Token valid
DC-->>User: Redirect to forward_url
else No token
DC->>JWT: Create state (1h exp)
DC-->>User: Redirect to Salesforce
User->>SF: Authorize app
SF-->>DC: Callback with code
DC->>JWT: Verify state
DC->>SF: Exchange code for tokens
SF-->>DC: Access + refresh tokens
DC->>DB: Store tokens
DC-->>User: Redirect to forward_url
end
Collections Usedโ
integrations.salesforce.key:
{
_id: ObjectId,
account_id: String,
owner: String,
token: {
access_token: String,
refresh_token: String, // Never expires unless revoked
issued_at: String // Unix timestamp
},
token_invalidated: Boolean
}
Authentication Functionsโ
getLogin(req, res)โ
Endpoint: GET /v1/integrations/salesforce/auth/login
Query Parameters:
forward_url(String, required) - Return URL after authentication
Logic:
- Validate
forward_urlparameter exists - Check JWT authentication in
req.auth - Query database for existing token
- If token exists and not invalidated โ redirect to success
- If
token_invalidated: trueโ delete token - Generate JWT state token (1h expiration):
{
aid: account_id,
uid: user_id,
forward_url: forward_url
} - Build Salesforce authorization URL
- Redirect user to Salesforce OAuth
Success Response (token exists):
HTTP/1.1 302 Found
Location: {forward_url}?status=success&integration=salesforce
Success Response (new auth):
HTTP/1.1 302 Found
Location: https://login.salesforce.com/services/oauth2/authorize?client_id=...&state={jwt}
Error Response:
{
"succes": false,
"errno": 400,
"message": "Please provide forward_url"
}
callback(req, res)โ
Endpoint: GET /v1/integrations/salesforce/auth/callback
Query Parameters:
code(String) - Authorization codestate(String) - JWT state token
Logic:
-
Validate authorization code exists
-
Verify and decode JWT state token
-
Exchange code for tokens via Salesforce API:
POST https://login.salesforce.com/services/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
client_id={SALESFORCE_CLIENT_ID}&
client_secret={SALESFORCE_SECRET_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
code={code} -
Salesforce response:
{
"access_token": "00D...",
"refresh_token": "5Aep...",
"issued_at": "1704844800000",
"instance_url": "https://na1.salesforce.com",
"id": "https://login.salesforce.com/id/00D.../005...",
"token_type": "Bearer",
"signature": "..."
} -
Store tokens in database:
{
token: {
access_token: response.data.access_token,
refresh_token: response.data.refresh_token,
issued_at: response.data.issued_at
},
account_id: aid,
owner: uid
} -
Redirect to forward_url with success
Success Response:
HTTP/1.1 302 Found
Location: {forward_url}?status=success&integration=salesforce
Error Responses:
# Missing code
Location: ?status=error&integration=salesforce&reason=Authentication not initiated
# Invalid state
{
"success": false,
"message": "Invalid State"
}
# Salesforce error
Location: {forward_url}?status=error&integration=salesforce&reason={error_description}
deleteAccessToken(req, res)โ
Endpoint: DELETE /v1/integrations/salesforce/auth/
Logic:
- Verify JWT authentication
- Find token by account_id and owner
- Delete token from database
- Return success confirmation
Success Response:
{
"success": true,
"message": "SUCCESS"
}
Error Responses:
// Token not found
{
"success": false,
"errno": 404,
"message": "Access Token Not Found"
}
// Unauthorized
{
"success": false,
"errno": 400,
"message": "Unauthorized User"
}
Provider Functionsโ
authorizeURL(stateURL)โ
Purpose: Build Salesforce OAuth authorization URL
Returns: Authorization URL string
URL Structure:
https://login.salesforce.com/services/oauth2/authorize?
client_id={SALESFORCE_CLIENT_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
response_type=code&
scope={SALESFORCE_AUTH_SCOPES}&
state={stateURL}
Scopes: api id web refresh_token (URL-encoded as api%20id%20web%20refresh_token)
accessToken(queryCode)โ
Purpose: Exchange authorization code for access tokens
Salesforce API: POST https://login.salesforce.com/services/oauth2/token
Request:
POST /services/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
client_id={SALESFORCE_CLIENT_ID}&
client_secret={SALESFORCE_SECRET_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
code={queryCode}
Response: Axios response object with token data
getRefreshAccessToken(queryData)โ
Purpose: Obtain new access token using refresh token
Parameters:
queryData.id- Document IDqueryData.token.refresh_token- Stored refresh token
Salesforce API: POST https://login.salesforce.com/services/oauth2/token
Request:
POST /services/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&
client_id={SALESFORCE_CLIENT_ID}&
client_secret={SALESFORCE_SECRET_ID}&
redirect_uri={SALESFORCE_REDIRECT_URL}&
refresh_token={refresh_token}
Response:
{
"access_token": "NEW_ACCESS_TOKEN",
"issued_at": "1704844900000",
"instance_url": "https://na1.salesforce.com",
"id": "...",
"token_type": "Bearer",
"signature": "..."
}
Logic:
- Extract refresh_token from stored data
- POST to Salesforce token endpoint
- Update database with new access_token and issued_at
- Keep original refresh_token (not rotated by Salesforce)
- Return new token data
Unlike other OAuth providers, Salesforce refresh tokens don't rotate. The same refresh_token is reused and must be preserved in the database.
Database Model Functionsโ
findQuery(account_id, owner_id)โ
Purpose: Retrieve stored token
Query:
SalesforceKey.findOne({
account_id: account_id.toString(),
owner: owner_id.toString(),
});
Returns:
{
id: ObjectId, // _id converted
account_id: String,
owner: String,
token: {...}
}
saveRefreshToken(id, data)โ
Purpose: Create or update token document
Operation: Upsert with updateOne()
SalesforceKey.updateOne({ _id: new mongoose.Types.ObjectId(id) }, data, {
upsert: true,
setDefaultsOnInsert: true,
});
deleteToken(id)โ
Purpose: Delete token document
SalesforceKey.deleteOne({ _id: new mongoose.Types.ObjectId(id) });
๐ Part 2: CRM Data Exportโ
Data Export Flowโ
sequenceDiagram
participant Client
participant Export as Export Controller
participant Paginator
participant DB as MongoDB
participant SF as Salesforce
Client->>Export: GET /export/contacts?page=1&limit=100
Export->>DB: Fetch token
Export->>SF: COUNT query
SF-->>Export: Total records (e.g., 1500)
Export->>Paginator: Calculate pagination
Paginator-->>Export: Pagination object
Export->>SF: Data query with LIMIT + OFFSET
alt Token valid
SF-->>Export: Records
else Token expired (401)
Export->>SF: Refresh token
SF-->>Export: New access token
Export->>DB: Update token
Export->>SF: Retry query
SF-->>Export: Records
end
Export-->>Client: Data + pagination
Export Functionโ
exportData(req, res, next)โ
Endpoint: GET /v1/integrations/salesforce/export/:type
Path Parameters:
type(String) -contacts,deals, ornotes
Query Parameters:
page(Number, default: 1) - Page numberlimit(Number, optional) - Records per page (default: all records)
Logic:
-
Verify Authentication
if (!req.auth) {
return res.status(401).json({ message: 'Unauthorized User' });
} -
Fetch Stored Token
let query = await DatabaseQuery.findQuery(req.auth.account_id, req.auth.uid);
let accessToken = query.token.access_token; -
Determine API Endpoint and Count Type
const type = req.params.type;
let apiUrl, count_type;
if (type === 'contacts') {
apiUrl = contactsURL;
count_type = 'Contact';
} else if (type === 'deals') {
apiUrl = dealsURL;
count_type = 'opportunity';
} else if (type === 'notes') {
apiUrl = notesURL;
count_type = 'Note';
} -
Get Total Record Count
let paginationData = await axios.get(`${countAllRecordEndpoint}+${count_type}`, {
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
});
// Response: { "totalSize": 1500, "done": true, "records": [...] } -
Calculate Pagination
const getPaginationValue = pagination.paginator(
paginationData.data.totalSize,
pageNo,
limitValue,
); -
Fetch Data with LIMIT and OFFSET
if (parseInt(getPaginationValue.page) <= getPaginationValue.total_pages) {
response = await axios.get(
`${apiUrl} limit ${getPaginationValue.per_page} offset ${getPaginationValue.offsetValue}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
},
);
} -
Handle Token Expiration (401 INVALID_SESSION_ID)
if (
error.response &&
error.response.status === 401 &&
error.response.data[0].errorCode === 'INVALID_SESSION_ID'
) {
// Refresh access token
accessToken = await providers.getRefreshAccessToken(query);
// Retry COUNT query
paginationData = await axios.get(/* ... */);
// Retry data query
response = await axios.get(/* ... */);
} -
Return Response
return res.status(200).json({
success: true,
message: 'SUCCESS',
data: (response && response.data && response.data.records) || [],
pagination: getPaginationValue,
});
Success Response:
{
"success": true,
"message": "SUCCESS",
"data": [
{
"attributes": {
"type": "Contact",
"url": "/services/data/v48.0/sobjects/Contact/003..."
},
"Name": "John Doe",
"Account": { "Name": "Acme Corp" },
"Title": "Director of Marketing",
"Phone": "+1234567890",
"Email": "john.doe@acme.com",
"Owner": { "Alias": "jsmith" }
}
],
"pagination": {
"page": 1,
"per_page": 100,
"total": 1500,
"total_pages": 15,
"next_page": 2,
"prev_page": null,
"offsetValue": 0
}
}
Error Responses:
// Invalid type
{
"success": false,
"errno": 400,
"message": "Please provide valid type. Example contacts and deals"
}
// Token not found
{
"success": false,
"errno": 400,
"message": "Api Token Not Found"
}
// Unauthorized
{
"success": false,
"errno": 400,
"message": "Unauthorized User"
}
Pagination Systemโ
paginator(items, page, per_page)โ
Purpose: Calculate pagination values for SOQL queries
Source: Controllers/pagination.js
Parameters:
items(Number) - Total record countpage(Number) - Requested page numberper_page(Number) - Records per page (optional, defaults to all items)
Returns:
{
page: 1, // Current page
per_page: 100, // Records per page
total: 1500, // Total records
total_pages: 15, // Total pages
next_page: 2, // Next page (null if last)
prev_page: null, // Previous page (null if first)
offsetValue: 0 // SOQL OFFSET value
}
Calculation Logic:
var page = (page && parseInt(page)) || 1;
var per_page = per_page || items; // Default to all items
var offset = (page - 1) * per_page;
var total_pages = Math.ceil(items / per_page);
return {
page: page,
per_page: per_page,
total: items,
total_pages: total_pages,
next_page: page < total_pages ? page + 1 : null,
prev_page: page > 1 ? page - 1 : null,
offsetValue: offset < items ? offset : 0,
};
SOQL Queries with Paginationโ
Contacts Queryโ
Full Query:
SELECT name, account.name, title, phone, email, contact.owner.alias
FROM contact
LIMIT 100 OFFSET 0
Response:
{
"totalSize": 1500,
"done": true,
"records": [
{
"attributes": {
"type": "Contact",
"url": "/services/data/v48.0/sobjects/Contact/003..."
},
"Name": "John Doe",
"Account": { "Name": "Acme Corp" },
"Title": "Director of Marketing",
"Phone": "+1234567890",
"Email": "john.doe@acme.com",
"Owner": { "Alias": "jsmith" }
}
]
}
Opportunities (Deals) Queryโ
Full Query:
SELECT name, account.name, amount, CloseDate, StageName, opportunity.owner.alias
FROM opportunity
LIMIT 100 OFFSET 0
Response:
{
"totalSize": 850,
"done": true,
"records": [
{
"attributes": {
"type": "Opportunity",
"url": "/services/data/v48.0/sobjects/Opportunity/006..."
},
"Name": "Q1 2024 Deal",
"Account": { "Name": "Acme Corp" },
"Amount": 50000,
"CloseDate": "2024-03-31",
"StageName": "Negotiation/Review",
"Owner": { "Alias": "jsmith" }
}
]
}
Notes Queryโ
Full Query:
SELECT body, IsDeleted, IsPrivate, OwnerId, ParentId, Title, note.owner.alias
FROM note
LIMIT 100 OFFSET 0
Response:
{
"totalSize": 320,
"done": true,
"records": [
{
"attributes": {
"type": "Note",
"url": "/services/data/v48.0/sobjects/Note/002..."
},
"Body": "Follow-up call scheduled for next week",
"IsDeleted": false,
"IsPrivate": false,
"OwnerId": "005...",
"ParentId": "003...",
"Title": "Follow-up Call",
"Owner": { "Alias": "jsmith" }
}
]
}
๐งช Edge Cases & Special Handlingโ
Token Expiration During Requestโ
Issue: Access token may expire between checks
Handling: Catch 401 INVALID_SESSION_ID error and refresh token:
if (
error.response &&
error.response.status === 401 &&
error.response.data[0].errorCode === 'INVALID_SESSION_ID'
) {
accessToken = await providers.getRefreshAccessToken(query);
// Retry both COUNT and data queries
}
Invalid Type Parameterโ
Issue: User requests unsupported data type
Handling: Return error with supported types:
if (apiUrl === null) {
return res.status(400).json({
message: 'Please provide valid type. Example contacts and deals',
});
}
Pagination Out of Boundsโ
Issue: Requested page exceeds total_pages
Handling: Only execute query if page is valid:
if (parseInt(getPaginationValue.page) <= getPaginationValue.total_pages) {
response = await axios.get(/* ... */);
}
Returns empty array if page exceeds total.
No Limit Specifiedโ
Issue: User doesn't provide limit parameter
Handling: Default to all records:
var per_page = per_page || items; // Returns all items if no limit
โ ๏ธ Important Notesโ
- ๐ JWT State Tokens: 1-hour expiration prevents CSRF attacks
- ๐ Refresh Token Persistence: Salesforce refresh tokens don't rotate - keep original
- ๐ Two-Query Pattern: COUNT query for pagination + data query with LIMIT/OFFSET
- โฑ๏ธ Automatic Token Refresh: 401 INVALID_SESSION_ID triggers refresh and retry
- ๐ Instance URLs: Update endpoints based on Salesforce org instance (na1, ap5, etc.)
- ๐ No Default Limit: If limit not specified, returns ALL records (use cautiously)
- ๐ Relationship Queries: Dot notation for related objects (account.name, contact.owner.alias)
- ๐จ Error Propagation: Errors passed to Express error middleware via next(error)
๐ Related Documentationโ
- Integration Overview: Salesforce Index
- Salesforce REST API: REST API Developer Guide
- OAuth 2.0: Web Server OAuth Flow
- SOQL Reference: SOQL and SOSL
๐ Quick Start Examplesโ
Authentication Flowโ
# 1. Initiate OAuth
GET /v1/integrations/salesforce/auth/login?forward_url=https://app.dashclicks.com/success
# 2. User authorizes in Salesforce
# 3. Callback handles token exchange automatically
Export All Contacts (Paginated)โ
let page = 1;
let allContacts = [];
while (true) {
const response = await fetch(
`/v1/integrations/salesforce/export/contacts?page=${page}&limit=200`,
{
headers: { Authorization: `Bearer ${jwt}` },
},
);
const result = await response.json();
allContacts.push(...result.data);
if (!result.pagination.next_page) break;
page = result.pagination.next_page;
}
Export Opportunities with Custom Fieldsโ
Modify SALESFORCE_DEALS_ENDPOINT to include custom fields:
SALESFORCE_DEALS_ENDPOINT=https://na1.salesforce.com/services/data/v48.0/query?q=select+name,+amount,+Custom_Field__c+from+opportunity
Then export:
GET /v1/integrations/salesforce/export/deals?page=1&limit=100