{
  "schemaVersion": "1.0",
  "item": {
    "slug": "twenty-oauth-mastery",
    "name": "Twenty CRM OAuth Mastery",
    "source": "tencent",
    "type": "skill",
    "category": "效率提升",
    "sourceUrl": "https://clawhub.ai/avirweb/twenty-oauth-mastery",
    "canonicalUrl": "https://clawhub.ai/avirweb/twenty-oauth-mastery",
    "targetPlatform": "OpenClaw"
  },
  "install": {
    "downloadMode": "redirect",
    "downloadUrl": "/downloads/twenty-oauth-mastery",
    "sourceDownloadUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=twenty-oauth-mastery",
    "sourcePlatform": "tencent",
    "targetPlatform": "OpenClaw",
    "installMethod": "Manual import",
    "extraction": "Extract archive",
    "prerequisites": [
      "OpenClaw"
    ],
    "packageFormat": "ZIP package",
    "includedAssets": [
      "README.md",
      "SKILL.md"
    ],
    "primaryDoc": "SKILL.md",
    "quickSetup": [
      "Download the package from Yavira.",
      "Extract the archive and review SKILL.md first.",
      "Import or place the package into your OpenClaw setup."
    ],
    "agentAssist": {
      "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
      "steps": [
        "Download the package from Yavira.",
        "Extract it into a folder your agent can access.",
        "Paste one of the prompts below and point your agent at the extracted folder."
      ],
      "prompts": [
        {
          "label": "New install",
          "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Then review README.md for any prerequisites, environment setup, or post-install checks. Tell me what you changed and call out any manual steps you could not complete."
        },
        {
          "label": "Upgrade existing",
          "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Then review README.md for any prerequisites, environment setup, or post-install checks. Summarize what changed and any follow-up checks I should run."
        }
      ]
    },
    "sourceHealth": {
      "source": "tencent",
      "status": "healthy",
      "reason": "direct_download_ok",
      "recommendedAction": "download",
      "checkedAt": "2026-04-30T16:55:25.780Z",
      "expiresAt": "2026-05-07T16:55:25.780Z",
      "httpStatus": 200,
      "finalUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=network",
      "contentType": "application/zip",
      "probeMethod": "head",
      "details": {
        "probeUrl": "https://wry-manatee-359.convex.site/api/v1/download?slug=network",
        "contentDisposition": "attachment; filename=\"network-1.0.0.zip\"",
        "redirectLocation": null,
        "bodySnippet": null
      },
      "scope": "source",
      "summary": "Source download looks usable.",
      "detail": "Yavira can redirect you to the upstream package for this source.",
      "primaryActionLabel": "Download for OpenClaw",
      "primaryActionHref": "/downloads/twenty-oauth-mastery"
    },
    "validation": {
      "installChecklist": [
        "Use the Yavira download entry.",
        "Review SKILL.md after the package is downloaded.",
        "Confirm the extracted package contains the expected setup assets."
      ],
      "postInstallChecks": [
        "Confirm the extracted package includes the expected docs or setup files.",
        "Validate the skill or prompts are available in your target agent workspace.",
        "Capture any manual follow-up steps the agent could not complete."
      ]
    },
    "downloadPageUrl": "https://openagent3.xyz/downloads/twenty-oauth-mastery",
    "agentPageUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery/agent",
    "manifestUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery/agent.md"
  },
  "agentAssist": {
    "summary": "Hand the extracted package to your coding agent with a concrete install brief instead of figuring it out manually.",
    "steps": [
      "Download the package from Yavira.",
      "Extract it into a folder your agent can access.",
      "Paste one of the prompts below and point your agent at the extracted folder."
    ],
    "prompts": [
      {
        "label": "New install",
        "body": "I downloaded a skill package from Yavira. Read SKILL.md from the extracted folder and install it by following the included instructions. Then review README.md for any prerequisites, environment setup, or post-install checks. Tell me what you changed and call out any manual steps you could not complete."
      },
      {
        "label": "Upgrade existing",
        "body": "I downloaded an updated skill package from Yavira. Read SKILL.md from the extracted folder, compare it with my current installation, and upgrade it while preserving any custom configuration unless the package docs explicitly say otherwise. Then review README.md for any prerequisites, environment setup, or post-install checks. Summarize what changed and any follow-up checks I should run."
      }
    ]
  },
  "documentation": {
    "source": "clawhub",
    "primaryDoc": "SKILL.md",
    "sections": [
      {
        "title": "Twenty CRM OAuth Mastery Skill",
        "body": "Author: Generated from extensive OAuth debugging sessions in OpenCode\nLast Updated: 2026-02-08\nVersion: 1.0"
      },
      {
        "title": "Skill Metadata",
        "body": "name: twenty-oauth-mastery\ndescription: Expert-level OAuth authentication knowledge for Twenty CRM including implementation, troubleshooting, and best practices\nexpertise_level: Expert/Mastery\ncategory: Authentication\napplicable_to:\n  - Twenty CRM authentication\n  - Google/Microsoft OAuth\n  - Token refresh management\n  - Domain restrictions\n  - Email/Calendar sync integration\nprerequisites:\n  - Knowledge of TypeScript/JavaScript\n  - Understanding of OAuth 2.0 protocol\n  - Familiarity with NestJS framework\nkeywords:\n  - oauth\n  - authentication\n  - twenty-crm\n  - google-oauth\n  - microsoft-oauth\n  - token-refresh\n  - sync-integration\n  - domain-restriction"
      },
      {
        "title": "When to Use This Skill",
        "body": "You should use this skill when working on:\n\n✅ Implementing new OAuth providers\n✅ Fixing OAuth login issues\n✅ Setting up automatic Gmail/Calendar sync after OAuth\n✅ Debugging token refresh failures\n✅ Configuring domain restrictions\n✅ Troubleshooting redirect loops"
      },
      {
        "title": "Quick Reference for Common Issues",
        "body": "IssueFile to CheckQuick FixRedirect 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: falseInfinite loopSignInUpGlobalScopeFormEffect.tsxTrack processed token signatures"
      },
      {
        "title": "1. Twenty CRM OAuth Architecture",
        "body": "Key Files: twenty/packages/twenty-server/src/engine/core-modules/auth/\n\nStructure:\n\nauth/\n├── strategies/         # Passport strategies (Google, Microsoft)\n├── controllers/        # OAuth endpoints and callbacks\n├── services/          # Auth logic, sync setup, token management\n├── guards/            # Auth guards and validation\n└── utils/             # Scope configuration, utilities"
      },
      {
        "title": "2. Critical Code Patterns",
        "body": "Passport Strategy Pattern (MUST FOLLOW)\n\n@Injectable()\nexport class GoogleStrategy extends PassportStrategy(Strategy, 'google') {\n  constructor(twentyConfigService: TwentyConfigService) {\n    super({\n      clientID: twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),\n      clientSecret: twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'),\n      callbackURL: twentyConfigService.get('AUTH_GOOGLE_CALLBACK_URL'),\n      scope: getGoogleApisOauthScopes(),\n      passReqToCallback: true, // 🔴 CRITICAL: Required for request state\n    });\n  }\n\n  async validate(\n    request: GoogleRequest,\n    _accessToken: string,\n    _refreshToken: string,\n    profile: GoogleProfile,\n  ) {\n    // 🔴 CRITICAL: Include tokens in return object\n    // Without this, automatic sync setup fails\n    return {\n      ...profile,\n      accessToken: _accessToken,\n      refreshToken: _refreshToken,\n      hostedDomain: request.query.hosted_domain || profile.emails?.[0]?.value?.split('@')[1],\n    };\n  }\n}\n\nWhy This Matters:\n\npassReqToCallback: true: Enables access to request state\nToken preservation: Required for OAuthSyncService to work"
      },
      {
        "title": "3. Common Issues & Solutions",
        "body": "Issue 1: Redirect Loop After OAuth\n\nSymptoms: OAuth completes but user stuck on welcome page\n\nRoot Causes:\n\nBackend not compiled: Source has fix, container running old JavaScript\nFix:\nnpx nx build twenty-server\ndocker restart fratres-twenty\n\n\n\nMissing isSingleDomainMode: Redirect logic not in compiled code\nCheck:\ndocker exec fratres-twenty cat /app/dist/engine/core-modules/auth/services/auth.service.js | grep isSingleDomainMode\n\n\n\nCookie domain mismatch: Cookie not accessible\nFix:\n// auth.service.ts - Remove explicit domain attribute\nres.cookie('tokenPair', JSON.stringify(authTokens), {\n  path: '/',\n  secure: true,\n  sameSite: 'lax',\n  httpOnly: false, // 🔴 Must be false for JavaScript access\n});\n\nIssue 2: Domain Enforcement Blocking .co Users\n\nSymptoms: @company.co rejected, only @company.com allowed\n\nThree Places to Fix:\n\nGoogle Strategy (google.auth.strategy.ts):\n// ❌ WRONG - Hardcoded\nhd: 'company.com'\n\n// ✅ CORRECT - Remove hd parameter\n// (no hd parameter)\n\n\n\nController (google-auth.controller.ts):\n// ❌ WRONG - Hardcoded check\nif (hostedDomain !== 'company.com') { throw ... }\n\n// ✅ CORRECT - Allowlist\nconst allowedOAuthDomains = ['company.com', 'company.co'];\nif (!hostedDomain || !allowedOAuthDomains.includes(hostedDomain)) {\n  throw new UnauthorizedException(\n    `Only ${allowedOAuthDomains.map(d => `@${d}`).join(', ')} allowed`\n  );\n}\n\n\n\nDatabase (workspaceMetadata table):\nINSERT INTO \"workspaceMetadata\" (\"id\", \"workspaceId\", \"key\", \"value\", \"createdAt\", \"updatedAt\")\nVALUES (gen_random_uuid(), 'workspace-id', 'approvedAccessDomains', '[\"company.com\", \"company.co\"]', NOW(), NOW());\n\nIssue 3: Automatic Sync Not Triggered\n\nSymptoms: User logs in but connected account/sync channels not created\n\nRoot Cause: Tokens lost in validate() method\n\nFix:\n\n// google.auth.strategy.ts validate()\nasync validate(request, accessToken, refreshToken, profile) {\n  // ❌ WRONG - Tokens lost\n  return { ...profile };\n  \n  // ✅ CORRECT - Tokens preserved\n  return {\n    ...profile,\n    accessToken,\n    refreshToken,\n  };\n}\n\nAdditional Checks:\n\nVerify auth.service.ts calls oauthSyncService.setupSyncForOAuthUser() after login\nVerify tokens are passed to sync service\nCheck Google scopes include gmail.readonly and calendar.events\nVerify CALENDAR_PROVIDER_GOOGLE_ENABLED=true\n\nIssue 4: Frontend Token Processing Loop\n\nSymptoms: SignInUpGlobalScopeFormEffect runs repeatedly, infinite API calls\n\nRoot Cause: Same token processed multiple times\n\nFix:\n\n// SignInUpGlobalScopeFormEffect.tsx\nuseEffect(() => {\n  const tokenPairFromUrl = getAuthPairFromUrl();\n  \n  if (tokenPairFromUrl) {\n    const tokenSignature = JSON.stringify(tokenPairFromUrl);\n    \n    // 🔴 CRITICAL: Skip if already processed\n    if (processedTokenSignatures.current.has(tokenSignature)) {\n      return;\n    }\n    \n    // Track this signature\n    processedTokenSignatures.current.add(tokenSignature);\n    \n    // Now process the token\n    setAuthTokens(tokenPairFromUrl);\n  }\n}, []);"
      },
      {
        "title": "4. OAuth Sync Integration",
        "body": "When to Use: Users should have Gmail/Calendar auto-connected after OAuth login\n\nImplementation:\n\nCreate OAuthSyncService:\nasync setupSyncForOAuthUser(input: {\n  workspaceId: string;\n  userId: string;\n  workspaceMemberId: string;\n  email: string;\n  accessToken: string;\n  refreshToken: string;\n  scopes: string[];\n}) {\n  // 1. Create/update connected account with tokens\n  // 2. Create message channel\n  // 3. Create calendar channel (if enabled)\n  // 4. Queue initial sync jobs\n}\n\n\n\nIntegrate into AuthService:\n// auth.service.ts:signInUpWithSocialSSO()\nconst { redirectUrl, authTokens } = await this.generateTokens(...);\n\n// 🔴 CRITICAL: Call sync setup BEFORE redirect\nif (provider === 'google') {\n  try {\n    await this.oauthSyncService.setupSyncForOAuthUser({\n      workspaceId,\n      userId,\n      email: user.email,\n      accessToken: authTokens.authToken.accessToken,\n      refreshToken: authTokens.authToken.refreshToken,\n      scopes: user.scopes || [],\n    });\n  } catch (error) {\n    // Log error but don't fail login\n    this.logger.error('Failed to setup OAuth sync', error);\n  }\n}\n\nreturn { redirectUrl, authTokens };\n\nCritical:\n\nUse try/catch to prevent sync setup from failing login\nCheck for existing channels (prevent duplication)\nOnly run for specific providers/domains if needed"
      },
      {
        "title": "5. Token Refresh Management",
        "body": "Token Refresh Pattern:\n\nasync refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {\n  const oAuth2Client = new google.auth.OAuth2(clientId, clientSecret);\n  oAuth2Client.setCredentials({ refresh_token: refreshToken });\n  \n  try {\n    const { token } = await oAuth2Client.getAccessToken();\n    \n    // 🔴 CRITICAL: Preserve original refresh token\n    // Google may not return a new one\n    return {\n      accessToken: token,\n      refreshToken: refreshToken,\n    };\n  } catch (error) {\n    throw parseGoogleOAuthError(error);\n  }\n}\n\nError Handling:\n\nexport const parseGoogleOAuthError = (error: unknown) => {\n  const gaxiosError = error as GaxiosError;\n  const code = gaxiosError.response?.status;\n  const reason = gaxiosError.response?.data?.error;\n  \n  switch (code) {\n    case 400:\n      if (reason === 'invalid_grant') {\n        // 🔴 FATAL: Refresh token expired/revoked\n        return new ConnectedAccountRefreshAccessTokenException(\n          'invalid_grant',\n          ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,\n        );\n      }\n      break;\n    case 401:\n      return new ConnectedAccountRefreshAccessTokenException(\n        'unauthorized',\n        ConnectedAccountRefreshAccessTokenExceptionCode.UNAUTHORIZED,\n      );\n    case 429:\n      // 🔴 RETRYABLE: Rate limit error\n      return new ConnectedAccountRefreshAccessTokenException(\n        'rate_limit',\n        ConnectedAccountRefreshAccessTokenExceptionCode.RATE_LIMIT_ERROR,\n      );\n  }\n  \n  return new ConnectedAccountRefreshAccessTokenException('unknown', ...);\n};"
      },
      {
        "title": "6. Testing Strategies",
        "body": "Unit Testing (Token Refresh)\n\ndescribe('GoogleAPIRefreshAccessTokenService', () => {\n  it('should refresh token successfully', async () => {\n    const mockRefreshToken = 'valid-refresh-token';\n    const mockNewAccessToken = 'new-access-token';\n    \n    jest.spyOn(google.auth, 'OAuth2').mockImplementation(() => ({\n      setCredentials: jest.fn(),\n      getAccessToken: jest.fn().mockResolvedValue({ token: mockNewAccessToken }),\n    }));\n    \n    const result = await service.refreshTokens(mockRefreshToken);\n    \n    expect(result.accessToken).toBe(mockNewAccessToken);\n    expect(result.refreshToken).toBe(mockRefreshToken); // Original preserved\n  });\n});\n\nCookie Injection Test (Playwright)\n\n// Test: frontend reads and processes cookie\nawait context.addCookies([{\n  name: 'tokenPair',\n  value: JSON.stringify({ authToken: { accessToken: 'fake-token' } }),\n  domain: 'isearch.1791technology.com',\n  path: '/',\n  secure: true,\n  sameSite: 'Lax',\n}]);\n\nawait page.goto('https://isearch.1791technology.com');\n\n// Check console logs\nconst logs = await page.evaluate(() => window.tokenPairLogs || []);\nassert(logs.includes('tokenPairPayload from cookies: found'));\nassert(logs.includes('Setting auth tokens...'));"
      },
      {
        "title": "7. Configuration",
        "body": "Required Environment Variables:\n\n# Google OAuth\nAUTH_GOOGLE_ENABLED=true\nAUTH_GOOGLE_CLIENT_ID=849758856044-54v9md2rt6ucthch26p8g4etotcb8gth.apps.googleusercontent.com\nAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...\nAUTH_GOOGLE_CALLBACK_URL=https://yourdomain.com/auth/google/redirect\n\n# Calendars/Email\nCALENDAR_PROVIDER_GOOGLE_ENABLED=true\nMESSAGING_PROVIDER_GMAIL_ENABLED=true\n\n# Billing (disable for self-hosted)\nIS_BILLING_ENABLED=false\n\nGoogle Cloud Console:\n\nRedirect URIs: https://yourdomain.com/auth/google/redirect\nAuthorized Origins: https://yourdomain.com"
      },
      {
        "title": "8. Deployment Checklist",
        "body": "Before Deploying:\n\nTypeScript source updated\n Unit tests passing\n Type check: npx nx typecheck twenty-server\n Build: npx nx build twenty-server\n Verify compiled JavaScript has changes (check dist/ folder)\n Copy dist/ to container\n Restart container\n Check health: curl -f /healthz\n\nAfter Deploying:\n\nTest OAuth flow manually\n Check browser console\n Verify redirect to dashboard (not welcome)\n Check connected account in database\n Verify sync channels created (if applicable)"
      },
      {
        "title": "9. Troubleshooting Workflow",
        "body": "Step 1: Verify Container Running New Code\n\ndocker ps | grep fratres-twenty\ndocker exec fratres-twenty cat /app/dist/engine/core-modules/auth/services/auth.service.js | grep isSingleDomainMode\n\nStep 2: Check Google Cloud Console\n\nRedirect URIs match production URL\nClient ID and secret correct\nOAuth consent screen configured\n\nStep 3: Check Environment\n\ndocker exec fratres-twenty env | grep AUTH_GOOGLE\ndocker exec fratres-twenty env | grep CALENDAR_PROVIDER\n\nStep 4: Test OAuth Entry Point\n\ncurl -v https://yourdomain.com/auth/google | grep Location\n# Should redirect to accounts.google.com with correct client_id\n\nStep 5: Check Database (Sync Issues)\n\n-- Check connected accounts\nSELECT id, handle, provider, \"accessToken\" IS NOT NULL\nFROM \"connectedAccount\"\nWHERE handle = 'user@example.com';\n\n-- Check sync channels\nSELECT id, \"syncStatus\"\nFROM \"messageChannel\"\nWHERE \"connectedAccountId\" = 'account-id';\n\nStep 6: Check Logs\n\ndocker logs fratres-twenty --tail 100 | grep -i oauth"
      },
      {
        "title": "10. Common Pitfalls ❌",
        "body": "Forgetting to rebuild - Source changes don't auto-compile\nHardcoding domains - Use allowlists instead\nSetting httpOnly: true - Frontend can't read tokenPair cookie\nLosing tokens in validate() - Must return accessToken/refreshToken\nNot preserving refresh tokens - Google may not return new ones\nMissing passReqToCallback: true - Can't access request state\nNot testing with real OAuth - Mock tests miss edge cases\nSkipping health checks - Container running old code unnoticed"
      },
      {
        "title": "When OAuth Works But Sync Doesn't",
        "body": "Debug Path:\n\nCheck oauth-sync.service.ts exists and is called\nVerify tokens passed through validate()\nCheck scopes include gmail.readonly and calendar.events\nVerify CALENDAR_PROVIDER_GOOGLE_ENABLED=true\nCheck connected account in database\nVerify sync channels with syncStatus=ONGOING\n\nCommon Fix: Return tokens in validate() method"
      },
      {
        "title": "When .co Domain Users Can't Login",
        "body": "Debug Path:\n\nCheck google.auth.strategy.ts for hardcoded hd parameter\nCheck google-auth.controller.ts domain validation\nCheck auth.service.ts domain allowlist\nCheck workspaceMetadata.approvedAccessDomains in database\n\nCommon Fixes:\n\nRemove hardcoded hd parameter\nUpdate controller/service allowlists\nInsert domain into database"
      },
      {
        "title": "When Frontend Gets Stuck on Welcome Page",
        "body": "Debug Path:\n\nCheck isSingleDomainMode logic in auth.service.ts\nCheck compiled auth.service.js has logic\nCheck computeRedirectURI returns AppPath.Index\nCheck cookie httpOnly attribute\n\nCommon Fixes:\n\nRebuild backend: npx nx build twenty-server\nEnsure redirect to dashboard: AppPath.Index\nSet httpOnly: false on cookie"
      },
      {
        "title": "Quick Commands",
        "body": "# Build backend\nnpx nx build twenty-server\n\n# Build frontend\nnpx nx build twenty-front\n\n# Typecheck\nnpx nx typecheck twenty-server\n\n# Restart container\ndocker restart fratres-twenty\n\n# Check logs\ndocker logs fratres-twenty --tail 100\n\n# Health check\ncurl -f https://yourdomain.com/healthz\n\n# Test OAuth redirect\ncurl -v https://yourdomain.com/auth/google"
      },
      {
        "title": "Summary",
        "body": "This skill provides expert-level OAuth knowledge for Twenty CRM covering:\n\nArchitecture: Twenty's OAuth using Passport strategies\nCommon Issues: 5+ major issues with detailed fixes\nAutomatic Sync: Gmail/Calendar sync after OAuth\nToken Management: Refresh patterns and error handling\nTesting: Unit and integration test patterns\nConfiguration: Required environment variables\nDeployment: Step-by-step checklist\nTroubleshooting: Systematic workflow\n\nUse this skill when:\n\nImplementing new OAuth provider\nFixing OAuth login issues\nSetting up automatic sync integration\nDebugging token refresh failures\nConfiguring domain restrictions\nTroubleshooting redirect loops"
      }
    ],
    "body": "Twenty CRM OAuth Mastery Skill\n\nAuthor: Generated from extensive OAuth debugging sessions in OpenCode\nLast Updated: 2026-02-08\nVersion: 1.0\n\nSkill Metadata\nname: twenty-oauth-mastery\ndescription: Expert-level OAuth authentication knowledge for Twenty CRM including implementation, troubleshooting, and best practices\nexpertise_level: Expert/Mastery\ncategory: Authentication\napplicable_to:\n  - Twenty CRM authentication\n  - Google/Microsoft OAuth\n  - Token refresh management\n  - Domain restrictions\n  - Email/Calendar sync integration\nprerequisites:\n  - Knowledge of TypeScript/JavaScript\n  - Understanding of OAuth 2.0 protocol\n  - Familiarity with NestJS framework\nkeywords:\n  - oauth\n  - authentication\n  - twenty-crm\n  - google-oauth\n  - microsoft-oauth\n  - token-refresh\n  - sync-integration\n  - domain-restriction\n\nQuick Start\nWhen to Use This Skill\n\nYou should use this skill when working on:\n\n✅ Implementing new OAuth providers\n✅ Fixing OAuth login issues\n✅ Setting up automatic Gmail/Calendar sync after OAuth\n✅ Debugging token refresh failures\n✅ Configuring domain restrictions\n✅ Troubleshooting redirect loops\n\nQuick Reference for Common Issues\nIssue\tFile to Check\tQuick Fix\nRedirect loop\tauth.service.ts\tRebuild: npx nx build twenty-server\n.co domain blocked\tgoogle-auth.controller.ts\tAdd to allowlist: ['company.com', 'company.co']\nSync not starting\tgoogle.auth.strategy.ts\tReturn tokens in validate()\nCookie not readable\tController cookie settings\tSet httpOnly: false\nInfinite loop\tSignInUpGlobalScopeFormEffect.tsx\tTrack processed token signatures\nCore Knowledge\n1. Twenty CRM OAuth Architecture\n\nKey Files: twenty/packages/twenty-server/src/engine/core-modules/auth/\n\nStructure:\n\nauth/\n├── strategies/         # Passport strategies (Google, Microsoft)\n├── controllers/        # OAuth endpoints and callbacks\n├── services/          # Auth logic, sync setup, token management\n├── guards/            # Auth guards and validation\n└── utils/             # Scope configuration, utilities\n\n2. Critical Code Patterns\nPassport Strategy Pattern (MUST FOLLOW)\n@Injectable()\nexport class GoogleStrategy extends PassportStrategy(Strategy, 'google') {\n  constructor(twentyConfigService: TwentyConfigService) {\n    super({\n      clientID: twentyConfigService.get('AUTH_GOOGLE_CLIENT_ID'),\n      clientSecret: twentyConfigService.get('AUTH_GOOGLE_CLIENT_SECRET'),\n      callbackURL: twentyConfigService.get('AUTH_GOOGLE_CALLBACK_URL'),\n      scope: getGoogleApisOauthScopes(),\n      passReqToCallback: true, // 🔴 CRITICAL: Required for request state\n    });\n  }\n\n  async validate(\n    request: GoogleRequest,\n    _accessToken: string,\n    _refreshToken: string,\n    profile: GoogleProfile,\n  ) {\n    // 🔴 CRITICAL: Include tokens in return object\n    // Without this, automatic sync setup fails\n    return {\n      ...profile,\n      accessToken: _accessToken,\n      refreshToken: _refreshToken,\n      hostedDomain: request.query.hosted_domain || profile.emails?.[0]?.value?.split('@')[1],\n    };\n  }\n}\n\n\nWhy This Matters:\n\npassReqToCallback: true: Enables access to request state\nToken preservation: Required for OAuthSyncService to work\n3. Common Issues & Solutions\nIssue 1: Redirect Loop After OAuth\n\nSymptoms: OAuth completes but user stuck on welcome page\n\nRoot Causes:\n\nBackend not compiled: Source has fix, container running old JavaScript\n\nFix:\n\nnpx nx build twenty-server\ndocker restart fratres-twenty\n\n\nMissing isSingleDomainMode: Redirect logic not in compiled code\n\nCheck:\n\ndocker exec fratres-twenty cat /app/dist/engine/core-modules/auth/services/auth.service.js | grep isSingleDomainMode\n\n\nCookie domain mismatch: Cookie not accessible\n\nFix:\n\n// auth.service.ts - Remove explicit domain attribute\nres.cookie('tokenPair', JSON.stringify(authTokens), {\n  path: '/',\n  secure: true,\n  sameSite: 'lax',\n  httpOnly: false, // 🔴 Must be false for JavaScript access\n});\n\nIssue 2: Domain Enforcement Blocking .co Users\n\nSymptoms: @company.co rejected, only @company.com allowed\n\nThree Places to Fix:\n\nGoogle Strategy (google.auth.strategy.ts):\n\n// ❌ WRONG - Hardcoded\nhd: 'company.com'\n\n// ✅ CORRECT - Remove hd parameter\n// (no hd parameter)\n\n\nController (google-auth.controller.ts):\n\n// ❌ WRONG - Hardcoded check\nif (hostedDomain !== 'company.com') { throw ... }\n\n// ✅ CORRECT - Allowlist\nconst allowedOAuthDomains = ['company.com', 'company.co'];\nif (!hostedDomain || !allowedOAuthDomains.includes(hostedDomain)) {\n  throw new UnauthorizedException(\n    `Only ${allowedOAuthDomains.map(d => `@${d}`).join(', ')} allowed`\n  );\n}\n\n\nDatabase (workspaceMetadata table):\n\nINSERT INTO \"workspaceMetadata\" (\"id\", \"workspaceId\", \"key\", \"value\", \"createdAt\", \"updatedAt\")\nVALUES (gen_random_uuid(), 'workspace-id', 'approvedAccessDomains', '[\"company.com\", \"company.co\"]', NOW(), NOW());\n\nIssue 3: Automatic Sync Not Triggered\n\nSymptoms: User logs in but connected account/sync channels not created\n\nRoot Cause: Tokens lost in validate() method\n\nFix:\n\n// google.auth.strategy.ts validate()\nasync validate(request, accessToken, refreshToken, profile) {\n  // ❌ WRONG - Tokens lost\n  return { ...profile };\n  \n  // ✅ CORRECT - Tokens preserved\n  return {\n    ...profile,\n    accessToken,\n    refreshToken,\n  };\n}\n\n\nAdditional Checks:\n\nVerify auth.service.ts calls oauthSyncService.setupSyncForOAuthUser() after login\nVerify tokens are passed to sync service\nCheck Google scopes include gmail.readonly and calendar.events\nVerify CALENDAR_PROVIDER_GOOGLE_ENABLED=true\nIssue 4: Frontend Token Processing Loop\n\nSymptoms: SignInUpGlobalScopeFormEffect runs repeatedly, infinite API calls\n\nRoot Cause: Same token processed multiple times\n\nFix:\n\n// SignInUpGlobalScopeFormEffect.tsx\nuseEffect(() => {\n  const tokenPairFromUrl = getAuthPairFromUrl();\n  \n  if (tokenPairFromUrl) {\n    const tokenSignature = JSON.stringify(tokenPairFromUrl);\n    \n    // 🔴 CRITICAL: Skip if already processed\n    if (processedTokenSignatures.current.has(tokenSignature)) {\n      return;\n    }\n    \n    // Track this signature\n    processedTokenSignatures.current.add(tokenSignature);\n    \n    // Now process the token\n    setAuthTokens(tokenPairFromUrl);\n  }\n}, []);\n\n4. OAuth Sync Integration\n\nWhen to Use: Users should have Gmail/Calendar auto-connected after OAuth login\n\nImplementation:\n\nCreate OAuthSyncService:\n\nasync setupSyncForOAuthUser(input: {\n  workspaceId: string;\n  userId: string;\n  workspaceMemberId: string;\n  email: string;\n  accessToken: string;\n  refreshToken: string;\n  scopes: string[];\n}) {\n  // 1. Create/update connected account with tokens\n  // 2. Create message channel\n  // 3. Create calendar channel (if enabled)\n  // 4. Queue initial sync jobs\n}\n\n\nIntegrate into AuthService:\n\n// auth.service.ts:signInUpWithSocialSSO()\nconst { redirectUrl, authTokens } = await this.generateTokens(...);\n\n// 🔴 CRITICAL: Call sync setup BEFORE redirect\nif (provider === 'google') {\n  try {\n    await this.oauthSyncService.setupSyncForOAuthUser({\n      workspaceId,\n      userId,\n      email: user.email,\n      accessToken: authTokens.authToken.accessToken,\n      refreshToken: authTokens.authToken.refreshToken,\n      scopes: user.scopes || [],\n    });\n  } catch (error) {\n    // Log error but don't fail login\n    this.logger.error('Failed to setup OAuth sync', error);\n  }\n}\n\nreturn { redirectUrl, authTokens };\n\n\nCritical:\n\nUse try/catch to prevent sync setup from failing login\nCheck for existing channels (prevent duplication)\nOnly run for specific providers/domains if needed\n5. Token Refresh Management\n\nToken Refresh Pattern:\n\nasync refreshTokens(refreshToken: string): Promise<ConnectedAccountTokens> {\n  const oAuth2Client = new google.auth.OAuth2(clientId, clientSecret);\n  oAuth2Client.setCredentials({ refresh_token: refreshToken });\n  \n  try {\n    const { token } = await oAuth2Client.getAccessToken();\n    \n    // 🔴 CRITICAL: Preserve original refresh token\n    // Google may not return a new one\n    return {\n      accessToken: token,\n      refreshToken: refreshToken,\n    };\n  } catch (error) {\n    throw parseGoogleOAuthError(error);\n  }\n}\n\n\nError Handling:\n\nexport const parseGoogleOAuthError = (error: unknown) => {\n  const gaxiosError = error as GaxiosError;\n  const code = gaxiosError.response?.status;\n  const reason = gaxiosError.response?.data?.error;\n  \n  switch (code) {\n    case 400:\n      if (reason === 'invalid_grant') {\n        // 🔴 FATAL: Refresh token expired/revoked\n        return new ConnectedAccountRefreshAccessTokenException(\n          'invalid_grant',\n          ConnectedAccountRefreshAccessTokenExceptionCode.INVALID_REFRESH_TOKEN,\n        );\n      }\n      break;\n    case 401:\n      return new ConnectedAccountRefreshAccessTokenException(\n        'unauthorized',\n        ConnectedAccountRefreshAccessTokenExceptionCode.UNAUTHORIZED,\n      );\n    case 429:\n      // 🔴 RETRYABLE: Rate limit error\n      return new ConnectedAccountRefreshAccessTokenException(\n        'rate_limit',\n        ConnectedAccountRefreshAccessTokenExceptionCode.RATE_LIMIT_ERROR,\n      );\n  }\n  \n  return new ConnectedAccountRefreshAccessTokenException('unknown', ...);\n};\n\n6. Testing Strategies\nUnit Testing (Token Refresh)\ndescribe('GoogleAPIRefreshAccessTokenService', () => {\n  it('should refresh token successfully', async () => {\n    const mockRefreshToken = 'valid-refresh-token';\n    const mockNewAccessToken = 'new-access-token';\n    \n    jest.spyOn(google.auth, 'OAuth2').mockImplementation(() => ({\n      setCredentials: jest.fn(),\n      getAccessToken: jest.fn().mockResolvedValue({ token: mockNewAccessToken }),\n    }));\n    \n    const result = await service.refreshTokens(mockRefreshToken);\n    \n    expect(result.accessToken).toBe(mockNewAccessToken);\n    expect(result.refreshToken).toBe(mockRefreshToken); // Original preserved\n  });\n});\n\nCookie Injection Test (Playwright)\n// Test: frontend reads and processes cookie\nawait context.addCookies([{\n  name: 'tokenPair',\n  value: JSON.stringify({ authToken: { accessToken: 'fake-token' } }),\n  domain: 'isearch.1791technology.com',\n  path: '/',\n  secure: true,\n  sameSite: 'Lax',\n}]);\n\nawait page.goto('https://isearch.1791technology.com');\n\n// Check console logs\nconst logs = await page.evaluate(() => window.tokenPairLogs || []);\nassert(logs.includes('tokenPairPayload from cookies: found'));\nassert(logs.includes('Setting auth tokens...'));\n\n7. Configuration\n\nRequired Environment Variables:\n\n# Google OAuth\nAUTH_GOOGLE_ENABLED=true\nAUTH_GOOGLE_CLIENT_ID=849758856044-54v9md2rt6ucthch26p8g4etotcb8gth.apps.googleusercontent.com\nAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-...\nAUTH_GOOGLE_CALLBACK_URL=https://yourdomain.com/auth/google/redirect\n\n# Calendars/Email\nCALENDAR_PROVIDER_GOOGLE_ENABLED=true\nMESSAGING_PROVIDER_GMAIL_ENABLED=true\n\n# Billing (disable for self-hosted)\nIS_BILLING_ENABLED=false\n\n\nGoogle Cloud Console:\n\nRedirect URIs: https://yourdomain.com/auth/google/redirect\nAuthorized Origins: https://yourdomain.com\n8. Deployment Checklist\n\nBefore Deploying:\n\n TypeScript source updated\n Unit tests passing\n Type check: npx nx typecheck twenty-server\n Build: npx nx build twenty-server\n Verify compiled JavaScript has changes (check dist/ folder)\n Copy dist/ to container\n Restart container\n Check health: curl -f /healthz\n\nAfter Deploying:\n\n Test OAuth flow manually\n Check browser console\n Verify redirect to dashboard (not welcome)\n Check connected account in database\n Verify sync channels created (if applicable)\n9. Troubleshooting Workflow\n\nStep 1: Verify Container Running New Code\n\ndocker ps | grep fratres-twenty\ndocker exec fratres-twenty cat /app/dist/engine/core-modules/auth/services/auth.service.js | grep isSingleDomainMode\n\n\nStep 2: Check Google Cloud Console\n\nRedirect URIs match production URL\nClient ID and secret correct\nOAuth consent screen configured\n\nStep 3: Check Environment\n\ndocker exec fratres-twenty env | grep AUTH_GOOGLE\ndocker exec fratres-twenty env | grep CALENDAR_PROVIDER\n\n\nStep 4: Test OAuth Entry Point\n\ncurl -v https://yourdomain.com/auth/google | grep Location\n# Should redirect to accounts.google.com with correct client_id\n\n\nStep 5: Check Database (Sync Issues)\n\n-- Check connected accounts\nSELECT id, handle, provider, \"accessToken\" IS NOT NULL\nFROM \"connectedAccount\"\nWHERE handle = 'user@example.com';\n\n-- Check sync channels\nSELECT id, \"syncStatus\"\nFROM \"messageChannel\"\nWHERE \"connectedAccountId\" = 'account-id';\n\n\nStep 6: Check Logs\n\ndocker logs fratres-twenty --tail 100 | grep -i oauth\n\n10. Common Pitfalls ❌\nForgetting to rebuild - Source changes don't auto-compile\nHardcoding domains - Use allowlists instead\nSetting httpOnly: true - Frontend can't read tokenPair cookie\nLosing tokens in validate() - Must return accessToken/refreshToken\nNot preserving refresh tokens - Google may not return new ones\nMissing passReqToCallback: true - Can't access request state\nNot testing with real OAuth - Mock tests miss edge cases\nSkipping health checks - Container running old code unnoticed\nExpert Insights\nWhen OAuth Works But Sync Doesn't\n\nDebug Path:\n\nCheck oauth-sync.service.ts exists and is called\nVerify tokens passed through validate()\nCheck scopes include gmail.readonly and calendar.events\nVerify CALENDAR_PROVIDER_GOOGLE_ENABLED=true\nCheck connected account in database\nVerify sync channels with syncStatus=ONGOING\n\nCommon Fix: Return tokens in validate() method\n\nWhen .co Domain Users Can't Login\n\nDebug Path:\n\nCheck google.auth.strategy.ts for hardcoded hd parameter\nCheck google-auth.controller.ts domain validation\nCheck auth.service.ts domain allowlist\nCheck workspaceMetadata.approvedAccessDomains in database\n\nCommon Fixes:\n\nRemove hardcoded hd parameter\nUpdate controller/service allowlists\nInsert domain into database\nWhen Frontend Gets Stuck on Welcome Page\n\nDebug Path:\n\nCheck isSingleDomainMode logic in auth.service.ts\nCheck compiled auth.service.js has logic\nCheck computeRedirectURI returns AppPath.Index\nCheck cookie httpOnly attribute\n\nCommon Fixes:\n\nRebuild backend: npx nx build twenty-server\nEnsure redirect to dashboard: AppPath.Index\nSet httpOnly: false on cookie\nQuick Commands\n# Build backend\nnpx nx build twenty-server\n\n# Build frontend\nnpx nx build twenty-front\n\n# Typecheck\nnpx nx typecheck twenty-server\n\n# Restart container\ndocker restart fratres-twenty\n\n# Check logs\ndocker logs fratres-twenty --tail 100\n\n# Health check\ncurl -f https://yourdomain.com/healthz\n\n# Test OAuth redirect\ncurl -v https://yourdomain.com/auth/google\n\nSummary\n\nThis skill provides expert-level OAuth knowledge for Twenty CRM covering:\n\nArchitecture: Twenty's OAuth using Passport strategies\nCommon Issues: 5+ major issues with detailed fixes\nAutomatic Sync: Gmail/Calendar sync after OAuth\nToken Management: Refresh patterns and error handling\nTesting: Unit and integration test patterns\nConfiguration: Required environment variables\nDeployment: Step-by-step checklist\nTroubleshooting: Systematic workflow\n\nUse this skill when:\n\nImplementing new OAuth provider\nFixing OAuth login issues\nSetting up automatic sync integration\nDebugging token refresh failures\nConfiguring domain restrictions\nTroubleshooting redirect loops"
  },
  "trust": {
    "sourceLabel": "tencent",
    "provenanceUrl": "https://clawhub.ai/avirweb/twenty-oauth-mastery",
    "publisherUrl": "https://clawhub.ai/avirweb/twenty-oauth-mastery",
    "owner": "avirweb",
    "version": "1.0.0",
    "license": null,
    "verificationStatus": "Indexed source record"
  },
  "links": {
    "detailUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery",
    "downloadUrl": "https://openagent3.xyz/downloads/twenty-oauth-mastery",
    "agentUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery/agent",
    "manifestUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery/agent.json",
    "briefUrl": "https://openagent3.xyz/skills/twenty-oauth-mastery/agent.md"
  }
}