diff --git a/apps/docs/providers/source-control/azure-devops.mdx b/apps/docs/providers/source-control/azure-devops.mdx index 4e9af57e2..8ed5ca968 100644 --- a/apps/docs/providers/source-control/azure-devops.mdx +++ b/apps/docs/providers/source-control/azure-devops.mdx @@ -61,6 +61,19 @@ Service hook webhook secrets are generated automatically when Roomote configures service hooks. Advanced values remain editable later in Settings → Source Control. +#### Required API permissions + +Under the app registration's **API permissions**, add the Azure DevOps +permissions **Code**, **Graph**, and **User Delegation / Impersonation**, save +them, and grant admin consent for the tenant. + +Roomote requests the `.default` scope, so Microsoft Entra issues a token +covering whatever the app registration was already consented for. An app +registration missing these permissions still authenticates, and Azure DevOps +then rejects every API call with `401 Unauthorized`. Permissions added in the +Azure portal but not saved behave the same way, so re-check that the change was +saved before retrying a sync. + ### Connect with your Microsoft account Choose **Connect with your Microsoft account** in setup or Settings to authorize the Azure @@ -69,6 +82,9 @@ linked account reference with the deployment and refreshes its short-lived Entra access token as needed. The Entra client ID, client secret, and tenant ID are still required so Roomote can refresh the connection in background jobs. +This mode uses the same app registration, so it needs the same +[API permissions](#required-api-permissions) as the service principal. + The linked account can be reconnected or replaced later in Settings. Switching to PAT or service-principal mode removes the inactive credential path from the deployment configuration. @@ -85,6 +101,10 @@ https://dev.azure.com//_apis/git/repositories?api-version=7.1 Roomote stores the results as Azure DevOps repository rows. +If the sync reports that Azure DevOps rejected the credential, the usual cause +in either Entra mode is a missing or unsaved +[API permission](#required-api-permissions) on the app registration. + Azure DevOps-backed tasks clone from the synced repository row, so sync must run before launching an Azure DevOps-backed task. Worker tasks write host- and clone-path-scoped Azure DevOps credentials into the file-backed Git diff --git a/apps/web/src/app/(onboarding)/setup/AdoSourceControlConfig.tsx b/apps/web/src/app/(onboarding)/setup/AdoSourceControlConfig.tsx index 9c22131a4..92e701809 100644 --- a/apps/web/src/app/(onboarding)/setup/AdoSourceControlConfig.tsx +++ b/apps/web/src/app/(onboarding)/setup/AdoSourceControlConfig.tsx @@ -1,4 +1,6 @@ import { Blocks, Cog, PlugZap, UserCog, UserKey } from 'lucide-react'; +import { ADO_ENTRA_REQUIRED_API_PERMISSIONS_TEXT } from '@roomote/types'; + import { InstructionUrl } from './ProviderSetupInstructions'; type AdoAuthMode = 'pat' | 'entra' | 'delegated'; @@ -114,6 +116,21 @@ export function AdoSourceControlInstructions({ → New registration → Create an app in the Microsoft tenant that can access your Azure DevOps organization.

+

+ Open the app's{' '} + API permissions → + Add a permission → Azure DevOps, and grant{' '} + + {ADO_ENTRA_REQUIRED_API_PERMISSIONS_TEXT} + + . Then{' '} + + save the permissions + {' '} + and grant admin consent for the tenant. Azure does not apply permissions + until they are saved, and a missing one only shows up later as a 401 + when Roomote syncs repositories. +

{authMode === 'delegated' && ( <>

diff --git a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx index d128a61db..9dbb40644 100644 --- a/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepSourceControlConnect.tsx @@ -303,13 +303,19 @@ export function StepSourceControlConnect({ adoAuthMode === 'delegated' && adoLinkedAccount.data?.account ) { - await saveAdoLinkedAccount.mutateAsync({ - provider: 'ado', - values: { - ADO_AUTH_MODE: 'delegated', - ADO_LINKED_ACCOUNT_ID: adoLinkedAccount.data.account.accountId, - }, - }); + try { + await saveAdoLinkedAccount.mutateAsync({ + provider: 'ado', + values: { + ADO_AUTH_MODE: 'delegated', + ADO_LINKED_ACCOUNT_ID: adoLinkedAccount.data.account.accountId, + }, + }); + } catch { + // The save verifies the connection against Azure DevOps, so a failure + // here (already surfaced by onError) means the sync would fail too. + return; + } } syncRepositories.mutate(); diff --git a/apps/web/src/components/settings/SourceControlConfigForm.tsx b/apps/web/src/components/settings/SourceControlConfigForm.tsx index 3b417454f..8bbc1a111 100644 --- a/apps/web/src/components/settings/SourceControlConfigForm.tsx +++ b/apps/web/src/components/settings/SourceControlConfigForm.tsx @@ -3,7 +3,10 @@ import { useEffect, useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import type { SetupSourceControlStatus } from '@roomote/types'; +import { + ADO_ENTRA_REQUIRED_API_PERMISSIONS_TEXT, + type SetupSourceControlStatus, +} from '@roomote/types'; import { useTRPC } from '@/trpc/client'; import { Button, Check, Input, Spinner } from '@/components/system'; @@ -274,6 +277,23 @@ export function SourceControlConfigForm({ ) : null} + {isAdo && adoAuthMode !== 'pat' ? ( +

+ The Microsoft Entra app registration needs the{' '} + + {ADO_ENTRA_REQUIRED_API_PERMISSIONS_TEXT} + {' '} + API permissions, saved and admin-consented.{' '} + + Setup guide + +

+ ) : null} {isAdo && adoAuthMode === 'delegated' ? (

diff --git a/apps/web/src/trpc/commands/source-control/index.test.ts b/apps/web/src/trpc/commands/source-control/index.test.ts index 4cf7e287c..aaa719cfc 100644 --- a/apps/web/src/trpc/commands/source-control/index.test.ts +++ b/apps/web/src/trpc/commands/source-control/index.test.ts @@ -15,6 +15,9 @@ const { mockUpsertDeploymentEnvironmentVariables, mockResolveAdoOrganization, mockValidateAdoToken, + mockValidateAdoEntraCredentials, + mockValidateAdoDelegatedCredentials, + mockDescribeAdoApiError, mockValidateGiteaToken, mockEnv, } = vi.hoisted(() => ({ @@ -32,6 +35,9 @@ const { mockUpsertDeploymentEnvironmentVariables: vi.fn(), mockResolveAdoOrganization: vi.fn(), mockValidateAdoToken: vi.fn(), + mockValidateAdoEntraCredentials: vi.fn(), + mockValidateAdoDelegatedCredentials: vi.fn(), + mockDescribeAdoApiError: vi.fn(), mockValidateGiteaToken: vi.fn(), mockEnv: { R_APP_URL: 'https://roomote.example.com', @@ -41,12 +47,15 @@ const { })); vi.mock('@roomote/ado', () => ({ + describeAdoApiError: mockDescribeAdoApiError, ensureAdoServiceHooksForRepositories: mockEnsureAdoServiceHooksForRepositories, removeAdoServiceHooksForRepositories: mockRemoveAdoServiceHooksForRepositories, resolveAdoOrganization: mockResolveAdoOrganization, syncAdoRepositories: mockSyncAdoRepositories, + validateAdoDelegatedCredentials: mockValidateAdoDelegatedCredentials, + validateAdoEntraCredentials: mockValidateAdoEntraCredentials, validateAdoToken: mockValidateAdoToken, })); @@ -174,6 +183,11 @@ describe('source-control commands', () => { mockValidateGiteaToken.mockResolvedValue({ status: 'valid' }); mockResolveAdoOrganization.mockResolvedValue(null); mockValidateAdoToken.mockResolvedValue({ status: 'valid' }); + mockValidateAdoEntraCredentials.mockResolvedValue({ status: 'valid' }); + mockValidateAdoDelegatedCredentials.mockResolvedValue({ status: 'valid' }); + mockDescribeAdoApiError.mockImplementation(async (error: unknown) => + error instanceof Error ? error.message : String(error), + ); }); it('creates GitLab webhooks during the OAuth-triggered repository sync', async () => { @@ -525,6 +539,134 @@ describe('source-control commands', () => { expect(mockValidateAdoToken).not.toHaveBeenCalled(); }); + it('probes Microsoft Entra service-principal credentials before saving them', async () => { + mockValidateAdoEntraCredentials.mockResolvedValue({ + status: 'invalid', + error: 'Azure DevOps rejected the Microsoft Entra credential.', + }); + + await expect( + assertValidSourceControlConfigInput({ + provider: 'ado', + values: { + ADO_ORGANIZATION: 'acme', + ADO_AUTH_MODE: 'entra', + ADO_CLIENT_ID: 'client-id', + ADO_CLIENT_SECRET: 'client-secret', + ADO_TENANT_ID: 'tenant-id', + }, + }), + ).rejects.toThrow('Azure DevOps rejected the Microsoft Entra credential.'); + + expect(mockValidateAdoEntraCredentials).toHaveBeenCalledWith({ + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: undefined, + }); + expect(mockValidateAdoToken).not.toHaveBeenCalled(); + }); + + it('probes runtime-configured Entra credentials the form submits as blank', async () => { + // The Settings form sends an empty string for every field already + // satisfied by a runtime env var. Falling back on those blanks is what + // makes the probe run at all; otherwise the whole check is skipped and + // an unusable credential saves cleanly. + mockResolveAdoOrganization.mockResolvedValue('acme'); + mockResolveDeploymentEnvVar.mockImplementation( + async (name: string) => + ({ + ADO_CLIENT_ID: 'runtime-client-id', + ADO_CLIENT_SECRET: 'runtime-client-secret', + ADO_TENANT_ID: 'runtime-tenant-id', + })[name] ?? null, + ); + mockValidateAdoEntraCredentials.mockResolvedValue({ + status: 'invalid', + error: 'Azure DevOps rejected the Microsoft Entra credential.', + }); + + await expect( + assertValidSourceControlConfigInput({ + provider: 'ado', + values: { + ADO_ORGANIZATION: '', + ADO_CLIENT_ID: '', + ADO_CLIENT_SECRET: '', + ADO_TENANT_ID: '', + ADO_AUTH_MODE: 'entra', + ADO_LINKED_ACCOUNT_ID: '', + }, + }), + ).rejects.toThrow('Azure DevOps rejected the Microsoft Entra credential.'); + + expect(mockValidateAdoEntraCredentials).toHaveBeenCalledWith({ + clientId: 'runtime-client-id', + clientSecret: 'runtime-client-secret', + tenantId: 'runtime-tenant-id', + organization: 'acme', + baseUrl: undefined, + }); + }); + + it('probes the delegated Azure DevOps account once it is linked', async () => { + await assertValidSourceControlConfigInput({ + provider: 'ado', + values: { + ADO_ORGANIZATION: 'acme', + ADO_AUTH_MODE: 'delegated', + ADO_CLIENT_ID: 'client-id', + ADO_CLIENT_SECRET: 'client-secret', + ADO_TENANT_ID: 'tenant-id', + ADO_LINKED_ACCOUNT_ID: 'ado-user@example.com', + }, + }); + + expect(mockValidateAdoDelegatedCredentials).toHaveBeenCalledWith({ + linkedAccountId: 'ado-user@example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: undefined, + }); + }); + + it('skips the delegated probe before the Microsoft account is connected', async () => { + await assertValidSourceControlConfigInput({ + provider: 'ado', + allowIncompleteDelegated: true, + values: { + ADO_ORGANIZATION: 'acme', + ADO_AUTH_MODE: 'delegated', + ADO_CLIENT_ID: 'client-id', + ADO_CLIENT_SECRET: 'client-secret', + ADO_TENANT_ID: 'tenant-id', + ADO_LINKED_ACCOUNT_ID: '', + }, + }); + + expect(mockValidateAdoDelegatedCredentials).not.toHaveBeenCalled(); + }); + + it('explains a rejected Azure DevOps credential instead of echoing the sync status', async () => { + mockSyncAdoRepositories.mockRejectedValue( + new Error('Azure DevOps API request failed: 401 Unauthorized'), + ); + mockDescribeAdoApiError.mockResolvedValue( + 'Azure DevOps rejected the Microsoft Entra credential (status 401). Add the API permissions.', + ); + + await expect( + syncRepositoriesCommand(buildMockAuth(), { provider: 'ado' }), + ).resolves.toEqual({ + success: false, + error: + 'Azure DevOps rejected the Microsoft Entra credential (status 401). Add the API permissions.', + }); + }); + it('requires Gitea OAuth credentials during config validation', async () => { await expect( assertValidSourceControlConfigInput({ diff --git a/apps/web/src/trpc/commands/source-control/index.ts b/apps/web/src/trpc/commands/source-control/index.ts index 85ced8af5..ef100a049 100644 --- a/apps/web/src/trpc/commands/source-control/index.ts +++ b/apps/web/src/trpc/commands/source-control/index.ts @@ -586,9 +586,9 @@ export async function syncRepositoriesCommand( auth.userId, syncResult.repositories, ).catch( - (error: unknown): AdoWebhookSetupSummary => ({ + async (error: unknown): Promise => ({ status: 'skipped', - reason: error instanceof Error ? error.message : String(error), + reason: await Ado.describeAdoApiError(error), }), ); @@ -603,7 +603,15 @@ export async function syncRepositoriesCommand( return { success: false as const, - error: error instanceof Error ? error.message : String(error), + // A rejected Azure DevOps credential reaches the admin as a toast, so + // replace the bare status with what to fix (usually missing or unsaved + // Entra API permissions) instead of echoing "401 Unauthorized". + error: + input.provider === 'ado' + ? await Ado.describeAdoApiError(error) + : error instanceof Error + ? error.message + : String(error), }; } } @@ -783,35 +791,35 @@ export async function assertValidSourceControlConfigInput(params: { : undefined; const nextAdoToken = params.provider === 'ado' - ? (params.values?.['ADO_TOKEN']?.trim() ?? - (await resolveDeploymentEnvVar('ADO_TOKEN'))) + ? params.values?.['ADO_TOKEN']?.trim() || + (await resolveDeploymentEnvVar('ADO_TOKEN')) : undefined; const nextAdoClientId = params.provider === 'ado' - ? (params.values?.['ADO_CLIENT_ID']?.trim() ?? - (await resolveDeploymentEnvVar('ADO_CLIENT_ID'))) + ? params.values?.['ADO_CLIENT_ID']?.trim() || + (await resolveDeploymentEnvVar('ADO_CLIENT_ID')) : undefined; const nextAdoClientSecret = params.provider === 'ado' - ? (params.values?.['ADO_CLIENT_SECRET']?.trim() ?? - (await resolveDeploymentEnvVar('ADO_CLIENT_SECRET'))) + ? params.values?.['ADO_CLIENT_SECRET']?.trim() || + (await resolveDeploymentEnvVar('ADO_CLIENT_SECRET')) : undefined; const nextAdoTenantId = params.provider === 'ado' - ? (params.values?.['ADO_TENANT_ID']?.trim() ?? - (await resolveDeploymentEnvVar('ADO_TENANT_ID')) ?? - (await resolveDeploymentEnvVar('R_MICROSOFT_TENANT_ID'))) + ? params.values?.['ADO_TENANT_ID']?.trim() || + (await resolveDeploymentEnvVar('ADO_TENANT_ID')) || + (await resolveDeploymentEnvVar('R_MICROSOFT_TENANT_ID')) : undefined; const nextAdoAuthMode = params.provider === 'ado' - ? (params.values?.['ADO_AUTH_MODE']?.trim() ?? - (await resolveDeploymentEnvVar('ADO_AUTH_MODE')) ?? - (nextAdoToken ? 'pat' : 'entra')) + ? params.values?.['ADO_AUTH_MODE']?.trim() || + (await resolveDeploymentEnvVar('ADO_AUTH_MODE')) || + (nextAdoToken ? 'pat' : 'entra') : undefined; const nextAdoLinkedAccountId = params.provider === 'ado' - ? (params.values?.['ADO_LINKED_ACCOUNT_ID']?.trim() ?? - (await resolveDeploymentEnvVar('ADO_LINKED_ACCOUNT_ID'))) + ? params.values?.['ADO_LINKED_ACCOUNT_ID']?.trim() || + (await resolveDeploymentEnvVar('ADO_LINKED_ACCOUNT_ID')) : undefined; if ( @@ -853,24 +861,79 @@ export async function assertValidSourceControlConfigInput(params: { throw new Error('Configure the Bitbucket OAuth consumer ID and secret.'); } - if (nextAdoToken) { - const nextAdoOrganization = - params.values?.['ADO_ORGANIZATION']?.trim() ?? - (await Ado.resolveAdoOrganization()); - - if (!nextAdoOrganization) { - return; - } + if (params.provider !== 'ado') { + return; + } - const validation = await Ado.validateAdoToken({ - token: nextAdoToken, - organization: nextAdoOrganization, - baseUrl: params.values?.['ADO_BASE_URL']?.trim() || undefined, - }); + // `||`, not `??`, on every resolution above and below: the Settings form + // submits an empty string for each field already satisfied by a runtime env + // var, and `??` would keep that empty string instead of falling back to the + // configured value, which silently skipped the whole probe. + const nextAdoOrganization = + params.values?.['ADO_ORGANIZATION']?.trim() || + (await Ado.resolveAdoOrganization()); + + // Without an organization there is nothing to make a real API call against; + // the config-first setup step saves credentials before the org is known. + if (!nextAdoOrganization) { + return; + } + const nextAdoBaseUrl = params.values?.['ADO_BASE_URL']?.trim() || undefined; + const assertValid = (validation: Ado.AdoTokenValidationResult) => { if (validation.status === 'invalid') { throw new Error(validation.error); } + }; + + // Every mode is verified with one real Azure DevOps call before the values + // are persisted. Both Entra modes request the `.default` scope, so a token + // is issued regardless of which API permissions the app registration was + // granted. Without this probe a permission-less registration saves cleanly + // and 401s on every later call. + if (nextAdoAuthMode === 'pat') { + if (nextAdoToken) { + assertValid( + await Ado.validateAdoToken({ + token: nextAdoToken, + organization: nextAdoOrganization, + baseUrl: nextAdoBaseUrl, + }), + ); + } + + return; + } + + if (nextAdoAuthMode === 'delegated') { + // The linked account only exists after the Microsoft sign-in round trip, + // so the credentials-first save has nothing to probe yet. + if (nextAdoLinkedAccountId) { + assertValid( + await Ado.validateAdoDelegatedCredentials({ + linkedAccountId: nextAdoLinkedAccountId, + clientId: nextAdoClientId ?? undefined, + clientSecret: nextAdoClientSecret ?? undefined, + tenantId: nextAdoTenantId ?? undefined, + organization: nextAdoOrganization, + baseUrl: nextAdoBaseUrl, + }), + ); + } + + return; + } + + if (nextAdoClientId && nextAdoClientSecret && nextAdoTenantId) { + assertValid( + await Ado.validateAdoEntraCredentials({ + clientId: nextAdoClientId, + clientSecret: nextAdoClientSecret, + tenantId: nextAdoTenantId, + organization: nextAdoOrganization, + baseUrl: nextAdoBaseUrl, + }), + ); } } diff --git a/packages/ado/src/__tests__/api.test.ts b/packages/ado/src/__tests__/api.test.ts index 0036890c3..6a74e034a 100644 --- a/packages/ado/src/__tests__/api.test.ts +++ b/packages/ado/src/__tests__/api.test.ts @@ -74,12 +74,14 @@ vi.mock('@roomote/db/encryption', () => ({ })); import { + AdoApiError, buildAdoOrganizationApiBaseUrl, buildAdoRepositoryValues, clearAdoDeploymentUserCache, clearAdoEntraTokenCache, createAdoPullRequestComment, createTaskRunAdoCredentials, + describeAdoApiError, ensureAdoServiceHooksForRepositories, removeAdoServiceHooksForRepositories, getAdoBuildFailureEvidence, @@ -89,10 +91,37 @@ import { normalizeAdoLinkedAccountKey, resolveAdoToken, selectInnermostFailedAdoTimelineRecords, + validateAdoDelegatedCredentials, + validateAdoEntraCredentials, validateAdoToken, type AdoRepository, } from '../api'; +const ENTRA_PERMISSION_GUIDANCE = + 'Check API permissions and organization membership.'; + +/** + * Mocks the two hops an Entra credential makes: the Microsoft tenant token + * request, then the Azure DevOps call that the token is used for. + */ +function mockEntraThenAdo(adoResponse: () => Response) { + return vi.fn().mockImplementation(async (url) => { + // Match on the exact host: a substring check would also match a URL that + // merely contains the tenant host somewhere in its path or query. + if (new URL(String(url)).hostname === 'login.microsoftonline.com') { + return new Response( + JSON.stringify({ + access_token: 'header.payload.signature', + expires_in: 3600, + }), + { status: 200 }, + ); + } + + return adoResponse(); + }); +} + function makeTaskRun(payload: TaskRun['payload']): TaskRun { return { id: 123, @@ -403,19 +432,15 @@ describe('Azure DevOps API helpers', () => { }); }); - it('validates Azure DevOps tokens against the connection data API', async () => { - const fetchMock = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - authenticatedUser: { - id: 'user-guid', - uniqueName: 'roomote-bot@acme.example', - providerDisplayName: 'Roomote Bot', - }, - }), - { status: 200 }, - ), - ); + it('validates Azure DevOps tokens against the repository listing the sync uses', async () => { + // Not /_apis/connectionData: that resource is preview-only and answers 400 + // unless the api-version carries `-preview`, which made every valid + // credential look unverifiable. + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ count: 1, value: [] }), { status: 200 }), + ); await expect( validateAdoToken({ @@ -424,10 +449,10 @@ describe('Azure DevOps API helpers', () => { baseUrl: 'https://dev.azure.com', fetchImpl: fetchMock, }), - ).resolves.toEqual({ status: 'valid', displayName: 'Roomote Bot' }); + ).resolves.toEqual({ status: 'valid' }); expect(fetchMock).toHaveBeenCalledWith( - 'https://dev.azure.com/acme/_apis/connectionData?api-version=7.1', + 'https://dev.azure.com/acme/_apis/git/repositories?api-version=7.1&%24top=1', expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ @@ -457,6 +482,252 @@ describe('Azure DevOps API helpers', () => { }); }); + it('rejects a Microsoft Entra app registration that cannot reach Azure DevOps', async () => { + // The `.default` scope issues a token regardless of what was consented, + // so only the Azure DevOps call reveals the problem. Azure DevOps names it + // in the body, which is more specific than anything the status implies. + const fetchMock = mockEntraThenAdo( + () => + new Response( + JSON.stringify({ + message: + 'TF401444: Please sign-in at least once as 11111111-2222-3333-4444-555555555555\\\\11111111-2222-3333-4444-555555555555\\\\66666666-7777-8888-9999-000000000000 in a web browser to enable access.', + }), + { status: 401 }, + ), + ); + + const result = await validateAdoEntraCredentials({ + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: fetchMock, + }); + + expect(result.status).toBe('invalid'); + expect(result.status === 'invalid' && result.error).toContain('TF401444'); + expect(result.status === 'invalid' && result.error).toContain( + ENTRA_PERMISSION_GUIDANCE, + ); + // The GUID chain is collapsed for the toast; the raw message stays on + // the thrown AdoApiError for logs. + expect(result.status === 'invalid' && result.error).not.toContain( + '11111111-2222-3333-4444-555555555555', + ); + expect(result.status === 'invalid' && result.error).toContain( + 'sign-in at least once as …', + ); + expect(String(fetchMock.mock.calls[1]?.[0])).toBe( + 'https://dev.azure.com/acme/_apis/git/repositories?api-version=7.1&%24top=1', + ); + }); + + it('accepts a Microsoft Entra service principal that can reach Azure DevOps', async () => { + const fetchMock = mockEntraThenAdo( + () => + new Response(JSON.stringify({ count: 1, value: [] }), { status: 200 }), + ); + + await expect( + validateAdoEntraCredentials({ + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: fetchMock, + }), + ).resolves.toEqual({ status: 'valid' }); + expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({ + headers: expect.objectContaining({ + Authorization: 'Bearer header.payload.signature', + }), + }); + }); + + it('rejects Microsoft Entra credentials the tenant will not issue a token for', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('{}', { status: 401 })); + + const result = await validateAdoEntraCredentials({ + clientId: 'client-id', + clientSecret: 'wrong-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: fetchMock, + }); + + expect(result).toEqual({ + status: 'invalid', + error: expect.stringContaining( + 'Microsoft Entra did not issue an Azure DevOps token', + ), + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('returns unknown when the Entra token endpoint is unreachable or failing', async () => { + // A Microsoft outage or network blip must not block saving; only a + // definitive rejection may. + const outage = await validateAdoEntraCredentials({ + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: vi + .fn() + .mockResolvedValue(new Response('{}', { status: 503 })), + }); + expect(outage.status).toBe('unknown'); + + const network = await validateAdoEntraCredentials({ + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: vi + .fn() + .mockRejectedValue(new TypeError('fetch failed')), + }); + expect(network.status).toBe('unknown'); + }); + + it('rejects a delegated connection whose refresh grant was refused', async () => { + mockAuthAccountsFindFirst.mockResolvedValue({ + id: 'account-1', + accountId: 'ado-user@example.com', + accessToken: 'expired.token.value', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() - 60_000), + }); + + // 400 invalid_grant: the refresh token is expired or revoked. + const refused = await validateAdoDelegatedCredentials({ + linkedAccountId: 'ado-user@example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: vi + .fn() + .mockResolvedValue(new Response('{}', { status: 400 })), + }); + expect(refused).toEqual({ + status: 'invalid', + error: 'Azure DevOps delegated token refresh failed: 400 ', + }); + + // A refresh blocked by a network failure is unverifiable, not invalid. + const network = await validateAdoDelegatedCredentials({ + linkedAccountId: 'ado-user@example.com', + clientId: 'client-id', + clientSecret: 'client-secret', + tenantId: 'tenant-id', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: vi + .fn() + .mockRejectedValue(new TypeError('fetch failed')), + }); + expect(network.status).toBe('unknown'); + }); + + it('validates the delegated account with its stored access token', async () => { + mockAuthAccountsFindFirst.mockResolvedValue({ + id: 'account-1', + accountId: 'ado-user@example.com', + accessToken: 'header.payload.signature', + refreshToken: 'refresh-token', + accessTokenExpiresAt: new Date(Date.now() + 3_600_000), + }); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('{}', { status: 401 })); + + const result = await validateAdoDelegatedCredentials({ + linkedAccountId: 'ado-user@example.com', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: fetchMock, + }); + + expect(result.status).toBe('invalid'); + expect(result.status === 'invalid' && result.error).toContain( + ENTRA_PERMISSION_GUIDANCE, + ); + }); + + it('reports a delegated connection that was never completed', async () => { + mockAuthAccountsFindFirst.mockResolvedValue(null); + + await expect( + validateAdoDelegatedCredentials({ + linkedAccountId: 'ado-user@example.com', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + }), + ).resolves.toEqual({ + status: 'invalid', + error: + 'No Azure DevOps account is connected. Connect with Microsoft again, then save.', + }); + }); + + it('explains a rejected Entra credential instead of echoing the raw status', async () => { + delete process.env.ADO_TOKEN; + process.env.ADO_AUTH_MODE = 'entra'; + + const message = await describeAdoApiError( + new AdoApiError(401, 'Unauthorized'), + ); + + expect(message).toContain('(status 401)'); + expect(message).toContain(ENTRA_PERMISSION_GUIDANCE); + }); + + it('surfaces the Azure DevOps explanation captured from a failed request', async () => { + delete process.env.ADO_TOKEN; + process.env.ADO_AUTH_MODE = 'entra'; + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + message: + 'TF401444: Please sign-in at least once as tenant\\tenant\\object-id in a web browser to enable access.', + }), + { status: 401, statusText: 'Unauthorized' }, + ), + ); + + // The repository sync fails here, so this is the error an admin sees. + const error = await listAdoRepositories({ + token: 'header.payload.signature', + organization: 'acme', + baseUrl: 'https://dev.azure.com', + fetchImpl: fetchMock, + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AdoApiError); + await expect(describeAdoApiError(error)).resolves.toContain('TF401444'); + }); + + it('keeps non-authorization Azure DevOps failures verbatim', async () => { + await expect( + describeAdoApiError(new AdoApiError(500, 'Internal Server Error')), + ).resolves.toBe( + 'Azure DevOps API request failed: 500 Internal Server Error', + ); + await expect( + describeAdoApiError(new Error('socket hang up')), + ).resolves.toBe('socket hang up'); + }); + it('returns unknown when Azure DevOps token validation cannot complete', async () => { const fetchMock = vi .fn() @@ -471,7 +742,8 @@ describe('Azure DevOps API helpers', () => { }), ).resolves.toEqual({ status: 'unknown', - error: 'Could not verify the Azure DevOps token: network unreachable', + error: + 'Could not verify the Azure DevOps credential: network unreachable', }); }); diff --git a/packages/ado/src/api.ts b/packages/ado/src/api.ts index 72a77385a..19d4a6755 100644 --- a/packages/ado/src/api.ts +++ b/packages/ado/src/api.ts @@ -24,6 +24,9 @@ const ADO_PROVIDER = 'ado' satisfies SourceControlProvider; const DEFAULT_ADO_BASE_URL = 'https://dev.azure.com'; export const ADO_API_VERSION = '7.1'; const ADO_TOKEN_VALIDATION_TIMEOUT_MS = 10_000; +// Fits the whole TF401444 sentence (~204 chars with its three GUIDs) while +// still keeping pathological provider messages toast-sized. +const ADO_ERROR_MESSAGE_MAX_CHARS = 240; const ADO_ENTRA_TOKEN_SCOPE = 'https://app.vssps.visualstudio.com/.default'; const ADO_ENTRA_RESOURCE_SCOPE = '499b84ac-1321-427f-aa17-267ca6975798/.default'; @@ -236,35 +239,59 @@ function normalizeOrganization(organization: string): string { return trimmed; } -export async function resolveAdoToken(): Promise { - const authMode = await resolveDeploymentEnvVar('ADO_AUTH_MODE'); - if (authMode === 'delegated') { - return resolveAdoDelegatedToken(); - } +type AdoAuthMode = 'pat' | 'entra' | 'delegated'; - const pat = await resolveDeploymentEnvVar('ADO_TOKEN'); - if (pat?.trim() && authMode !== 'entra') { - return pat; +/** + * Effective deployment auth mode. `ADO_AUTH_MODE` is only written by the setup + * and Settings forms, so deployments configured from raw env vars fall back to + * the same inference {@link resolveAdoToken} uses: a PAT when one is present, + * otherwise the Entra service principal. + */ +async function resolveAdoAuthMode(): Promise { + const authMode = (await resolveDeploymentEnvVar('ADO_AUTH_MODE'))?.trim(); + + if (authMode === 'pat' || authMode === 'entra' || authMode === 'delegated') { + return authMode; } - const clientId = await resolveDeploymentEnvVar('ADO_CLIENT_ID'); - const clientSecret = await resolveDeploymentEnvVar('ADO_CLIENT_SECRET'); - const tenantId = - (await resolveDeploymentEnvVar('ADO_TENANT_ID')) ?? - (await resolveDeploymentEnvVar('R_MICROSOFT_TENANT_ID')); + return (await resolveDeploymentEnvVar('ADO_TOKEN'))?.trim() ? 'pat' : 'entra'; +} - if (!clientId?.trim() || !clientSecret?.trim() || !tenantId?.trim()) { - return null; - } +/** + * Thrown while acquiring a Microsoft Entra token. `definitive` separates a + * rejected credential (bad client id/secret/tenant, revoked refresh token: + * the token endpoint answers 400/401) from an unverifiable failure (network, + * timeout, 5xx). The validate* helpers map definitive failures to `invalid`, + * which blocks saving, and everything else to `unknown`, which does not. + */ +class AdoTokenAcquisitionError extends Error { + readonly definitive: boolean; - if ( - cachedAdoEntraToken && - cachedAdoEntraToken.expiresAt > Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS - ) { - return cachedAdoEntraToken.token; + constructor(message: string, definitive: boolean) { + super(message); + this.name = 'AdoTokenAcquisitionError'; + this.definitive = definitive; } +} + +function isDefinitiveTokenEndpointStatus(status: number): boolean { + return status === 400 || status === 401; +} - const response = await fetch( +async function requestAdoEntraClientCredentialsToken({ + clientId, + clientSecret, + tenantId, + fetchImpl = fetch, + timeoutMs, +}: { + clientId: string; + clientSecret: string; + tenantId: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise<{ token: string; expiresIn: number }> { + const response = await fetchImpl( `https://login.microsoftonline.com/${encodeURIComponent(tenantId.trim())}/oauth2/v2.0/token`, { method: 'POST', @@ -275,12 +302,16 @@ export async function resolveAdoToken(): Promise { scope: ADO_ENTRA_TOKEN_SCOPE, grant_type: 'client_credentials', }), + ...(timeoutMs === undefined + ? {} + : { signal: AbortSignal.timeout(timeoutMs) }), }, ); if (!response.ok) { - throw new Error( + throw new AdoTokenAcquisitionError( `Azure DevOps Microsoft Entra token request failed: ${response.status} ${response.statusText}`, + isDefinitiveTokenEndpointStatus(response.status), ); } @@ -290,8 +321,6 @@ export async function resolveAdoToken(): Promise { }; const token = typeof payload.access_token === 'string' ? payload.access_token : null; - const expiresIn = - typeof payload.expires_in === 'number' ? payload.expires_in : 3600; if (!token) { throw new Error( @@ -299,6 +328,47 @@ export async function resolveAdoToken(): Promise { ); } + return { + token, + expiresIn: + typeof payload.expires_in === 'number' ? payload.expires_in : 3600, + }; +} + +export async function resolveAdoToken(): Promise { + const authMode = await resolveDeploymentEnvVar('ADO_AUTH_MODE'); + if (authMode === 'delegated') { + return resolveAdoDelegatedToken(); + } + + const pat = await resolveDeploymentEnvVar('ADO_TOKEN'); + if (pat?.trim() && authMode !== 'entra') { + return pat; + } + + const clientId = await resolveDeploymentEnvVar('ADO_CLIENT_ID'); + const clientSecret = await resolveDeploymentEnvVar('ADO_CLIENT_SECRET'); + const tenantId = + (await resolveDeploymentEnvVar('ADO_TENANT_ID')) ?? + (await resolveDeploymentEnvVar('R_MICROSOFT_TENANT_ID')); + + if (!clientId?.trim() || !clientSecret?.trim() || !tenantId?.trim()) { + return null; + } + + if ( + cachedAdoEntraToken && + cachedAdoEntraToken.expiresAt > Date.now() + ADO_ENTRA_TOKEN_EXPIRY_SKEW_MS + ) { + return cachedAdoEntraToken.token; + } + + const { token, expiresIn } = await requestAdoEntraClientCredentialsToken({ + clientId, + clientSecret, + tenantId, + }); + cachedAdoEntraToken = { token, expiresAt: Date.now() + expiresIn * 1000, @@ -307,13 +377,24 @@ export async function resolveAdoToken(): Promise { return token; } -async function resolveAdoDelegatedToken(): Promise { - const linkedAccountId = await resolveDeploymentEnvVar( - 'ADO_LINKED_ACCOUNT_ID', - ); - const clientId = await resolveDeploymentEnvVar('ADO_CLIENT_ID'); - const clientSecret = await resolveDeploymentEnvVar('ADO_CLIENT_SECRET'); +async function resolveAdoDelegatedToken(overrides?: { + linkedAccountId?: string; + clientId?: string; + clientSecret?: string; + tenantId?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise { + const linkedAccountId = + overrides?.linkedAccountId ?? + (await resolveDeploymentEnvVar('ADO_LINKED_ACCOUNT_ID')); + const clientId = + overrides?.clientId ?? (await resolveDeploymentEnvVar('ADO_CLIENT_ID')); + const clientSecret = + overrides?.clientSecret ?? + (await resolveDeploymentEnvVar('ADO_CLIENT_SECRET')); const tenantId = + overrides?.tenantId ?? (await resolveDeploymentEnvVar('ADO_TENANT_ID')) ?? (await resolveDeploymentEnvVar('R_MICROSOFT_TENANT_ID')); @@ -364,12 +445,13 @@ async function resolveAdoDelegatedToken(): Promise { !clientSecret?.trim() || !tenantId?.trim() ) { - throw new Error( + throw new AdoTokenAcquisitionError( 'Azure DevOps delegated connection needs to be reconnected. Open Settings and connect with Microsoft again.', + true, ); } - const response = await fetch( + const response = await (overrides?.fetchImpl ?? fetch)( `https://login.microsoftonline.com/${encodeURIComponent(tenantId.trim())}/oauth2/v2.0/token`, { method: 'POST', @@ -381,12 +463,18 @@ async function resolveAdoDelegatedToken(): Promise { refresh_token: account.refreshToken, scope: ADO_ENTRA_RESOURCE_SCOPE, }), + ...(overrides?.timeoutMs === undefined + ? {} + : { signal: AbortSignal.timeout(overrides.timeoutMs) }), }, ); if (!response.ok) { - throw new Error( + // 400 means Entra rejected the grant itself (expired or revoked refresh + // token, bad client secret); anything else is unverifiable. + throw new AdoTokenAcquisitionError( `Azure DevOps delegated token refresh failed: ${response.status} ${response.statusText}`, + isDefinitiveTokenEndpointStatus(response.status), ); } @@ -496,6 +584,104 @@ function buildAdoBasicAuthHeader(token: string): string { return `Basic ${Buffer.from(`:${token}`, 'utf8').toString('base64')}`; } +/** + * Carries the HTTP status alongside the message so callers can tell a rejected + * credential apart from any other failure and replace the raw status with + * actionable text (see {@link describeAdoApiError}). + */ +export class AdoApiError extends Error { + readonly status: number; + /** Azure DevOps' own explanation, when the response body carried one. */ + readonly providerMessage: string | null; + + constructor( + status: number, + statusText: string, + providerMessage: string | null = null, + ) { + super(`Azure DevOps API request failed: ${status} ${statusText}`); + this.name = 'AdoApiError'; + this.status = status; + this.providerMessage = providerMessage; + } +} + +/** + * Azure DevOps answers a rejected credential with a 203 sign-in page as often + * as it answers 401/403, so all three mean "this credential cannot do that". + */ +function isAdoAuthorizationFailureStatus(status: number): boolean { + return status === 203 || status === 401 || status === 403; +} + +const GUID_PATTERN = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; + +/** + * Provider messages like TF401444 embed `tenant\tenant\objectId` GUID chains + * that dominate a toast without helping anyone read it. Collapse them to a + * single ellipsis for display; the untrimmed message stays on the thrown + * {@link AdoApiError} for logs. + */ +function compactAdoProviderMessage(message: string): string { + return message + .replace(GUID_PATTERN, '…') + .replace(/…(\\+…)+/g, '…') + .replace(/\s{2,}/g, ' '); +} + +/** + * What an admin should actually do about a rejected credential. Entra tokens + * are minted from the `.default` scope, so an app registration that was never + * granted the Azure DevOps API permissions, or whose newly added permissions + * were never saved in the Azure portal, or that was never added to the + * organization, authenticates fine and fails on every API call instead. + * + * This surfaces as a toast: a short lead-in, then Azure DevOps' own + * (GUID-compacted) explanation, then one clipped action. The permission names + * and the add-save-consent walkthrough live in the setup/Settings guidance + * and the docs, which the guidance links to. + */ +function buildAdoCredentialRejectionMessage({ + authMode, + status, + providerMessage, +}: { + authMode: AdoAuthMode; + status?: number; + providerMessage?: string | null; +}): string { + const suffix = status === undefined ? '' : ` (status ${status})`; + const quote = providerMessage + ? `: “${compactAdoProviderMessage(providerMessage)}”` + : '.'; + + if (authMode === 'pat') { + return `Azure DevOps rejected the access token${suffix}${quote} Confirm it is active, belongs to the organization, and has Code read access.`; + } + + return `Azure DevOps rejected the Microsoft Entra credential${suffix}${quote} Check API permissions and organization membership.`; +} + +/** + * Message to surface for a failed Azure DevOps call. Rejected credentials get + * mode-specific remediation; everything else keeps its original message. + */ +export async function describeAdoApiError(error: unknown): Promise { + if ( + !(error instanceof AdoApiError) || + !isAdoAuthorizationFailureStatus(error.status) + ) { + return error instanceof Error ? error.message : String(error); + } + + return buildAdoCredentialRejectionMessage({ + authMode: await resolveAdoAuthMode(), + status: error.status, + providerMessage: error.providerMessage, + }); +} + async function requestAdoJson({ organizationApiBaseUrl, fetchImpl = fetch, @@ -529,8 +715,10 @@ async function requestAdoJson({ ); if (![200, 201].includes(response.status)) { - throw new Error( - `Azure DevOps API request failed: ${response.status} ${response.statusText}`, + throw new AdoApiError( + response.status, + response.statusText, + readAdoErrorMessage(await response.text().catch(() => '')), ); } @@ -715,18 +903,57 @@ export async function getAdoPullRequest({ } export type AdoTokenValidationResult = - | { status: 'valid'; displayName: string } + | { status: 'valid' } | { status: 'invalid'; error: string } | { status: 'unknown'; error: string }; -export async function validateAdoToken({ +/** + * Azure DevOps explains most rejections in the response body + * (`{"message": "TF401444: Please sign-in at least once as …"}`), which is + * more specific than anything we can infer from the status alone. Pull it out + * so the admin sees the provider's own diagnosis next to the remediation. + */ +function readAdoErrorMessage(body: string): string | null { + try { + const parsed: unknown = JSON.parse(body); + const message = + typeof parsed === 'object' && parsed !== null + ? (parsed as { message?: unknown }).message + : null; + + if (typeof message !== 'string' || !message.trim()) { + return null; + } + + const collapsed = message.trim().replace(/\s+/g, ' '); + return collapsed.length > ADO_ERROR_MESSAGE_MAX_CHARS + ? `${collapsed.slice(0, ADO_ERROR_MESSAGE_MAX_CHARS)}…` + : collapsed; + } catch { + return null; + } +} + +/** + * Single real Azure DevOps call every credential must be able to make. This is + * the same repository listing the sync performs, so a PAT without Code access + * or an Entra app registration that cannot reach the organization fails at + * save time with remediation instead of at the first sync with a bare 401. + * + * Deliberately not `/_apis/connectionData`: that resource is preview-only and + * answers 400 unless the api-version carries a `-preview` suffix, which would + * make a working credential look unverifiable rather than valid. + */ +async function probeAdoCredential({ token, + authMode, organization, baseUrl, fetchImpl = fetch, timeoutMs = ADO_TOKEN_VALIDATION_TIMEOUT_MS, }: { token: string; + authMode: AdoAuthMode; organization: string; baseUrl?: string; fetchImpl?: typeof fetch; @@ -741,8 +968,9 @@ export async function validateAdoToken({ organization, }); const response = await fetchImpl( - buildAdoApiUrl(organizationApiBaseUrl, '/_apis/connectionData', { + buildAdoApiUrl(organizationApiBaseUrl, '/_apis/git/repositories', { 'api-version': ADO_API_VERSION, + $top: 1, }), { method: 'GET', @@ -754,43 +982,176 @@ export async function validateAdoToken({ }, ); - // Azure DevOps answers rejected PATs with a 203 sign-in page instead of - // a 401, so treat that status as a definitive rejection too. - if ([203, 401, 403].includes(response.status)) { + if (isAdoAuthorizationFailureStatus(response.status)) { return { status: 'invalid', - error: - 'Azure DevOps rejected the access token. Confirm it is active, belongs to the organization, and has Code read access.', + error: buildAdoCredentialRejectionMessage({ + authMode, + providerMessage: readAdoErrorMessage(await response.text()), + }), }; } if (response.status !== 200) { return { status: 'unknown', - error: `Could not verify the Azure DevOps token: ${response.status} ${response.statusText}`, + error: `Could not verify the Azure DevOps credential: ${response.status} ${response.statusText}`, }; } - const { authenticatedUser } = adoConnectionDataSchema.parse( - await response.json(), - ); + return { status: 'valid' }; + } catch (error) { + return { + status: 'unknown', + error: `Could not verify the Azure DevOps credential: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +} + +export async function validateAdoToken(params: { + token: string; + organization: string; + baseUrl?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise { + return probeAdoCredential({ ...params, authMode: 'pat' }); +} + +/** + * Verifies a Microsoft Entra service principal end to end: the tenant issues a + * client-credentials token, then that token makes a real Azure DevOps call. + * The token request only proves the client id/secret/tenant are right; the + * `.default` scope succeeds no matter which API permissions were consented, so + * the second step is the one that catches a permission-less app registration. + */ +export async function validateAdoEntraCredentials({ + clientId, + clientSecret, + tenantId, + organization, + baseUrl, + fetchImpl, + timeoutMs = ADO_TOKEN_VALIDATION_TIMEOUT_MS, +}: { + clientId: string; + clientSecret: string; + tenantId: string; + organization: string; + baseUrl?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise { + let token: string; + + try { + ({ token } = await requestAdoEntraClientCredentialsToken({ + clientId, + clientSecret, + tenantId, + fetchImpl, + timeoutMs, + })); + } catch (error) { + // Only a definitive rejection by the token endpoint blocks saving. + // Network trouble, timeouts, and 5xx answers are unverifiable, matching + // how an unreachable Azure DevOps is treated below. + if (error instanceof AdoTokenAcquisitionError && error.definitive) { + return { + status: 'invalid', + error: `Microsoft Entra did not issue an Azure DevOps token. Confirm the client ID, client secret, and tenant ID are correct and the client secret has not expired. (${error.message})`, + }; + } return { - status: 'valid', - displayName: - authenticatedUser.providerDisplayName ?? - authenticatedUser.displayName ?? - authenticatedUser.uniqueName ?? - authenticatedUser.id, + status: 'unknown', + error: `Could not verify the Microsoft Entra credential: ${ + error instanceof Error ? error.message : String(error) + }`, }; + } + + return probeAdoCredential({ + token, + authMode: 'entra', + organization, + baseUrl, + fetchImpl, + timeoutMs, + }); +} + +/** + * Verifies the Azure DevOps account linked through delegated sign-in, using + * the stored access token (refreshed first when it has expired). + */ +export async function validateAdoDelegatedCredentials({ + linkedAccountId, + clientId, + clientSecret, + tenantId, + organization, + baseUrl, + fetchImpl, + timeoutMs = ADO_TOKEN_VALIDATION_TIMEOUT_MS, +}: { + linkedAccountId: string; + clientId?: string; + clientSecret?: string; + tenantId?: string; + organization: string; + baseUrl?: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}): Promise { + let token: string | null; + + try { + token = await resolveAdoDelegatedToken({ + linkedAccountId, + clientId, + clientSecret, + tenantId, + fetchImpl, + timeoutMs, + }); } catch (error) { + // Only a definitive rejection blocks saving: a missing refresh token + // (reconnect needed) or the token endpoint refusing the grant. A network + // failure or 5xx during refresh is unverifiable. + if (error instanceof AdoTokenAcquisitionError && error.definitive) { + return { + status: 'invalid', + error: error.message, + }; + } + return { status: 'unknown', - error: `Could not verify the Azure DevOps token: ${ + error: `Could not verify the delegated Azure DevOps connection: ${ error instanceof Error ? error.message : String(error) }`, }; } + + if (!token) { + return { + status: 'invalid', + error: + 'No Azure DevOps account is connected. Connect with Microsoft again, then save.', + }; + } + + return probeAdoCredential({ + token, + authMode: 'delegated', + organization, + baseUrl, + fetchImpl, + timeoutMs, + }); } function normalizeAdoParentCommentId( diff --git a/packages/types/src/source-control.ts b/packages/types/src/source-control.ts index a0194bf79..63caa5f1e 100644 --- a/packages/types/src/source-control.ts +++ b/packages/types/src/source-control.ts @@ -108,6 +108,17 @@ export const sourceControlProviderDescriptors = { } >; +/** + * Azure DevOps API permissions a Microsoft Entra app registration must be + * granted and admin-consented before Roomote can read repositories and + * manage service hooks. Both Entra modes request the `.default` scope, so a + * registration with none of these still mints a token and every subsequent + * Azure DevOps call answers 401. Shared so the setup instructions and the + * rejected-credential guidance name the same list. + */ +export const ADO_ENTRA_REQUIRED_API_PERMISSIONS_TEXT = + 'Code, Graph, and User Delegation / Impersonation'; + export const sourceControlTokenBackedProviders = sourceControlProviders.filter( (provider): provider is SourceControlTokenBackedProvider => sourceControlProviderDescriptors[provider].connectionMode === 'token',