Install
openclaw skills install twenty-oauth-masteryClawHub Security found sensitive or high-impact capabilities. Review the scan results before using.
Provides expert OAuth 2.0 implementation, troubleshooting, and token management for Twenty CRM with Google/Microsoft OAuth and email/calendar sync integration.
openclaw skills install twenty-oauth-masteryAuthor: Generated from extensive OAuth debugging sessions in OpenCode
Last Updated: 2026-02-08
Version: 1.0
name: twenty-oauth-mastery
description: Expert-level OAuth authentication knowledge for Twenty CRM including implementation, troubleshooting, and best practices
expertise_level: Expert/Mastery
category: Authentication
applicable_to:
- Twenty CRM authentication
- Google/Microsoft OAuth
- Token refresh management
- Domain restrictions
- Email/Calendar sync integration
prerequisites:
- Knowledge of TypeScript/JavaScript
- Understanding of OAuth 2.0 protocol
- Familiarity with NestJS framework
keywords:
- oauth
- authentication
- twenty-crm
- google-oauth
- microsoft-oauth
- token-refresh
- sync-integration
- domain-restriction
You should use this skill when working on:
✅ Implementing new OAuth providers
✅ Fixing OAuth login issues
✅ Setting up automatic Gmail/Calendar sync after OAuth
✅ Debugging token refresh failures
✅ Configuring domain restrictions
✅ Troubleshooting redirect loops
| Issue | File to Check | Quick Fix |
|---|---|---|
| Redirect loop | auth.service.ts | Rebuild: npx nx build twenty-server |
| .co domain blocked | google-auth.controller.ts | Add to allowlist: ['company.com', 'company.co'] |
| Sync not starting | google.auth.strategy.ts | Return tokens in validate() |
| Cookie not readable | Controller cookie settings | Set httpOnly: false |
| Infinite loop | SignInUpGlobalScopeFormEffect.tsx | Track processed token signatures |
Key Files: twenty/packages/twenty-server/src/engine/core-modules/auth/
Structure:
auth/
├── strategies/ # Passport strategies (Google, Microsoft)
├── controllers/ # OAuth endpoints and callbacks
├── services/ # Auth logic, sync setup, token management
├── guards/ # Auth guards and validation
└── utils/ # Scope configuration, utilities
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(twentyConfigService: TwentyConfigService) {
super({
clientID: twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),
clientSecret: twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'),
callbackURL: twentyConfigService.get('AUTH_GOOGLE_CALLBACK_URL'),
scope: getGoogleApisOauthScopes(),
passReqToCallback: true, // 🔴 CRITICAL: Required for request state
});
}
async validate(
request: GoogleRequest,
_accessToken: string,
_refreshToken: string,
profile: GoogleProfile,
) {
// 🔴 CRITICAL: Include tokens in return object
// Without this, automatic sync setup fails
return {
...profile,
accessToken: _accessToken,
refreshToken: _refreshToken,
hostedDomain: request.query.hosted_domain || profile.emails?.[0]?.value?.split('@')[1],
};
}
}
Why This Matters:
passReqToCallback: true: Enables access to request stateSymptoms: OAuth completes but user stuck on welcome page
Root Causes:
Backend not compiled: Source has fix, container running old JavaScript
Fix:
npx nx build twenty-server
docker restart fratres-twenty
Missing isSingleDomainMode: Redirect logic not in compiled code
Check:
docker exec fratres-twenty cat /app/dist/engine/core-modules/auth/services/auth.service.js | grep isSingleDomainMode
Cookie domain mismatch: Cookie not accessible
Fix:
// auth.service.ts - Remove explicit domain attribute
res.cookie('tokenPair', JSON.stringify(authTokens), {
path: '/',
secure: true,
sameSite: 'lax',
httpOnly: false, // 🔴 Must be false for JavaScript access
});
Symptoms: @company.co rejected, only @company.com allowed
Three Places to Fix:
Google Strategy (google.auth.strategy.ts):
// ❌ WRONG - Hardcoded
hd: 'company.com'
// ✅ CORRECT - Remove hd parameter
// (no hd parameter)
Controller (google-auth.controller.ts):
// ❌ WRONG - Hardcoded check
if (hostedDomain !== 'company.com') { throw ... }
// ✅ CORRECT - Allowlist
const allowedOAuthDomains = ['company.com', 'company.co'];
if (!hostedDomain || !allowedOAuthDomains.includes(hostedDomain)) {
throw new UnauthorizedException(
`Only ${allowedOAuthDomains.map(d => `@${d}`).join(', ')} allowed`
);
}
Database (workspaceMetadata table):
INSERT INTO "workspaceMetadata" ("id", "workspaceId", "key", "value", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), 'workspace-id', 'approvedAccessDomains', '["company.com", "company.co"]', NOW(), NOW());
Symptoms: User logs in but connected account/sync channels not created
Root Cause: Tokens lost in validate() method
Fix:
// google.auth.strategy.ts validate()
async validate(request, accessToken, refreshToken, profile) {
// ❌ WRONG - Tokens lost
return { ...profile };
// ✅ CORRECT - Tokens preserved
return {
...profile,
accessToken,
refreshToken,
};
}
Additional Checks:
auth.service.ts calls oauthSyncService.setupSyncForOAuthUser() after logingmail.readonly and calendar.eventsCALENDAR_PROVIDER_GOOGLE_ENABLED=trueSymptoms: SignInUpGlobalScopeFormEffect runs repeatedly, infinite API calls
Root Cause: Same token processed multiple times
Fix:
// SignInUpGlobalScopeFormEffect.tsx
useEffect(() => {
const tokenPairFromUrl = getAuthPairFromUrl();
if (tokenPairFromUrl) {
const tokenSignature = JSON.stringify(tokenPairFromUrl);
// 🔴 CRITICAL: Skip if already processed
if (processedTokenSignatures.current.has(tokenSignature)) {
return;
}
// Track this signature
processedTokenSignatures.current.add(tokenSignature);
// Now process the token
setAuthTokens(tokenPairFromUrl);
}
}, []);
When to Use: Users should have Gmail/Calendar auto-connected after OAuth login
Implementation:
Create OAuthSyncService:
async setupSyncForOAuthUser(input: {
workspaceId: string;
userId: string;
workspaceMemberId: string;
email: string;
accessToken: string;
refreshToken: string;
scopes: string[];
}) {
// 1. Create/update connected account with tokens
// 2. Create message channel
// 3. Create calendar channel (if enabled)
// 4. Queue initial sync jobs
}
Integrate into AuthService:
// auth.service.ts:signInUpWithSocialSSO()
const { redirectUrl, authTokens } = await this.generateTokens(...);
// 🔴 CRITICAL: Call sync setup BEFORE redirect
if (provider === 'google') {
try {
await this.oauthSyncService.setupSyncForOAuthUser({
workspaceId,
userId,
email: user.email,
accessToken: authTokens.authToken.accessToken,
refreshToken: authTokens.authToken.refreshToken,
scopes: user.scopes || [],
});
} catch (error) {
// Log error but don't fail login
this.logger.error('Failed to setup OAuth sync', error);
}
}
return { redirectUrl, authTokens };
Critical:
Token Refresh Pattern:
async refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {
const oAuth2Client = new google.auth.OAuth2(clientId, clientSecret);
oAuth2Client.setCredentials({ refresh_token: refreshToken });
try {
const { token } = await oAuth2Client.getAccessToken();
// 🔴 CRITICAL: Preserve original refresh token
// Google may not return a new one
return {
accessToken: token,
refreshToken: refreshToken,
};
} catch (error) {
throw parseGoogleOAuthError(error);
}
}
Error Handling:
export const parseGoogleOAuthError = (error: unknown) => {
const gaxiosError = error as GaxiosError;
const code = gaxiosError.response?.status;
const reason = gaxiosError.response?.data?.error;
switch (code) {
case 400:
if (reason === 'invalid_grant') {
// 🔴 FATAL: Refresh token expired/revoked
return new ConnectedAccountRefreshAccessTokenException(
'invalid_grant',
ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,
);
}
break;
case 401:
return new ConnectedAccountRefreshAccessTokenException(
'unauthorized',
ConnectedAccountRefreshAccessTokenExceptionCode.UNAUTHORIZED,
);
case 429:
// 🔴 RETRYABLE: Rate limit error
return new ConnectedAccountRefreshAccessTokenException(
'rate_limit',
ConnectedAccountRefreshAccessTokenExceptionCode.RATE_LIMIT_ERROR,
);
}
return new ConnectedAccountRefreshAccessTokenException('unknown', ...);
};
describe('GoogleAPIRefreshAccessTokenService', () => {
it('should refresh token successfully', async () => {
const mockRefreshToken = 'valid-refresh-token';
const mockNewAccessToken = 'new-access-token';
jest.spyOn(google.auth, 'OAuth2').mockImplementation(() => ({
setCredentials: jest.fn(),
getAccessToken: jest.fn().mockResolvedValue({ token: mockNewAccessToken }),
}));
const result = await service.refreshTokens(mockRefreshToken);
expect(result.accessToken).toBe(mockNewAccessToken);
expect(result.refreshToken).toBe(mockRefreshToken); // Original preserved
});
});
// Test: frontend reads and processes cookie
await context.addCookies([{
name: 'tokenPair',
value: JSON.stringify({ authToken: { accessToken: 'fake-token' } }),
domain: 'isearch.1791technology.com',
path: '/',
secure: true,
sameSite: 'Lax',
}]);
await page.goto('https://isearch.1791technology.com');
// Check console logs
const logs = await page.evaluate(() => window.tokenPairLogs || []);
assert(logs.includes('tokenPairPayload from cookies: found'));
assert(logs.includes('Setting auth tokens...'));
Required Environment Variables:
# Google OAuth
AUTH_GOOGLE_ENABLED=true
AUTH_GOOGLE_CLIENT_ID=849758856044-54v9md2rt6ucthch26p8g4etotcb8gth.apps.googleusercontent.com
AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...
AUTH_GOOGLE_CALLBACK_URL=https://yourdomain.com/auth/google/redirect
# Calendars/Email
CALENDAR_PROVIDER_GOOGLE_ENABLED=true
MESSAGING_PROVIDER_GMAIL_ENABLED=true
# Billing (disable for self-hosted)
IS_BILLING_ENABLED=false
Google Cloud Console:
https://yourdomain.com/auth/google/redirecthttps://yourdomain.comBefore Deploying:
npx nx typecheck twenty-servernpx nx build twenty-servercurl -f /healthzAfter Deploying:
Step 1: Verify Container Running New Code
docker ps | grep fratres-twenty
docker exec fratres-twenty cat /app/dist/engine/core-modules/auth/services/auth.service.js | grep isSingleDomainMode
Step 2: Check Google Cloud Console
Step 3: Check Environment
docker exec fratres-twenty env | grep AUTH_GOOGLE
docker exec fratres-twenty env | grep CALENDAR_PROVIDER
Step 4: Test OAuth Entry Point
curl -v https://yourdomain.com/auth/google | grep Location
# Should redirect to accounts.google.com with correct client_id
Step 5: Check Database (Sync Issues)
-- Check connected accounts
SELECT id, handle, provider, "accessToken" IS NOT NULL
FROM "connectedAccount"
WHERE handle = 'user@example.com';
-- Check sync channels
SELECT id, "syncStatus"
FROM "messageChannel"
WHERE "connectedAccountId" = 'account-id';
Step 6: Check Logs
docker logs fratres-twenty --tail 100 | grep -i oauth
Debug Path:
oauth-sync.service.ts exists and is calledgmail.readonly and calendar.eventsCALENDAR_PROVIDER_GOOGLE_ENABLED=truesyncStatus=ONGOINGCommon Fix: Return tokens in validate() method
Debug Path:
google.auth.strategy.ts for hardcoded hd parametergoogle-auth.controller.ts domain validationauth.service.ts domain allowlistworkspaceMetadata.approvedAccessDomains in databaseCommon Fixes:
hd parameterDebug Path:
isSingleDomainMode logic in auth.service.tsauth.service.js has logiccomputeRedirectURI returns AppPath.IndexhttpOnly attributeCommon Fixes:
npx nx build twenty-serverAppPath.IndexhttpOnly: false on cookie# Build backend
npx nx build twenty-server
# Build frontend
npx nx build twenty-front
# Typecheck
npx nx typecheck twenty-server
# Restart container
docker restart fratres-twenty
# Check logs
docker logs fratres-twenty --tail 100
# Health check
curl -f https://yourdomain.com/healthz
# Test OAuth redirect
curl -v https://yourdomain.com/auth/google
This skill provides expert-level OAuth knowledge for Twenty CRM covering:
Use this skill when: