Facebook Authentication
The Facebook integration uses OAuth 2.0 with a multi-source architecture that allows separate token management for different use cases (analytics, lead generation, reputation).
OAuth 2.0 Flow
Step 1: Initiate Authentication
Endpoint: GET /v1/e/facebook/auth/login
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
forward_url | String | Yes | URL to redirect after authentication completes |
source | String | Yes | Integration source: meta-ads, facebook-leads, or reputation |
account_id | String | No | Sub-account ID (for agency/sub-account scenarios) |
Example Request:
GET /v1/e/facebook/auth/login?forward_url=https://app.dashclicks.com/analytics&source=meta-ads
Authorization: Bearer <jwt_token>
Response:
HTTP/1.1 301 Moved Permanently
Location: https://www.facebook.com/v18.0/dialog/oauth?client_id=...&state=...
Step 2: User Authorization
User is redirected to Facebook where they:
- Log in to Facebook (if not already logged in)
- Review requested permissions
- Authorize the application
- Get redirected back to callback URL
Step 3: Handle Callback
Endpoint: GET /v1/e/facebook/auth/callback
Query Parameters:
| Parameter | Type | Description |
|---|---|---|
code | String | Authorization code from Facebook |
state | String | JWT token with encrypted authentication data |
Callback Process:
sequenceDiagram
participant FB as Facebook
participant API as DashClicks API
participant DB as MongoDB
participant User as User Browser
FB->>API: GET /callback?code=xxx&state=jwt
API->>API: Verify JWT state token
API->>FB: POST /oauth/access_token (exchange code)
FB->>API: Return short-lived token
API->>FB: POST /oauth/access_token (exchange for long-lived)
FB->>API: Return 60-day token
API->>DB: Store token in integrations.facebookads.key
API->>DB: Send FCM notification to user
API->>User: 301 Redirect to forward_url?status=success&token=<id>
Success Response:
HTTP/1.1 301 Moved Permanently
Location: https://app.dashclicks.com/analytics?status=success&integration=facebookads&token=507f1f77bcf86cd799439011
Error Response:
HTTP/1.1 301 Moved Permanently
Location: https://app.dashclicks.com/analytics?status=error&reason=Invalid+State&integration=facebookads
Function Documentation
loginAuth(req, res, next)
Initiates the OAuth 2.0 flow by redirecting to Facebook's authorization page.
Parameters:
req.query.forward_url(String, required) - Redirect destination after authreq.query.source(String, required) - Integration source typereq.query.account_id(String, optional) - Sub-account IDreq.auth.account_id(ObjectId) - Authenticated account from JWT
Process:
- Validate Account Access: Verify user has access to target account
- Check Existing Tokens: Handle existing integrations based on source:
meta-ads: Always delete existing token (force re-auth)facebook-leads: Return existing token without re-authreputation: Delete existing token (force re-auth)
- Create JWT State Token: Encrypt authentication context
- Redirect to Facebook: Build OAuth URL with scopes and state
JWT State Token Payload:
{
data: '&account_id=<id>&forward_url=<url>&created_by=<id>&source=<source>&user_id=<id>';
}
OAuth URL Construction:
const oauthURL =
`https://www.facebook.com/${FACEBOOK_API_VERSION}/dialog/oauth?` +
`client_id=${FACEBOOK_CLIENT_ID}&` +
`display=popup&` +
`state=${jwtToken}&` +
`response_type=code&` +
`redirect_uri=${FACEBOOK_ADS_REDIRECT_URL}&` +
`scope=public_profile,ads_management,ads_read,pages_show_list,` +
`pages_manage_ads,pages_manage_metadata,business_management,` +
`leads_retrieval,pages_read_engagement,read_insights,` +
`pages_read_user_content,pages_manage_engagement`;
Source-Specific Behavior:
if (source === 'meta-ads') {
// Always delete for analytics - force fresh connection
await facebookModel.update(
{ account_id: accountId, source: source },
{ $set: { deleted: new Date() } },
);
} else if (source === 'facebook-leads') {
// Return existing token for inbound leads
if (searchRecord?._id) {
return res
.status(301)
.redirect(forward_url + '?status=success&integration=facebookads&token=' + searchRecord._id);
}
} else if (source === 'reputation') {
// Delete existing token for reputation management
await facebookModel.update(
{ account_id: accountId, source: source },
{ $set: { deleted: new Date() } },
);
}
Side Effects:
- May soft-delete existing tokens (sets
deletedtimestamp) - Creates JWT state token for security
Error Handling:
- Returns 400 if
forward_urlis missing - Returns 400 if account access check fails
- Redirects to
forward_urlwith error reason on exceptions
Example Implementation:
exports.loginAuth = async (req, res, next) => {
let forward_url;
try {
let accountId = await checkAccountAccess(req);
const user = await User.findOne({ account: accountId, is_owner: true });
let user_id = user?._id;
let creatorId = req.auth.account_id;
forward_url = req.query.forward_url;
const source = req.query.source;
if (!forward_url) {
return res.status(400).json({
success: false,
errno: 400,
message: 'forward url is required!',
});
}
// Check for existing token
const searchRecord = await faceBookAdsKey
.findOne({
account_id: accountId.toString(),
source: source,
deleted: { $exists: false },
})
.sort({ _id: -1 })
.lean()
.exec();
// Source-specific logic...
token = jwt.sign(
{
data: `&${[
`account_id=${accountId}`,
`forward_url=${forward_url}`,
`created_by=${creatorId}`,
`source=${source}`,
...(user_id ? [`user_id=${user_id}`] : []),
].join('&')}`,
},
process.env.APP_SECRET,
{ algorithm: 'HS256' },
);
res
.status(301)
.redirect(
`https://www.facebook.com/${process.env.FACEBOOK_API_VERSION}/dialog/oauth?` +
`client_id=${process.env.FACEBOOK_CLIENT_ID}&` +
`display=popup&state=${token}&response_type=code&` +
`redirect_uri=${process.env.FACEBOOK_ADS_REDIRECT_URL}&` +
`scope=public_profile,ads_management,ads_read,...`,
);
} catch (error) {
// Error handling...
}
};
postApiKey(req, res, next)
Handles the OAuth callback, exchanges authorization code for long-lived token, and stores credentials.
Parameters:
req.query.code(String, required) - Authorization code from Facebookreq.query.state(String, required) - JWT state token from initial request
Process:
- Verify JWT State: Decode and validate state parameter
- Extract Auth Context: Parse account_id, forward_url, source from state
- Check for Duplicate: Prevent duplicate tokens for same account/source
- Exchange Authorization Code: Get short-lived token from Facebook
- Exchange for Long-Lived Token: Convert to 60-day token
- Store Token: Save to
integrations.facebookads.keycollection - Send Notification: FCM notification to account owner
- Redirect: Return to forward_url with success status
Token Exchange:
// Step 1: Exchange authorization code for short-lived token
const authcode = await facebookProvider.getAccessToken(
code,
process.env.FACEBOOK_ADS_REDIRECT_URL
);
// Step 2: Inside getAccessToken - exchange for long-lived token
const longURL = `https://graph.facebook.com/${FACEBOOK_API_VERSION}/oauth/access_token?` +
`grant_type=fb_exchange_token&` +
`client_id=${FACEBOOK_CLIENT_ID}&` +
`client_secret=${FACEBOOK_CLIENT_SECRET}&` +
`fb_exchange_token=${shortToken}`;
const longToken = await axios(longURL);
// Result: 60-day token
{
access_token: 'EAAxxxxxxxx...',
token_type: 'bearer',
expires_in: 5183944 // Seconds until expiration
}
Token Storage:
authcode.expires_in = authcode.expires_in - 600 + Math.floor(Date.now() / 1000);
const dataToSave = {
token: authcode,
source: source,
created_by: new mongoose.Types.ObjectId(creatorId),
account_id: new mongoose.Types.ObjectId(accountId.toString()),
};
const save = await facebookModel.save(dataToSave);
FCM Notification:
const notification = {
title: 'Facebook Ads integration added',
body: 'A new Facebook Ads integration has been added.',
data: {
subType: 'bell', // If bell notifications enabled
},
module: 'projects',
type: 'project_added',
};
await fcm.schedule([accountId], [user_id], notification);
Response Flow:
// Success - redirect with token ID
return res
.status(301)
.redirect(forward_url + '?status=success&integration=facebookads&token=' + save.id);
// Error - redirect with reason
return res
.status(301)
.redirect(forward_url + '?status=error&reason=' + reason + '&integration=facebookads');
Side Effects:
- Creates new document in
integrations.facebookads.key - Sends FCM notification to account owner (if preferences allow)
- May return existing token if duplicate detected
Security:
- JWT state verification prevents CSRF attacks
- Returns 403 if state token is invalid
- Uses HMAC-SHA256 for JWT signing
deleteDocument(req, res, next)
Disconnects the Facebook integration by soft-deleting tokens and cleaning up related data.
Parameters:
req.query.source(String, optional) - Specific source to deletereq.query.global(String, optional) - Set to 'true' for global deletion (all sources)req.query.account_id(String, optional) - Sub-account ID
Process:
- Validate Account Access: Verify user permissions
- Delete Token: Soft-delete by setting
deletedtimestamp - Cleanup Related Data:
meta-ads: Delete analytics configfacebook-leads: Disable all lead campaignsglobal=true: Delete all sources + campaigns + config
- Send Notifications: Email and FCM notifications
Deletion Modes:
// Mode 1: Source-specific deletion
DELETE /v1/e/facebook/auth/delete?source=meta-ads
// Deletes only meta-ads token + analytics config
// Mode 2: Global deletion (all sources)
DELETE /v1/e/facebook/auth/delete?global=true
// Deletes all tokens + campaigns + config
Cleanup Logic:
if (source === 'facebook-leads') {
// Disable all Facebook Ads campaigns
await campaignModel.updateMany(
{
account_id: accountID,
is_deleted: false,
integration: 'facebook_ads',
},
{ $set: { is_deleted: true } },
);
}
if (source === 'meta-ads') {
// Delete analytics configuration
await analyticsuserconfig.deleteOne({ account_id: accountID });
}
// Soft-delete token
await facebookModel.update(
{ account_id: accountID, source: source },
{ $set: { deleted: new Date() } },
);
Global Deletion:
if (global && global === 'true') {
// Disable all campaigns
await campaignModel.updateMany(
{ account_id: accountID, is_deleted: false, integration: 'facebook_ads' },
{ $set: { is_deleted: true } },
);
// Delete config
await analyticsuserconfig.deleteOne({ account_id: accountID });
// Delete ALL tokens (all sources)
await facebookModel.update({ account_id: accountID }, { $set: { deleted: new Date() } });
}
Notification System:
const sendNotifications = async (isProject = false) => {
const accountTarget = isProject ? req.auth.parent_account : accountID;
// Browser/Bell notifications
if (config_notifications.browser || config_notifications.bell) {
await fcm.schedule([accountTarget], [req.auth.uid], {
title: 'Facebook integration is Disconnected',
body: 'Facebook integration has been disconnected.',
module: 'analytics',
type: 'integration_disconnected',
click_action: `${domainName}/analytics`,
});
}
// Email notifications
if (config_notifications.email) {
await new Mail().schedule({
mailBody: {
subject: 'Facebook integration disconnected',
content: 'Facebook integration has been disconnected.',
recipients: [{ name, email, first_name, last_name }],
},
accountID: accountTarget,
userID: req.auth.uid,
});
}
};
// Send to primary account
await sendNotifications();
// Send to parent account if sub-account scenario
if (req?.auth?.parent_account !== accountID) {
await sendNotifications(true);
}
Side Effects:
- Soft-deletes token(s) in
integrations.facebookads.key - May delete
analytics-facebook.ads.userconfigdocument - May disable campaigns in
campaign-datacollection - Sends email and FCM notifications
Response:
{
"success": true,
"message": "SUCCESS"
}
Error Handling:
- Returns 400 if account access check fails
- Preserves existing status code on errors
- Continues execution even if notification sending fails
Token Management
Long-Lived Tokens
Facebook tokens have a 60-day lifespan but can be automatically refreshed:
// Token stored with absolute expiration timestamp
{
access_token: 'EAAxxxxxxxx...',
token_type: 'bearer',
expires_in: 1704844800 // Unix timestamp (not seconds from now)
}
Token Validation & Auto-Refresh
The integration automatically checks and refreshes expired tokens:
static checkAccessTokenValidity = async ({ req, expirationTime, accessToken, docId, source }) => {
const currentTimeStamp = Math.floor(Date.now() / 1000);
if (currentTimeStamp > expirationTime) {
// Token is expired - get new access token
const tokenData = await fbProvider.getNewAccessToken(accessToken);
tokenData.expires_in = tokenData.expires_in + Math.floor(Date.now() / 1000);
const dataToSave = {
token: tokenData,
owner: req.auth.uid.toString(),
account_id: req.auth.account_id.toString()
};
if (source) {
dataToSave.source = source;
// Delete old token
await faceBookAdsKey.deleteMany({ account_id, source });
}
await new faceBookAdsKey(dataToSave).save();
return tokenData.access_token;
}
return accessToken; // Token still valid
};
Token Invalidation Detection
OAuth errors automatically flag tokens as invalid:
// Middleware in index.js
if (error?.response?.data?.error?.code && error?.response?.data?.error?.type) {
let errorCode = error?.response?.data?.error?.code;
let type = error?.response?.data?.error?.type;
// OAuth error codes that indicate token issues
if (
(errorCode == '100' || // Invalid parameter
errorCode == '102' || // Session expired
errorCode == '190' || // Access token invalid
(errorCode >= 200 && errorCode <= 299)) && // Permission errors
type == 'OAuthException'
) {
await faceBookAdsKey.updateMany(
{ account_id: new mongoose.Types.ObjectId(accId.toString()) },
{ $set: { token_invalidated: true } },
);
error.message = 'TOKEN_INVALIDATED';
}
}
Security Considerations
JWT State Parameter
Protects against CSRF attacks by encrypting authentication context:
const stateToken = jwt.sign(
{
data: `&account_id=${accountId}&forward_url=${forward_url}&created_by=${creatorId}&source=${source}`,
},
process.env.APP_SECRET,
{ algorithm: 'HS256' },
);
// On callback:
const payload = jwt.verify(req.query.state, process.env.APP_SECRET);
Sub-Account Support
Validates account access before any operation:
const accountId = await checkAccountAccess(req);
// Returns parent account if sub-account, or current account
Token Storage
- Tokens stored in MongoDB with account-level isolation
- Soft-delete pattern preserves audit trail
token_invalidatedflag prevents using bad tokens
Integration Checklist
Development
- Set up Facebook App in Meta for Developers
- Configure OAuth redirect URI in app settings
- Add required scopes to app review
- Set environment variables
- Test OAuth flow with personal account
- Test token refresh mechanism
- Test source-specific isolation (meta-ads vs facebook-leads)
Production
- Submit app for Facebook review (all required permissions)
- Enable Business Manager integration
- Configure production redirect URI
- Set up token expiration monitoring
- Implement alerting for token invalidation
- Test sub-account scenarios
- Verify notification delivery
Common Issues
Issue: "Invalid OAuth Redirect URI"
Cause: Redirect URI doesn't match app configuration
Solution:
# Ensure exact match in Facebook App settings
FACEBOOK_ADS_REDIRECT_URL=https://api.dashclicks.com/v1/e/facebook/auth/callback
Issue: "Missing Permissions"
Cause: App not approved for required scopes
Solution: Submit app for review with all required permissions explained
Issue: TOKEN_INVALIDATED
Cause: User revoked access or password changed
Solution: User must re-authenticate through OAuth flow
Issue: Duplicate Token Creation
Cause: Multiple concurrent OAuth flows
Solution: Integration checks for existing tokens before creating new ones
Related Documentation
- Facebook Index - Integration overview
- Accounts & Pages - Managing ad accounts and pages
- Campaigns - Campaign operations
- Analytics & Insights - Performance metrics