Назад към всички

twenty-oauth-mastery

// **Author**: Generated from extensive OAuth debugging sessions in OpenCode

$ git log --oneline --stat
stars:1,933
forks:367
updated:March 4, 2026
SKILL.mdreadonly

Twenty 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

IssueFile to CheckQuick Fix
Redirect loopauth.service.tsRebuild: npx nx build twenty-server
.co domain blockedgoogle-auth.controller.tsAdd to allowlist: ['company.com', 'company.co']
Sync not startinggoogle.auth.strategy.tsReturn tokens in validate()
Cookie not readableController cookie settingsSet httpOnly: false
Infinite loopSignInUpGlobalScopeFormEffect.tsxTrack 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:

  • passReqToCallback: true: Enables access to request state
  • 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:

  1. Backend not compiled: Source has fix, container running old JavaScript

    Fix:

    npx nx build twenty-server
    docker restart fratres-twenty
    
  2. 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
    
  3. 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:

  1. Google Strategy (google.auth.strategy.ts):

    // ❌ WRONG - Hardcoded
    hd: 'company.com'
    
    // ✅ CORRECT - Remove hd parameter
    // (no hd parameter)
    
  2. 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`
      );
    }
    
  3. 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());
    

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:

  1. Verify auth.service.ts calls oauthSyncService.setupSyncForOAuthUser() after login
  2. Verify tokens are passed to sync service
  3. Check Google scopes include gmail.readonly and calendar.events
  4. 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:

  1. 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
    }
    
  2. 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 ❌

  1. Forgetting to rebuild - Source changes don't auto-compile
  2. Hardcoding domains - Use allowlists instead
  3. Setting httpOnly: true - Frontend can't read tokenPair cookie
  4. Losing tokens in validate() - Must return accessToken/refreshToken
  5. Not preserving refresh tokens - Google may not return new ones
  6. Missing passReqToCallback: true - Can't access request state
  7. Not testing with real OAuth - Mock tests miss edge cases
  8. Skipping health checks - Container running old code unnoticed

Expert Insights

When OAuth Works But Sync Doesn't

Debug Path:

  1. Check oauth-sync.service.ts exists and is called
  2. Verify tokens passed through validate()
  3. Check scopes include gmail.readonly and calendar.events
  4. Verify CALENDAR_PROVIDER_GOOGLE_ENABLED=true
  5. Check connected account in database
  6. Verify sync channels with syncStatus=ONGOING

Common Fix: Return tokens in validate() method


When .co Domain Users Can't Login

Debug Path:

  1. Check google.auth.strategy.ts for hardcoded hd parameter
  2. Check google-auth.controller.ts domain validation
  3. Check auth.service.ts domain allowlist
  4. Check workspaceMetadata.approvedAccessDomains in database

Common Fixes:

  • Remove hardcoded hd parameter
  • Update controller/service allowlists
  • Insert domain into database

When Frontend Gets Stuck on Welcome Page

Debug Path:

  1. Check isSingleDomainMode logic in auth.service.ts
  2. Check compiled auth.service.js has logic
  3. Check computeRedirectURI returns AppPath.Index
  4. Check cookie httpOnly attribute

Common Fixes:

  • Rebuild backend: npx nx build twenty-server
  • Ensure redirect to dashboard: AppPath.Index
  • Set httpOnly: false on cookie

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:

  1. Architecture: Twenty's OAuth using Passport strategies
  2. Common Issues: 5+ major issues with detailed fixes
  3. Automatic Sync: Gmail/Calendar sync after OAuth
  4. Token Management: Refresh patterns and error handling
  5. Testing: Unit and integration test patterns
  6. Configuration: Required environment variables
  7. Deployment: Step-by-step checklist
  8. 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