diff --git a/.env.development b/.env.development index 0525dbd8..39f49d2e 100644 --- a/.env.development +++ b/.env.development @@ -14,10 +14,6 @@ CTAGS_COMMAND=ctags AUTH_SECRET="00000000000000000000000000000000000000000000" AUTH_URL="http://localhost:3000" # AUTH_CREDENTIALS_LOGIN_ENABLED=true -# AUTH_EE_GITHUB_CLIENT_ID="" -# AUTH_EE_GITHUB_CLIENT_SECRET="" -# AUTH_EE_GOOGLE_CLIENT_ID="" -# AUTH_EE_GOOGLE_CLIENT_SECRET="" DATA_CACHE_DIR=${PWD}/.sourcebot # Path to the sourcebot cache dir (ex. ~/sourcebot/.sourcebot) SOURCEBOT_PUBLIC_KEY_PATH=${PWD}/public.pem diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4956e6..ca43800d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed issue where certain file and folder names would cause type errors. [#862](https://github.com/sourcebot-dev/sourcebot/pull/862) +- Fixed token refresh error "Provider config not found or invalid for: x" when a sso is configured using deprecated env vars. [#841](https://github.com/sourcebot-dev/sourcebot/pull/841) ## [4.10.27] - 2026-02-05 diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 580de359..7f887305 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -41,25 +41,7 @@ The following environment variables allow you to configure your Sourcebot deploy | `HTTP_PROXY` | - |

HTTP proxy URL for routing non-SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.

| | `HTTPS_PROXY` | - |

HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.

| | `NO_PROXY` | - |

Comma-separated list of hostnames or domains that should bypass the proxy (e.g., `localhost,127.0.0.1,.internal.domain`). Requires `NODE_USE_ENV_PROXY=1`.

| - -### Enterprise Environment Variables -| Variable | Default | Description | -| :------- | :------ | :---------- | | `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` |

Enables/disables audit logging

| -| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` |

The base URL for GitHub Enterprise SSO authentication.

| -| `AUTH_EE_GITHUB_CLIENT_ID` | `-` |

The client ID for GitHub Enterprise SSO authentication.

| -| `AUTH_EE_GITHUB_CLIENT_SECRET` | `-` |

The client secret for GitHub Enterprise SSO authentication.

| -| `AUTH_EE_GITLAB_BASE_URL` | `https://gitlab.com` |

The base URL for GitLab Enterprise SSO authentication.

| -| `AUTH_EE_GITLAB_CLIENT_ID` | `-` |

The client ID for GitLab Enterprise SSO authentication.

| -| `AUTH_EE_GITLAB_CLIENT_SECRET` | `-` |

The client secret for GitLab Enterprise SSO authentication.

| -| `AUTH_EE_GOOGLE_CLIENT_ID` | `-` |

The client ID for Google SSO authentication.

| -| `AUTH_EE_GOOGLE_CLIENT_SECRET` | `-` |

The client secret for Google SSO authentication.

| -| `AUTH_EE_KEYCLOAK_CLIENT_ID` | `-` |

The client ID for Keycloak SSO authentication.

| -| `AUTH_EE_KEYCLOAK_CLIENT_SECRET` | `-` |

The client secret for Keycloak SSO authentication.

| -| `AUTH_EE_KEYCLOAK_ISSUER` | `-` |

The issuer URL for Keycloak SSO authentication.

| -| `AUTH_EE_OKTA_CLIENT_ID` | `-` |

The client ID for Okta SSO authentication.

| -| `AUTH_EE_OKTA_CLIENT_SECRET` | `-` |

The client secret for Okta SSO authentication.

| -| `AUTH_EE_OKTA_ISSUER` | `-` |

The issuer URL for Okta SSO authentication.

| | `AUTH_EE_GCP_IAP_ENABLED` | `false` |

When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect

| | `AUTH_EE_GCP_IAP_AUDIENCE` | - |

The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning

| | `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` |

Enables [permission syncing](/docs/features/permission-syncing).

| diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index 2feeb577..cb801288 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -51,7 +51,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp Prerequisites: - Configure GitHub as an [external identity provider](/docs/configuration/idp). -- **If you are using a self-hosted GitHub instance**, you must also set `AUTH_EE_GITHUB_BASE_URL` to the base URL of your GitHub instance (e.g. `https://github.example.com`). +- **If you are using a self-hosted GitHub instance**, you must also set the `baseUrl` property of the `github` identity provider in the [config file](/docs/configuration/config-file) to the base URL of your GitHub instance (e.g. `https://github.example.com`). Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot: - Outside collaborators @@ -68,7 +68,7 @@ Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and * Prerequisites: - Configure GitLab as an [external identity provider](/docs/configuration/idp). -- **If you are using a self-hosted GitLab instance**, you must also set `AUTH_EE_GITLAB_BASE_URL` to the base URL of your GitLab instance (e.g. `https://gitlab.example.com`). +- **If you are using a self-hosted GitLab instance**, you must also set the `baseUrl` property of the `gitlab` identity provider in the [config file](/docs/configuration/config-file) to the base URL of your GitLab instance (e.g. `https://gitlab.example.com`). Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. Users with **Guest** role or above with membership to a group or project will have their access synced to Sourcebot. Both direct and indirect membership to a group or project will be synced with Sourcebot. For more details, see the [GitLab docs](https://docs.gitlab.com/user/project/members/#membership-types). diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 9ed1dbc1..a3a98dfa 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -141,35 +141,11 @@ export const env = createEnv({ AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'), // Enterprise Auth - AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING: booleanSchema .default('false') .describe('When enabled, different SSO accounts with the same email address will automatically be linked.'), - AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(), - AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(), - AUTH_EE_GITHUB_BASE_URL: z.string().optional(), - - AUTH_EE_GITLAB_CLIENT_ID: z.string().optional(), - AUTH_EE_GITLAB_CLIENT_SECRET: z.string().optional(), - AUTH_EE_GITLAB_BASE_URL: z.string().default("https://gitlab.com"), - - AUTH_EE_GOOGLE_CLIENT_ID: z.string().optional(), - AUTH_EE_GOOGLE_CLIENT_SECRET: z.string().optional(), - - AUTH_EE_OKTA_CLIENT_ID: z.string().optional(), - AUTH_EE_OKTA_CLIENT_SECRET: z.string().optional(), - AUTH_EE_OKTA_ISSUER: z.string().optional(), - - AUTH_EE_KEYCLOAK_CLIENT_ID: z.string().optional(), - AUTH_EE_KEYCLOAK_CLIENT_SECRET: z.string().optional(), - AUTH_EE_KEYCLOAK_ISSUER: z.string().optional(), - - AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID: z.string().optional(), - AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET: z.string().optional(), - AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER: z.string().optional(), - AUTH_EE_GCP_IAP_ENABLED: booleanSchema.default('false'), AUTH_EE_GCP_IAP_AUDIENCE: z.string().optional(), @@ -297,6 +273,92 @@ export const env = createEnv({ // A comma separated list of glob patterns that shwould always be indexed regardless of their size. ALWAYS_INDEX_FILE_PATTERNS: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GITHUB_CLIENT_ID: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GITHUB_CLIENT_SECRET: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GITHUB_BASE_URL: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GITLAB_CLIENT_ID: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GITLAB_CLIENT_SECRET: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GITLAB_BASE_URL: z.string().default("https://gitlab.com"), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GOOGLE_CLIENT_ID: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_GOOGLE_CLIENT_SECRET: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_OKTA_CLIENT_ID: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_OKTA_CLIENT_SECRET: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_OKTA_ISSUER: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_KEYCLOAK_CLIENT_ID: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_KEYCLOAK_CLIENT_SECRET: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_KEYCLOAK_ISSUER: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_ID: z.string().optional(), + + /** + * @deprecated + * This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_MICROSOFT_ENTRA_ID_CLIENT_SECRET: z.string().optional(), + + /** + * @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead. + */ + AUTH_EE_MICROSOFT_ENTRA_ID_ISSUER: z.string().optional(), }, runtimeEnv, emptyStringAsUndefined: true, diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index d7bc51d3..d5ba9f02 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -26,11 +26,11 @@ export { } from "./utils.js"; export * from "./constants.js"; export { - env, resolveEnvironmentVariableOverridesFromConfig, loadConfig, isRemotePath, } from "./env.server.js"; +export { env } from "./env.server.js" export { createLogger, } from "./logger.js"; diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts index aac991e5..76b7123c 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -1,18 +1,42 @@ import { loadConfig, decryptOAuthToken } from "@sourcebot/shared"; import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared"; import { GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; -const { prisma } = await import('@/prisma'); +import { z } from 'zod'; +import { prisma } from '@/prisma'; const logger = createLogger('web-ee-token-refresh'); // Map of providerAccountId -> error message export type LinkedAccountErrors = Record; +// In-memory lock to prevent concurrent refresh attempts for the same user +const refreshLocks = new Map>(); + /** * Refreshes expiring OAuth tokens for all linked accounts of a user. * Loads accounts from database, refreshes tokens as needed, and returns any errors. + * Uses an in-memory lock to prevent concurrent refresh attempts for the same user. */ -export async function refreshLinkedAccountTokens(userId: string): Promise { +export const refreshLinkedAccountTokens = async (userId: string): Promise => { + // Check if there's already an in-flight refresh for this user + const existingRefresh = refreshLocks.get(userId); + if (existingRefresh) { + return existingRefresh; + } + + // Create the refresh promise and store it in the lock map + const refreshPromise = doRefreshLinkedAccountTokens(userId); + refreshLocks.set(userId, refreshPromise); + + try { + return await refreshPromise; + } finally { + refreshLocks.delete(userId); + } +}; + +const doRefreshLinkedAccountTokens = async (userId: string): Promise => { + // Only grab accounts that can be refreshed (i.e., have an access token, refresh token, and expires_at). const accounts = await prisma.account.findMany({ where: { userId, @@ -41,7 +65,7 @@ export async function refreshLinkedAccountTokens(userId: string): Promise= (expires_at - bufferTimeS)) { + if (expires_at !== null && expires_at > 0 && now >= (expires_at - bufferTimeS)) { const refreshToken = decryptOAuthToken(account.refresh_token); if (!refreshToken) { logger.error(`Failed to decrypt refresh token for providerAccountId: ${providerAccountId}`); @@ -51,9 +75,11 @@ export async function refreshLinkedAccountTokens(userId: string): Promise { +): Promise => { try { const config = await loadConfig(env.CONFIG_PATH); const identityProviders = config?.identityProviders ?? []; const providerConfigs = identityProviders.filter(idp => idp.provider === provider); + + // If no provider configs in the config file, try deprecated env vars if (providerConfigs.length === 0) { + const envCredentials = getDeprecatedEnvCredentials(provider); + if (envCredentials) { + logger.debug(`Using deprecated env vars for ${provider} token refresh`); + const result = await tryRefreshToken(provider, refreshToken, envCredentials); + if (result) { + return result; + } + logger.error(`Failed to refresh ${provider} token using deprecated env credentials`); + return null; + } logger.error(`Provider config not found or invalid for: ${provider}`); return null; } @@ -110,60 +153,12 @@ export async function refreshOAuthToken( const linkedAccountProviderConfig = providerConfig as GitHubIdentityProviderConfig | GitLabIdentityProviderConfig const clientId = await getTokenFromConfig(linkedAccountProviderConfig.clientId); const clientSecret = await getTokenFromConfig(linkedAccountProviderConfig.clientSecret); - const baseUrl = linkedAccountProviderConfig.baseUrl - - let url: string; - if (baseUrl) { - url = provider === 'github' - ? `${baseUrl}/login/oauth/access_token` - : `${baseUrl}/oauth/token`; - } else if (provider === 'github') { - url = 'https://github.com/login/oauth/access_token'; - } else if (provider === 'gitlab') { - url = 'https://gitlab.com/oauth/token'; - } else { - logger.error(`Unsupported provider for token refresh: ${provider}`); - continue; - } + const baseUrl = linkedAccountProviderConfig.baseUrl; - // Build request body parameters - const bodyParams: Record = { - client_id: clientId, - client_secret: clientSecret, - grant_type: 'refresh_token', - refresh_token: refreshToken, - }; - - // GitLab requires redirect_uri to match the original authorization request - // even when refreshing tokens. Use URL constructor to handle trailing slashes. - if (provider === 'gitlab') { - bodyParams.redirect_uri = new URL('/api/auth/callback/gitlab', env.AUTH_URL).toString(); + const result = await tryRefreshToken(provider, refreshToken, { clientId, clientSecret, baseUrl }); + if (result) { + return result; } - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - }, - body: new URLSearchParams(bodyParams), - }); - - if (!response.ok) { - const errorText = await response.text(); - logger.debug(`Failed to refresh ${provider} token with config: ${response.status} ${errorText}`); - continue; - } - - const data = await response.json(); - - const result = { - accessToken: data.access_token, - refreshToken: data.refresh_token ?? null, - expiresAt: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : 0, - }; - - return result; } catch (configError) { logger.debug(`Error trying provider config for ${provider}:`, configError); continue; @@ -177,3 +172,106 @@ export async function refreshOAuthToken( return null; } } + +type ProviderCredentials = { + clientId: string; + clientSecret: string; + baseUrl?: string; +}; + +// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.1 +const OAuthTokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string().optional(), + expires_in: z.number().optional(), + refresh_token: z.string().optional(), + scope: z.string().optional(), +}); + +type OAuthTokenResponse = z.infer; + +const tryRefreshToken = async ( + provider: string, + refreshToken: string, + credentials: ProviderCredentials, +): Promise => { + const { clientId, clientSecret, baseUrl } = credentials; + + let url: string; + if (baseUrl) { + url = provider === 'github' + ? new URL('/login/oauth/access_token', baseUrl).toString() + : new URL('/oauth/token', baseUrl).toString(); + } else if (provider === 'github') { + url = 'https://github.com/login/oauth/access_token'; + } else if (provider === 'gitlab') { + url = 'https://gitlab.com/oauth/token'; + } else { + logger.error(`Unsupported provider for token refresh: ${provider}`); + return null; + } + + // Build request body parameters + const bodyParams: Record = { + // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 (client authentication) + client_id: clientId, + client_secret: clientSecret, + + // @see: https://datatracker.ietf.org/doc/html/rfc6749#section-6 (refresh token grant) + grant_type: 'refresh_token', + refresh_token: refreshToken, + }; + + // GitLab requires redirect_uri to match the original authorization request + // even when refreshing tokens. Use URL constructor to handle trailing slashes. + if (provider === 'gitlab') { + bodyParams.redirect_uri = new URL('/api/auth/callback/gitlab', env.AUTH_URL).toString(); + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + }, + body: new URLSearchParams(bodyParams), + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`Failed to refresh ${provider} token: ${response.status} ${errorText}`); + return null; + } + + const json = await response.json(); + const result = OAuthTokenResponseSchema.safeParse(json); + + if (!result.success) { + logger.error(`Invalid OAuth token response from ${provider}:\n${result.error.message}`); + return null; + } + + return result.data; +} + +/** + * Get credentials from deprecated environment variables. + * This is for backwards compatibility with deployments using env vars instead of config file. + */ +const getDeprecatedEnvCredentials = (provider: string): ProviderCredentials | null => { + if (provider === 'github' && env.AUTH_EE_GITHUB_CLIENT_ID && env.AUTH_EE_GITHUB_CLIENT_SECRET) { + return { + clientId: env.AUTH_EE_GITHUB_CLIENT_ID, + clientSecret: env.AUTH_EE_GITHUB_CLIENT_SECRET, + baseUrl: env.AUTH_EE_GITHUB_BASE_URL, + }; + } + if (provider === 'gitlab' && env.AUTH_EE_GITLAB_CLIENT_ID && env.AUTH_EE_GITLAB_CLIENT_SECRET) { + return { + clientId: env.AUTH_EE_GITLAB_CLIENT_ID, + clientSecret: env.AUTH_EE_GITLAB_CLIENT_SECRET, + baseUrl: env.AUTH_EE_GITLAB_BASE_URL, + }; + } + return null; +} \ No newline at end of file