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