git clone https://github.com/openclaw/skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/openclaw/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/avirweb/twenty-oauth-mastery" ~/.claude/skills/clawdbot-skills-twenty-oauth-mastery && rm -rf "$T"
skills/avirweb/twenty-oauth-mastery/SKILL.mdTwenty CRM OAuth Mastery Skill
Author: Generated from extensive OAuth debugging sessions in OpenCode
Last Updated: 2026-02-08
Version: 1.0
Skill Metadata
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
Quick Start
When to Use This Skill
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
Quick Reference for Common Issues
| Issue | File to Check | Quick Fix |
|---|---|---|
| Redirect loop | | Rebuild: |
| .co domain blocked | | Add to allowlist: |
| Sync not starting | | Return tokens in validate() |
| Cookie not readable | Controller cookie settings | Set |
| Infinite loop | | Track processed token signatures |
Core Knowledge
1. Twenty CRM OAuth Architecture
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
2. Critical Code Patterns
Passport Strategy Pattern (MUST FOLLOW)
@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:
: Enables access to request statepassReqToCallback: true- Token preservation: Required for OAuthSyncService to work
3. Common Issues & Solutions
Issue 1: Redirect Loop After OAuth
Symptoms: 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 });
Issue 2: Domain Enforcement Blocking .co Users
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 (
table):workspaceMetadataINSERT INTO "workspaceMetadata" ("id", "workspaceId", "key", "value", "createdAt", "updatedAt") VALUES (gen_random_uuid(), 'workspace-id', 'approvedAccessDomains', '["company.com", "company.co"]', NOW(), NOW());
Issue 3: Automatic Sync Not Triggered
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:
- Verify
callsauth.service.ts
after loginoauthSyncService.setupSyncForOAuthUser() - Verify tokens are passed to sync service
- Check Google scopes include
andgmail.readonlycalendar.events - Verify
CALENDAR_PROVIDER_GOOGLE_ENABLED=true
Issue 4: Frontend Token Processing Loop
Symptoms:
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); } }, []);
4. OAuth Sync Integration
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:
- Use try/catch to prevent sync setup from failing login
- Check for existing channels (prevent duplication)
- Only run for specific providers/domains if needed
5. Token Refresh Management
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', ...); };
6. Testing Strategies
Unit Testing (Token Refresh)
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 }); });
Cookie Injection Test (Playwright)
// 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...'));
7. Configuration
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:
- Redirect URIs:
https://yourdomain.com/auth/google/redirect - Authorized Origins:
https://yourdomain.com
8. Deployment Checklist
Before Deploying:
- TypeScript source updated
- Unit tests passing
- Type check:
npx nx typecheck twenty-server - Build:
npx nx build twenty-server - Verify compiled JavaScript has changes (check dist/ folder)
- Copy dist/ to container
- Restart container
- Check health:
curl -f /healthz
After Deploying:
- Test OAuth flow manually
- Check browser console
- Verify redirect to dashboard (not welcome)
- Check connected account in database
- Verify sync channels created (if applicable)
9. Troubleshooting Workflow
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
- Redirect URIs match production URL
- Client ID and secret correct
- OAuth consent screen configured
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
10. Common Pitfalls ❌
- Forgetting to rebuild - Source changes don't auto-compile
- Hardcoding domains - Use allowlists instead
- Setting httpOnly: true - Frontend can't read tokenPair cookie
- Losing tokens in validate() - Must return accessToken/refreshToken
- Not preserving refresh tokens - Google may not return new ones
- Missing passReqToCallback: true - Can't access request state
- Not testing with real OAuth - Mock tests miss edge cases
- Skipping health checks - Container running old code unnoticed
Expert Insights
When OAuth Works But Sync Doesn't
Debug Path:
- Check
exists and is calledoauth-sync.service.ts - Verify tokens passed through validate()
- Check scopes include
andgmail.readonlycalendar.events - Verify
CALENDAR_PROVIDER_GOOGLE_ENABLED=true - Check connected account in database
- Verify sync channels with
syncStatus=ONGOING
Common Fix: Return tokens in validate() method
When .co Domain Users Can't Login
Debug Path:
- Check
for hardcodedgoogle.auth.strategy.ts
parameterhd - Check
domain validationgoogle-auth.controller.ts - Check
domain allowlistauth.service.ts - Check
in databaseworkspaceMetadata.approvedAccessDomains
Common Fixes:
- Remove hardcoded
parameterhd - Update controller/service allowlists
- Insert domain into database
When Frontend Gets Stuck on Welcome Page
Debug Path:
- Check
logic inisSingleDomainModeauth.service.ts - Check compiled
has logicauth.service.js - Check
returnscomputeRedirectURIAppPath.Index - Check cookie
attributehttpOnly
Common Fixes:
- Rebuild backend:
npx nx build twenty-server - Ensure redirect to dashboard:
AppPath.Index - Set
on cookiehttpOnly: false
Quick Commands
# 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
Summary
This skill provides expert-level OAuth knowledge for Twenty CRM covering:
- Architecture: Twenty's OAuth using Passport strategies
- Common Issues: 5+ major issues with detailed fixes
- Automatic Sync: Gmail/Calendar sync after OAuth
- Token Management: Refresh patterns and error handling
- Testing: Unit and integration test patterns
- Configuration: Required environment variables
- Deployment: Step-by-step checklist
- Troubleshooting: Systematic workflow
Use this skill when:
- Implementing new OAuth provider
- Fixing OAuth login issues
- Setting up automatic sync integration
- Debugging token refresh failures
- Configuring domain restrictions
- Troubleshooting redirect loops