How OpenWork Handles OAuth Authentication with Google Workspace: A Complete Technical Guide

OpenWork implements Google Workspace OAuth authentication using a secure, reusable OAuth 2.0 Authorization Code flow with PKCE, centralized in a three-layer architecture that separates provider configuration, flow orchestration, and token management.

OpenWork connects to Google Workspace APIs (Gmail, Calendar, Drive) through a robust OAuth 2.0 implementation. This article breaks down exactly how the authentication flow works, from provider registration to token refresh, based on the actual source code from the different-ai/openwork repository.

OAuth 2.0 Authorization Code Flow Overview

OpenWork uses the classic OAuth 2.0 Authorization Code flow with PKCE (Proof Key for Code Exchange) for enhanced security. The entire flow is abstracted by the native-OAuth provider registry, allowing new providers to be added without changing route code.

The implementation spans three main layers:

  • Provider registry — defines Google Workspace endpoints, scopes, and features
  • OAuth flow implementation — handles connection start, callback, and token exchange
  • Token retrieval and refresh — manages access tokens, automatic refresh, and scope validation

Step 1: Provider Registration in NATIVE_OAUTH_PROVIDERS

Google Workspace is declared as a native OAuth provider in [provider-registry.ts](https://github.com/different-ai/openwork/blob/dev/ee/apps/den-api/src/capability-sources/provider-registry.ts#L19-L55). This configuration centralizes all provider-specific settings.

// ee/apps/den-api/src/capability-sources/provider-registry.ts
export const NATIVE_OAUTH_PROVIDERS: Record<string, NativeOAuthProviderConfig> = {
  "google-workspace": {
    providerId: "google-workspace",
    displayName: "Google Workspace",
    authorizeUrl: env.googleOAuthAuthorizeUrl ?? "https://accounts.google.com/o/oauth2/v2/auth",
    tokenUrl: env.googleOAuthTokenUrl ?? "https://oauth2.googleapis.com/token",
    websiteUrl: "https://workspace.google.com",
    defaultScopes: [
      "openid",
      "https://www.googleapis.com/auth/userinfo.email",
      "https://www.googleapis.com/auth/userinfo.profile",
    ],
    defaultFeatures: ["calendarRead", "gmailDraft", "driveFile"],
    optionalFeatures: {
      calendarRead: ["https://www.googleapis.com/auth/calendar.readonly"],
      calendarWrite: ["https://www.googleapis.com/auth/calendar.events"],
      // …other optional scopes…
    },
    usesPkce: true,
    extraAuthorizeParams: { access_type: "offline", prompt: "consent" },
  },
  // …other providers…
};

Key configuration elements include:

  • usesPkce: true — enables PKCE for secure public client flows
  • extraAuthorizeParams — requests offline access (access_type: "offline") and forces consent prompt to ensure refresh tokens are issued
  • defaultFeatures and optionalFeatures — defines granular scope groups that admins can enable per OAuth client

Step 2: OAuth Client Configuration

Before users can connect, an organization admin must configure an OAuth client. This is done via the /v1/oauth-providers/:providerId/client endpoint, implemented in registerOAuthProviderRoutes in [oauth-providers.ts](https://github.com/different-ai/openwork/blob/dev/ee/apps/den-api/src/routes/org/oauth-providers.ts).

The client record (containing client ID and secret) is stored in the org_oauth_clients table and associated with the organization's Google Cloud project.

Step 3: Initiating the Connection Flow

When a member clicks Connect in the desktop app, the flow begins at GET /v1/oauth-providers/google-workspace/connect/start. The beginNativeProviderConnect function handles this:

// ee/apps/den-api/src/routes/org/oauth-providers.ts
async function beginNativeProviderConnect(input: OrgIds & {
  provider: NativeOAuthProviderConfig;
  request: Request;
}): Promise<{ authorizeUrl: string } | { error: string; message?: string }> {
  const client = await getOrgOAuthClient(input.organizationId, input.provider.providerId);
  if (!client) return { error: "client_not_configured" };

  const { verifier, challenge } = createPkcePair();
  const state = createOAuthStateToken({
    organizationId: input.organizationId,
    orgMembershipId: input.orgMembershipId,
    providerId: input.provider.providerId,
    secret: env.betterAuthSecret,
  });

  const authorizeUrl = buildAuthorizeUrl({
    provider: input.provider,
    client,
    state,
    redirectUri: callbackRedirectUri(input.request, input.provider.providerId),
    codeChallenge: challenge,
  });

  await upsertConnectedAccount({
    organizationId: input.organizationId,
    orgMembershipId: input.orgMembershipId,
    providerId: input.provider.providerId,
    pendingCodeVerifier: verifier,
  });
  return { authorizeUrl };
}

This function:

  1. Verifies the organization has configured an OAuth client
  2. Generates a PKCE verifier and challenge using createPkcePair()
  3. Creates a signed state token with createOAuthStateToken() to prevent CSRF attacks
  4. Builds the Google authorization URL via buildAuthorizeUrl()
  5. Stores the PKCE verifier in a pending connected_accounts row

The desktop app then redirects the user's browser to the returned authorizeUrl.

Step 4: Handling the OAuth Callback

Google redirects the user to GET /v1/oauth-providers/google-workspace/connect/callback after authorization. The callback handler:

  1. Validates the signed state token with verifyOAuthStateToken()
  2. Retrieves the saved OAuth client and pending PKCE verifier
  3. Exchanges the authorization code for access and refresh tokens
// ee/apps/den-api/src/routes/org/oauth-providers.ts (callback handler)
const tokens = await exchangeCodeForTokens({
  provider,
  client,
  code,
  redirectUri: callbackRedirectUri(c.req.raw, providerId),
  codeVerifier: pending.pendingCodeVerifier,
});

await completeConnectedAccountForActiveMember({
  organizationId: statePayload.organizationId,
  orgMembershipId: statePayload.orgMembershipId,
  providerId,
  expectedAccountId: pending.id,
  expectedPendingCodeVerifier: pending.pendingCodeVerifier,
  accessToken: tokens.access_token,
  refreshToken: tokens.refresh_token ?? null,
  tokenType: tokens.token_type ?? null,
  expiresAt: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000) : null,
  scopes: tokens.scope
    ? tokens.scope.split(" ")
    : resolveProviderScopes(provider, clientSelectedFeatures(provider, client.extra)),
  pendingCodeVerifier: null,
});

The exchangeCodeForTokens function in [generic-oauth.ts](https://github.com/different-ai/openwork/blob/dev/ee/apps/den-api/src/capability-sources/generic-oauth.ts) performs the POST to Google's token endpoint. Upon success, completeConnectedAccountForActiveMember persists all token data to the database.

Step 5: Token Usage and Automatic Refresh

Capability routes (Gmail, Calendar, Drive) obtain valid access tokens through the googleWorkspaceToken helper in [google-workspace.ts](https://github.com/different-ai/openwork/blob/dev/ee/apps/den-api/src/routes/org/google-workspace.ts):

// ee/apps/den-api/src/routes/org/google-workspace.ts
async function googleWorkspaceToken(input: {
  organizationId: DenTypeId<"organization">;
  orgMembershipId: DenTypeId<"member">;
}): Promise<GoogleWorkspaceAccessToken> {
  const provider = getNativeOAuthProvider("google-workspace");
  if (!provider) return { kind: "google_api_error", message: "google-workspace provider is not registered." };

  const token = await getValidAccessToken({
    provider,
    organizationId: input.organizationId,
    orgMembershipId: input.orgMembershipId,
  });

  if ("error" in token) {
    return { kind: "needs_connection", message: CONNECT_GOOGLE_ACCOUNT_MESSAGE };
  }
  return { kind: "ok", accessToken: token.accessToken, account: token.account };
}

The getValidAccessToken function in generic-oauth.ts handles:

  • Reading the stored access token from the database
  • Automatic token refresh if expiration is near, using refreshTokens()
  • Returning either valid credentials or a needs_connection error

Step 6: Scope Enforcement

Each capability validates required scopes before calling Google APIs:

if (missingScope(token.account, [GMAIL_READ_SCOPE])) {
  return c.json(
    { error: "needs_connection", message: missingPermissionMessage("Gmail read") },
    409,
  );
}

The missingScope helper checks the granted scopes stored in the connected account record. If scopes are missing, the route returns a 409 Conflict with a user-friendly message directing the admin to enable the required permission feature.

Token Lifecycle: Refresh and Revocation

OpenWork implements several safeguards for credential management:

Aspect Implementation
Automatic refresh getValidAccessToken refreshes tokens on demand when near expiry
Token persistence Refresh tokens and expiry times stored in connected_accounts table
Revocation on client change revokeAccountsBeforeOAuthClientIdentityChange invalidates existing connections when OAuth client or tenant ID changes
CAS updates Compare-and-set style updates in oauth-credentials.ts prevent race conditions during concurrent token refreshes

Architecture Benefits: Adding New Providers

This layered design enables rapid provider expansion. To add a new native OAuth provider (Microsoft 365, Slack, etc.), you only need to:

  1. Add an entry to NATIVE_OAUTH_PROVIDERS in provider-registry.ts
  2. Create capability-specific route files that call getValidAccessToken

The route handlers in oauth-providers.ts automatically work with any registered provider—no code changes required.

Summary

  • OpenWork's Google Workspace OAuth uses Authorization Code flow with PKCE for maximum security
  • Provider configuration is centralized in NATIVE_OAUTH_PROVIDERS in provider-registry.ts
  • The oauth-providers.ts route file handles all provider-agnostic flow logic: client storage, PKCE generation, state tokens, callback processing, and token persistence
  • Core OAuth utilities in generic-oauth.ts manage PKCE, state tokens, URL building, token exchange, and automatic refresh
  • Capability routes in google-workspace.ts use googleWorkspaceToken() for validated, automatically-refreshed access tokens with mandatory scope checks
  • The architecture separates concerns cleanly, allowing new providers to be added by configuration alone

Frequently Asked Questions

What OAuth flow does OpenWork use for Google Workspace?

OpenWork uses the OAuth 2.0 Authorization Code flow with PKCE. This is implemented through the beginNativeProviderConnect function, which generates a PKCE verifier/challenge pair and includes the code challenge in the authorization URL. PKCE provides additional security for public clients like desktop applications.

How does OpenWork handle token refresh automatically?

Token refresh is handled transparently by getValidAccessToken in generic-oauth.ts. When a capability route requests a token, this function checks the expiration timestamp. If the token is near expiry, it calls refreshTokens to exchange the stored refresh token for new access and refresh tokens, then persists the updated credentials before returning them to the caller.

Where are OAuth client credentials stored in OpenWork?

OAuth client credentials (client ID and secret) are stored in the org_oauth_clients database table. They are configured by organization admins through the /v1/oauth-providers/:providerId/client endpoint. Access tokens and refresh tokens for individual users are stored in the connected_accounts table.

What happens when required scopes are missing for a Google Workspace capability?

Each capability route calls missingScope() to validate granted scopes before API calls. If required scopes are missing, the route returns a 409 Conflict response with error: "needs_connection" and a message prompting the admin to enable the specific permission feature in the OAuth client configuration. This enables graceful degradation and clear user guidance.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →