Last active
August 22, 2025 08:56
-
-
Save codingfreak/9db97a4db83a718b72c2e8a4f233a268 to your computer and use it in GitHub Desktop.
Create federated Azure DevOps service connection
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| $ErrorActionPreference = 'Stop' | |
| function Invoke-AzureDevOpsRestApi { | |
| # Parameter help description | |
| [CmdletBinding()] | |
| param ( | |
| [string] | |
| $OrganizationName = '', | |
| [string] | |
| $ProjectName = '', | |
| [string] | |
| $RelativeUrl, | |
| [string] | |
| $ContentType = '', | |
| [string] | |
| [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH')] | |
| $Method, | |
| [object] | |
| $Body = $null, | |
| [string] | |
| [ValidateSet('VisualStudio', 'AzureDevOps')] | |
| $ApiType = 'AzureDevOps', | |
| [Hashtable] | |
| $AdditionalParameters | |
| ) | |
| process { | |
| $api = $ApiType -eq 'VisualStudio' ? 'https://app.vssps.visualstudio.com' : 'https://dev.azure.com' | |
| $baseUrl = $api | |
| $apiVersion = '7.1' | |
| $uri = "$baseUrl/$($OrganizationName.Length -eq 0 ? '' : "$($OrganizationName)/")$($ProjectName.Length -eq 0 ? '' : "$($ProjectName)/")_apis/$($RelativeUrl)?api-version=$apiVersion" | |
| if ($null -ne $AdditionalParameters -and $AdditionalParameters.Count -gt 0) { | |
| foreach ($key in $AdditionalParameters.Keys) { | |
| $uri += "&$($key)=$($AdditionalParameters[$key])" | |
| } | |
| } | |
| $accessToken = Get-AccessToken -ResourceUrl 499b84ac-1321-427f-aa17-267ca6975798 | |
| $headers = @{ | |
| Accept = "application/json" | |
| Authorization = "Bearer $accessToken" | |
| } | |
| if ($Body.Length -gt 0) { | |
| $response = Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers -Body $Body -ContentType $ContentType | |
| } else { | |
| $response = Invoke-RestMethod -Method $Method -Uri $uri -Headers $headers | |
| } | |
| return $response.value ?? $response | |
| } | |
| } | |
| function Get-AccessToken { | |
| [CmdLetBinding()] | |
| param ( | |
| [string] | |
| $TenantId = '', | |
| [string] | |
| $ResourceUrl = '', | |
| [switch] | |
| $AsSecureString | |
| ) | |
| if ($TenantId.Length -eq 0) { | |
| $TenantId = (Get-AzContext).Tenant.Id | |
| } | |
| if ($ResourceUrl.Length -gt 0) { | |
| $token = Get-AzAccessToken -ResourceUrl $ResourceUrl -WarningAction SilentlyContinue -AsSecureString | |
| } else { | |
| $token = Get-AzAccessToken -WarningAction SilentlyContinue -AsSecureString | |
| } | |
| if (!$accessToken) { | |
| } | |
| if ($AsSecureString.IsPresent) { | |
| # return the secure token as it is | |
| return $token.Token | |
| } | |
| $accessToken = ConvertFrom-SecureString -SecureString $token.Token -AsPlainText | |
| return $accessToken | |
| } | |
| function Get-ProjectId { | |
| [CmdletBinding()] | |
| param ( | |
| [string] | |
| $OrganizationName, | |
| [string] | |
| $ProjectName | |
| ) | |
| process { | |
| $result = Invoke-AzureDevOpsRestApi -OrganizationName $OrganizationName ` | |
| -Method GET ` | |
| -RelativeUrl 'projects' | |
| foreach ($project in $result) { | |
| if ($project.name -eq $ProjectName) { | |
| return $project.id | |
| } | |
| } | |
| return '' | |
| } | |
| } | |
| function New-DefaultDevOpsServiceConnection { | |
| [CmdletBinding()] | |
| param ( | |
| [string] | |
| $OrganizationName, | |
| [string] | |
| $ProjectName, | |
| [string] | |
| $ProjectId, | |
| [string] | |
| $ServiceConnectionName, | |
| [string] | |
| $ServicePrincipalId, | |
| [string] | |
| $ServicePrincipalSecret | |
| ) | |
| process { | |
| $ctx = Get-AzContext | |
| $jsonBody = @" | |
| { | |
| "name": "$ServiceConnectionName", | |
| "data": { | |
| "creationMode": "Manual", | |
| "environment": "AzureCloud", | |
| "scopeLevel": "Subscription", | |
| "subscriptionName": "$($ctx.Subscription.Name)", | |
| "subscriptionId": "$($ctx.Subscription.Id)" | |
| }, | |
| "type": "AzureRM", | |
| "serviceEndpointProjectReferences": [ | |
| { | |
| "name": "$ServiceConnectionName", | |
| "projectReference": { | |
| "id": "$ProjectId", | |
| "name": "$ProjectName" | |
| } | |
| } | |
| ], | |
| "url": "https://management.azure.com/", | |
| "authorization": { | |
| "parameters": { | |
| "servicePrincipalKey": "$ServicePrincipalSecret", | |
| "tenantId": "$($ctx.Tenant.Id)", | |
| "servicePrincipalId": "$ServicePrincipalId", | |
| "authenticationType": "spnKey" | |
| }, | |
| "scheme": "ServicePrincipal" | |
| }, | |
| "isReady": true | |
| } | |
| "@ | |
| return Invoke-AzureDevOpsRestApi ` | |
| -OrganizationName $OrganizationName ` | |
| -ProjectName $ProjectName ` | |
| -ContentType 'application/json' ` | |
| -Method 'POST' ` | |
| -RelativeUrl 'serviceendpoint/endpoints' ` | |
| -Body $jsonBody | |
| } | |
| } | |
| function New-FederatedCredential { | |
| param ( | |
| [string] | |
| $OrganizationName, | |
| [string] | |
| $ProjectName, | |
| [string] | |
| $ServiceConnectionName, | |
| [string] | |
| $ServicePrincipalId, | |
| [string] | |
| $ServicePrincipalName | |
| ) | |
| # Build the project name for the federated credential | |
| $builtFederatedCredentialName = "$($OrganizationName.ToLowerInvariant())-$($ProjectName.ToLowerInvariant())" | |
| # Get the name from the naming convention | |
| $federatedCredentialName = "devops-$builtFederatedCredentialName" | |
| Write-Host "Creating federated credential '$federatedCredentialName' for service principal '$ServicePrincipalName' to service connection '$ServiceConnectionName'" | |
| # Retrieve the app registration using the Application ID | |
| $AppRegistration = Get-AzADApplication -ApplicationId $ServicePrincipalId | |
| # Get the Object ID of the app registration | |
| $ApplicationObjectId = $AppRegistration.Id | |
| # Build the subject identifier for the federated credential | |
| $subjectIdentifier = "sc://$OrganizationName/$ProjectName/$ServiceConnectionName" | |
| # Check if the federated credential already exists | |
| $federatedCredential = Get-AzADAppFederatedCredential -ApplicationObjectId $ApplicationObjectId | |
| # check if the name of the retrieved federated credential matches the naming convention of the new federated credential | |
| if ($federatedCredential.Name -eq $federatedCredentialName) { | |
| Write-Host "Federated credential '$federatedCredentialName' already exists for service principal '$ServicePrincipalName' to service connection '$ServiceConnectionName'." | |
| return | |
| } | |
| # Get the authenticated user's memberId | |
| $userProfile = Invoke-AzureDevOpsRestApi -Method GET ` | |
| -RelativeUrl 'profile/profiles/me' ` | |
| -ApiType VisualStudio | |
| $memberId = $userProfile.id | |
| # Make the API call to get the list of accounts with memberId | |
| $response = Invoke-AzureDevOpsRestApi -Method GET ` | |
| -RelativeUrl 'accounts' ` | |
| -ApiType VisualStudio ` | |
| -AdditionalParameters @{ 'memberId' = $memberId } | |
| # Filter the accounts where AccountName matches the organization name | |
| $accounts = $response | |
| $account = $accounts | Where-Object { $_.AccountName -eq $OrganizationName } | |
| $orgId = $account.AccountId | |
| Write-Host "DevOps Organization ID: $orgId" | |
| New-AzADAppFederatedCredential ` | |
| -ApplicationObjectId $ApplicationObjectId ` | |
| -Audience 'api://AzureADTokenExchange' ` | |
| -Issuer "https://vstoken.dev.azure.com/$orgId" ` | |
| -Name $federatedCredentialName ` | |
| -Subject $subjectIdentifier | Out-Null | |
| Write-Host "Federated credential '$federatedCredentialName' created successfully." | |
| } | |
| function Convert-ServiceConnectionToFederated { | |
| param ( | |
| [string] | |
| $OrganizationName, | |
| [string] | |
| $ProjectName, | |
| [string] | |
| $ServiceConnectionName | |
| ) | |
| # Make the REST API call to get service connections | |
| $response = Invoke-AzureDevOpsRestApi ` | |
| -OrganizationName $OrganizationName ` | |
| -ProjectName $ProjectName ` | |
| -Method 'GET' ` | |
| -RelativeUrl 'serviceendpoint/endpoints' ` | |
| -AdditionalParameters @{ | |
| 'authSchemes' = 'ServicePrincipal' | |
| 'type' = 'azurerm' | |
| 'includeFailed' = 'false' | |
| 'includeDetails' = 'true' | |
| } | |
| $serviceEndpoints = $response | |
| if (!$serviceEndpoints -or ($serviceEndpoints.Count -eq 0)) { | |
| # NOTE: This endpoint is never returning any service connection which was converted | |
| # to automatic. This means we cannot figure out if a service connection already exists. | |
| Write-Host "No service connections found or service connection already initialized correctly." | |
| return | |
| } | |
| # Filter the service connections | |
| $serviceEndpoint = $serviceEndpoints | Where-Object { | |
| $_.name -eq $ServiceConnectionName -and | |
| $_.authorization.scheme -eq 'ServicePrincipal' -and | |
| $_.data.creationMode -eq 'Manual' -and | |
| !($_.isShared -and $_.serviceEndpointProjectReferences[0].projectReference.name -ne $ProjectName) | |
| } | Select-Object -First 1 | |
| $serviceEndpoint = ($serviceEndpoints | Where-Object { $_.name -eq $ServiceConnectionName }) | |
| if (!$serviceEndpoint) { | |
| Write-Host "No service connection found with name '$ServiceConnectionName' that can be converted." | |
| return | |
| } | |
| Write-Host "Converting service connection '$($serviceEndpoint.name)' to federated authentication..." | |
| $serviceEndpoint.authorization.scheme = "WorkloadIdentityFederation" | |
| $serviceEndpoint.data.PSObject.Properties.Remove('revertSchemeDeadline') | |
| $serviceEndpoint.authorization.parameters.PSObject.Properties.Remove('authenticationType') | |
| # Convert back to JSON string | |
| $serviceEndpointRequest = $serviceEndpoint | ConvertTo-Json -Depth 99 -Compress | |
| # Make the REST API call to convert the service connection | |
| $result = Invoke-AzureDevOpsRestApi ` | |
| -OrganizationName $OrganizationName ` | |
| -ProjectName $ProjectName ` | |
| -Method "PUT" ` | |
| -RelativeUrl "serviceendpoint/endpoints/$($serviceEndpoint.id)" ` | |
| -Body $serviceEndpointRequest ` | |
| -ContentType "application/json" ` | |
| -AdditionalParameters @{ | |
| 'operation' = 'ConvertAuthenticationScheme' | |
| } | |
| if (!$result) { | |
| throw "Failed to convert service connection '$($serviceEndpoint.name)' to Workload Identity Federated connection." | |
| } | |
| Write-Host "Service connection '$($serviceEndpoint.name)' has been converted to Workload Identity Federated connection." | |
| } | |
| # FILL IN THOSE VALUES! | |
| $orgName = 'TODO' | |
| $projectName = 'TODO' | |
| $serviceConnectionName = 'TODO' | |
| $spId = 'TODO' | |
| $spName = 'TODO' | |
| $spSecret = 'TODO' | |
| # Resolve project id | |
| $projectId = Get-ProjectId -OrganizationName $orgName ` | |
| -ProjectName $projectName | |
| Write-Host "Project ID for $projectName is $projectId." | |
| # Create classic service connection | |
| New-DefaultDevOpsServiceConnection -OrganizationName $orgName ` | |
| -ProjectName $projectName ` | |
| -ProjectId $projectId ` | |
| -ServiceConnectionName $serviceConnectionName ` | |
| -ServicePrincipalId $spId ` | |
| -ServicePrincipalSecret $spSecret | |
| Write-Host "Service connection created in draft mode." | |
| # Upgrade to federated. | |
| New-FederatedCredential ` | |
| -OrganizationName $orgName ` | |
| -ProjectName $projectName ` | |
| -ServicePrincipalId $spId ` | |
| -ServicePrincipalName $spName ` | |
| -ServiceConnectionName $serviceConnectionName | |
| Write-Host "Giving Azure time to digest 😒..." | |
| Start-Sleep -Seconds 10 | |
| Convert-ServiceConnectionToFederated -OrganizationName $orgName ` | |
| -ProjectName $projectName ` | |
| -ServiceConnectionName $serviceConnectionName | |
| Write-Host "Done" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment