Skip to main content

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​

ParameterTypeRequiredDescription
forward_urlString✅ YesURL to redirect after OAuth completion
subaccountidString❌ NoSub-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​

ParameterTypeRequiredDescription
codeString✅ YesAuthorization code from Google
stateString✅ YesJWT 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​

  1. User revokes access in Google account settings
  2. Refresh token expires (typically after 6 months of inactivity)
  3. User changes password (invalidates all tokens)
  4. 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
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}`;
💬

Documentation Assistant

Ask me anything about the docs

Hi! I'm your documentation assistant. Ask me anything about the docs!

I can help you with:
- Code examples
- Configuration details
- Troubleshooting
- Best practices

Try asking: How do I configure the API?
09:30 AM