Authentication
📋 Overview
Google Business Profile integration uses OAuth 2.0 for authentication. The flow involves redirecting users to Google's consent screen, receiving an authorization code, exchanging it for tokens, and storing them for future API requests.
🔐 OAuth 2.0 Flow
Flow Diagram
1. User initiates connection
↓
2. DashClicks redirects to Google OAuth
↓
3. User grants permissions
↓
4. Google redirects to callback with code
↓
5. Exchange code for tokens
↓
6. Retrieve Google account ID
↓
7. Store tokens in MongoDB
↓
8. Redirect to forward_url with success
🚀 Initiate OAuth Flow
Endpoint
GET /v1/e/integrations/google/business/auth/login
Authentication
Authorization: Bearer {jwt_token}
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
forward_url | String | ✅ Yes | URL to redirect after OAuth completion |
subaccountid | String | ❌ No | Sub-account ID (if integrating for sub-account) |
Request Example
curl -X GET "https://api.dashclicks.com/v1/e/integrations/google/business/auth/login?forward_url=https://app.dashclicks.com/integrations" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Response
HTTP 301 Redirect to Google OAuth consent screen:
https://accounts.google.com/o/oauth2/v2/auth?
redirect_uri=https://api.dashclicks.com/v1/e/integrations/google/business/auth/callback&
prompt=consent&
response_type=code&
client_id={GOOGLE_CLIENT_ID}&
scope=https://www.googleapis.com/auth/business.manage%20https://www.googleapis.com/auth/userinfo.profile%20https://www.googleapis.com/auth/userinfo.email&
access_type=offline&
state={encrypted_jwt_token}
State Parameter
The state parameter contains encrypted information:
const token = jwt.sign(
{
data: 'account_id=' + accountId + '&forward_url=' + forward_url + '&created_by=' + creatorId,
redirect_uri: REDIRECT_URL,
},
process.env.APP_SECRET,
{ algorithm: 'HS256' },
);
Purpose: Prevents CSRF attacks and carries account context through OAuth flow.
🔄 OAuth Callback
Endpoint
GET /v1/e/integrations/google/business/auth/callback
Authentication
None (called by Google)
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
code | String | ✅ Yes | Authorization code from Google |
state | String | ✅ Yes | JWT token from login request |
Request Example
# This request is made by Google, not by your application
GET https://api.dashclicks.com/v1/e/integrations/google/business/auth/callback?code=4/0AY0e-g7...&state=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Processing Flow
1. Validate State Parameter
let payload = '';
try {
payload = jwt.verify(req.query.state, process.env.APP_SECRET);
} catch (error) {
return res.status(403).json({
success: false,
errno: 403,
message: 'Invalid State',
});
}
let stateParams = querystring.parse(payload.data);
const account = stateParams.account_id;
const createdBy = stateParams.created_by;
const forward_url = stateParams.forward_url;
2. Exchange Code for Tokens
const data = {
code: req.query.code,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
redirect_uri: PROXY_URL,
grant_type: 'authorization_code',
};
const resp = await axios.post('https://oauth2.googleapis.com/token', data);
const { access_token, refresh_token } = resp.data;
Response from Google:
{
"access_token": "ya29.a0AfH6SMBx...",
"expires_in": 3599,
"refresh_token": "1//0gCgYIARAAGA...",
"scope": "https://www.googleapis.com/auth/business.manage ...",
"token_type": "Bearer"
}
3. Retrieve Google Account ID
const headers = {
Authorization: `Bearer ${access_token}`,
};
const url = 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts';
const res = await axios.get(url, { headers });
if (res.data.error) {
throw new Error(res.data.error.message);
}
const { accounts } = res.data;
const google_account_id = accounts[0].name.split('/')[1];
API Response:
{
"accounts": [
{
"name": "accounts/1234567890",
"accountName": "My Business",
"type": "PERSONAL",
"role": "OWNER"
}
]
}
4. Store Tokens in MongoDB
const token = {
account_id: account,
google_account_id: google_account_id,
access_token: access_token,
refresh_token: refresh_token,
created_by: new mongoose.Types.ObjectId(createdBy),
};
let businessToken = await GoogleBusinessToken.findOneAndUpdate(
{ account_id: account },
{ access_token, refresh_token, google_account_id },
{ new: true, upsert: true },
);
if (!businessToken) {
businessToken = await new GoogleBusinessToken(token).save();
}
5. Redirect to Forward URL
return res
.status(301)
.redirect(
forward_url +
'?status=success' +
'&integration=google_business' +
'&token=' +
businessToken._id,
);
Success Response
HTTP 301 Redirect to forward URL with query parameters:
https://app.dashclicks.com/integrations?status=success&integration=google_business&token=507f1f77bcf86cd799439011
Error Response
HTTP 301 Redirect with error information:
https://app.dashclicks.com/integrations?status=error&reason=access_denied&integration=google_business
🔑 Token Storage Schema
Document Structure
{
_id: ObjectId("507f1f77bcf86cd799439011"),
account_id: ObjectId("507f191e810c19729de860ea"),
google_account_id: "1234567890",
access_token: "ya29.a0AfH6SMBx...",
refresh_token: "1//0gCgYIARAAGA...",
created_by: ObjectId("507f191e810c19729de860eb"),
createdAt: ISODate("2025-10-10T08:00:00.000Z"),
updatedAt: ISODate("2025-10-10T08:00:00.000Z")
}
Field Descriptions
- account_id: DashClicks account that owns this integration
- google_account_id: Google account ID extracted from account name
- access_token: Short-lived token for API requests (typically 1 hour)
- refresh_token: Long-lived token for obtaining new access tokens
- created_by: User who performed the OAuth flow
- createdAt/updatedAt: Automatic timestamps
🔄 Token Refresh
Access tokens expire after ~1 hour. The integration refreshes tokens on-demand:
Refresh Flow
// 1. Retrieve stored refresh_token
const businessToken = await GoogleBusinessToken.findById(token_id);
const { refresh_token } = businessToken;
// 2. Request new access_token
const data = {
refresh_token: refresh_token,
client_id: GOOGLE_CLIENT_ID,
client_secret: GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token',
};
const resp = await axios.post('https://oauth2.googleapis.com/token', data);
const { access_token } = resp.data;
// 3. Use fresh access_token for API requests
const headers = { Authorization: `Bearer ${access_token}` };
const locationRes = await axios.get(
'https://mybusinessbusinessinformation.googleapis.com/v1/accounts/-/locations',
{ headers },
);
Refresh Response
{
"access_token": "ya29.a0AfH6SMCd...",
"expires_in": 3599,
"scope": "https://www.googleapis.com/auth/business.manage ...",
"token_type": "Bearer"
}
Note: Refresh response does NOT include a new refresh_token - the original refresh token remains valid.
🚫 Token Invalidation
When Tokens Become Invalid
- User revokes access in Google account settings
- Refresh token expires (typically after 6 months of inactivity)
- User changes password (invalidates all tokens)
- OAuth scopes change (requires new consent)
Detection
The integration detects invalidated tokens from API responses:
// Error response from Google
{
"error": {
"code": 401,
"message": "Request had invalid authentication credentials.",
"status": "UNAUTHENTICATED"
}
}
// Or
{
"error": "invalid_grant",
"error_description": "Token has been expired or revoked."
}
Error Handling
if (error?.response?.data?.error?.code) {
let errorCode = error.response.data.error.code;
if (errorCode == '401' || errorCode == '403') {
error.message = 'TOKEN_INVALIDATED';
}
}
if (error?.response?.data?.error && typeof error.response.data.error == 'string') {
if (error.response.data.error == 'invalid_grant') {
error.message = 'TOKEN_INVALIDATED';
}
}
Response to Client
{
"success": false,
"errno": 400,
"message": "TOKEN_INVALIDATED"
}
Solution: User must re-authenticate via /auth/login endpoint.
🔒 Forced Re-authentication
The login endpoint always deletes existing tokens before starting OAuth flow:
try {
await GoogleBusinessToken.deleteOne({ account_id: accountId });
} catch (error) {
logger.error({
initiator: 'GoogleBusiness\\Auth\\Controller',
error: error,
message: 'Error deleting existing Google Business token',
});
// Ignore errors if token doesn't exist
}
Purpose:
- Ensures fresh consent from user
- Prevents stale token issues
- Forces re-validation of permissions
🏢 Sub-account Integration
Request with Sub-account
curl -X GET "https://api.dashclicks.com/v1/e/integrations/google/business/auth/login?forward_url=https://app.dashclicks.com/integrations&subaccountid=507f191e810c19729de860ea" \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
Validation
let accountId = req.auth.account_id; // Parent account from JWT
if (subAccount && subAccount !== '') {
let isValidSubAccount = await accountModel.findOne({
_id: new mongoose.Types.ObjectId(subAccount),
parent_account: new mongoose.Types.ObjectId(accountId.toString()),
});
if (!isValidSubAccount || !isValidSubAccount._id) {
return res.status(400).json({
success: false,
message: 'Invalid SubAccount Id',
});
}
accountId = subAccount; // Use sub-account for token storage
}
Token Storage: Tokens are stored with account_id = sub-account ID, not parent account.
📊 OAuth Scopes Explained
business.manage
Permission: Manage business information, locations, and settings
Required for:
- Listing locations
- Updating business information
- Managing business settings
userinfo.profile
Permission: Access basic profile information
Required for:
- Displaying user's Google account name
- Identifying which Google account is connected
userinfo.email
Permission: Access user's email address
Required for:
- Displaying connected Google email
- Identifying account ownership
⚠️ Error Scenarios
1. Missing Forward URL
{
"success": false,
"errno": 400,
"message": "forward url is required!"
}
Cause: forward_url query parameter not provided
Solution: Always include forward_url in login request
2. Invalid State Parameter
{
"success": false,
"errno": 403,
"message": "Invalid State"
}
Cause:
- State parameter tampered with
- JWT signature invalid
- State parameter expired
Solution: Restart OAuth flow from /auth/login
3. OAuth Code Exchange Failed
Redirect to forward URL with error:
https://app.dashclicks.com/integrations?status=error&reason=invalid_grant&integration=google_business
Cause:
- Authorization code already used
- Authorization code expired
- Invalid client credentials
Solution: Restart OAuth flow
4. Invalid Sub-account
{
"success": false,
"message": "Invalid SubAccount Id"
}
Cause:
- Sub-account doesn't exist
- Sub-account not owned by parent account
- Invalid ObjectId format
Solution: Verify sub-account ID and ownership
5. User Denies Permission
Redirect to forward URL:
https://app.dashclicks.com/integrations?status=error&reason=access_denied&integration=google_business
Cause: User clicked "Deny" on Google consent screen
Solution: Educate user on required permissions and retry
🔐 Security Best Practices
1. Always Use HTTPS
# Production
GOOGLE_BUSINESS_CALLBACK_URL=https://api.dashclicks.com/v1/e/integrations/google/business/auth/callback
# Development (use ngrok or similar)
GOOGLE_BUSINESS_CALLBACK_URL=https://dev.example.com/v1/e/integrations/google/business/auth/callback
2. Validate Redirect URLs
// Only allow whitelisted forward URLs
const allowedDomains = ['app.dashclicks.com', 'staging.dashclicks.com', 'localhost:3000'];
const forwardDomain = new URL(forward_url).hostname;
if (!allowedDomains.includes(forwardDomain)) {
return res.status(400).json({
success: false,
message: 'Invalid forward_url domain',
});
}
3. Secure Token Storage
- ✅ Tokens stored in MongoDB (not frontend)
- ✅ Access tokens never sent to frontend
- ✅ Only token IDs returned to client
- ✅ Refresh tokens encrypted at rest (recommended)
4. State Parameter Protection
- ✅ JWT signed with
APP_SECRET - ✅ Contains account context to prevent account switching
- ✅ Validated on callback before processing
5. Prompt Consent Every Time
prompt = consent; // Force consent screen
Why?: Ensures user is always aware of what permissions they're granting, even on re-authentication.
🎯 Use Cases
1. Initial Connection
// Frontend: Redirect to login endpoint
window.location.href = `/v1/e/integrations/google/business/auth/login?forward_url=${encodeURIComponent(
window.location.href,
)}`;
// Backend: Handle callback and store tokens
// Frontend: Receives redirect with token ID
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('status') === 'success') {
const tokenId = urlParams.get('token');
localStorage.setItem('googleBusinessToken', tokenId);
}
2. Re-authentication After Token Invalidation
// API request returns TOKEN_INVALIDATED
try {
const response = await axios.get('/v1/e/integrations/google/business/account/locations', {
params: { token: tokenId },
});
} catch (error) {
if (error.response?.data?.message === 'TOKEN_INVALIDATED') {
// Redirect to re-authenticate
window.location.href = `/v1/e/integrations/google/business/auth/login?forward_url=${encodeURIComponent(
window.location.href,
)}`;
}
}
3. Sub-account Integration
// Agency integrating Google Business for a client sub-account
const subAccountId = '507f191e810c19729de860ea';
window.location.href = `/v1/e/integrations/google/business/auth/login?forward_url=${encodeURIComponent(
window.location.href,
)}&subaccountid=${subAccountId}`;