public/core/Add-MtMaesterAppFederatedCredential.ps1

function Add-MtMaesterAppFederatedCredential {
    <#
    .SYNOPSIS
    Adds a federated credential to a Maester application for GitHub Actions authentication.

    .DESCRIPTION
    Adds a federated credential (workload identity) to a Maester application to enable
    authentication from GitHub Actions workflows without using client secrets.
    The credential allows the specified GitHub repository and branch to authenticate
    as the application.

    .PARAMETER Id
    The Object ID of the Maester application to add the federated credential to.

    .PARAMETER AppId
    The Application (Client) ID of the Maester application to add the federated credential to.

    .PARAMETER GitHubRepository
    The GitHub repository name (without the organization). E.g. maester-tests.
    If both -GitHubOrganization and -GitHubRepository are omitted and the current working
    directory is inside a git repository whose 'origin' remote points at GitHub, both
    values are auto-detected from `git remote get-url origin`. Specifying one without
    the other is not supported - either pass both explicitly, or pass neither and rely
    on auto-detection.

    .PARAMETER GitHubBranch
    The GitHub branch that can use this credential. Defaults to 'main'.

    .PARAMETER Name
    The name for the federated credential. Defaults to 'maester-devops-<org>-<repo>'.

    .PARAMETER SetGitHubSecrets
    If specified, sets the AZURE_CLIENT_ID and AZURE_TENANT_ID secrets on the target
    GitHub repository using the GitHub CLI (`gh`). Requires `gh` to be installed and
    authenticated (`gh auth login`). When the secrets cannot be set automatically the
    cmdlet falls back to printing the manual setup instructions.

    Re-running the cmdlet with -SetGitHubSecrets against an app that already has a
    matching federated credential will skip the credential creation step and proceed
    directly to (re)setting the secrets.

    .EXAMPLE
    Add-MtMaesterAppFederatedCredential -AppId "12345678-1234-1234-1234-123456789012" -GitHubOrganization "myorg" -GitHubRepository "myrepo"

    Adds a federated credential for the main branch of myorg/myrepo to the specified Maester app.

    .EXAMPLE
    Add-MtMaesterAppFederatedCredential -Id "87654321-4321-4321-4321-210987654321" -GitHubOrganization "myorg" -GitHubRepository "myrepo" -Name "maester-develop"

    Adds a federated credential for the develop branch with a custom name.

    .EXAMPLE
    Add-MtMaesterAppFederatedCredential -AppId "12345678-1234-1234-1234-123456789012" -SetGitHubSecrets

    Auto-detects the GitHub organization and repository from the current git remote, adds
    the federated credential, and pushes AZURE_CLIENT_ID / AZURE_TENANT_ID to the repo's
    Actions secrets via the GitHub CLI.

    .LINK
    https://maester.dev/docs/commands/Add-MtMaesterAppFederatedCredential
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Colors are beautiful')]
    [CmdletBinding()]
    param(
        # The ID of the Maester app to update
        [Parameter(Mandatory = $true, ParameterSetName = 'ById')]
        [Alias('ObjectId')]
        [string] $Id,

        # The Application (Client) ID of the Maester app to update
        [Parameter(Mandatory = $true, ParameterSetName = 'ByApplicationId')]
        [Alias('ClientId')]
        [string] $AppId,

        # Your GitHub organization name or GitHub username. E.g. jasonf.
        # If omitted (together with -GitHubRepository) the value is auto-detected from
        # the local git remote ('origin') when the current directory is a git repo.
        [Parameter(Mandatory = $false)]
        [string] $GitHubOrganization,

        # Your GitHub repository name where the GitHub Actions workflow is located. E.g. maester-tests.
        # Auto-detected from the local git remote ('origin') when omitted.
        [Parameter(Mandatory = $false)]
        [string] $GitHubRepository,

        # The GitHub branch that can use this credential
        [Parameter(Mandatory = $false)]
        [string] $GitHubBranch = 'main',

        # The name for the federated credential
        [Parameter(Mandatory = $false)]
        [string] $Name,

        # If set, also pushes AZURE_CLIENT_ID and AZURE_TENANT_ID to the GitHub repo's
        # Actions secrets using the GitHub CLI (`gh`). Falls back to printing manual
        # instructions if `gh` is not installed or not authenticated.
        [Parameter(Mandatory = $false)]
        [switch] $SetGitHubSecrets
    )

    if (-not (Test-MtAzContext)) {
        return
    }

    # Auto-detect GitHub org/repo from the local git remote. Only triggers when BOTH
    # parameters were omitted - mixing an explicit value with an auto-detected one is
    # ambiguous (which repo did the caller really mean?) so we require both-or-neither.
    if (-not $GitHubOrganization -and -not $GitHubRepository) {
        $detected = Get-MtGitHubRepoFromGit
        if ($detected) {
            $GitHubOrganization = $detected.Organization
            $GitHubRepository   = $detected.Repository
            Write-Host "Auto-detected GitHub repository from git remote: $GitHubOrganization/$GitHubRepository" -ForegroundColor Cyan
        }
    } elseif (-not $GitHubOrganization -or -not $GitHubRepository) {
        Write-Error "Specify both -GitHubOrganization and -GitHubRepository, or omit both to auto-detect from the local git remote."
        return
    }

    if (-not $GitHubOrganization -or -not $GitHubRepository) {
        Write-Error "GitHubOrganization and GitHubRepository are required. They can be auto-detected when the current directory is a git working tree whose 'origin' remote points at GitHub."
        return
    }

    try {
        if ($Id) {
            $params = @{ Id = $Id }
        } elseif ($AppId) {
            $params = @{ AppId = $AppId }
        }

        $app = Get-MtMaesterApp @params
        if (-not $app) {
            $errorId = if($Id) { $Id } else { $AppId }
            Write-Error "Maester application not found with the specified identifier: $errorId."
            return
        }

        Write-Host "Adding federated credential to Maester application" -ForegroundColor Green
        Write-Output $app

        if (-not $Name) { # Set default name if not provided
            $Name = "maester-devops-$($GitHubOrganization)-$($GitHubRepository)"
        }
        $githubIssuer = "https://token.actions.githubusercontent.com"

        # Check for existing federated credentials
        Write-Verbose "Checking for existing federated credentials..."
        $existingCredsResponse = Invoke-MtAzureRequest -RelativeUri "applications/$($app.id)/federatedIdentityCredentials" -Method GET -Graph

        $existingCreds = $existingCredsResponse.value

        # Check if a similar credential already exists
        $duplicateName = $existingCreds | Where-Object { $_.name -eq $Name }
        $duplicateSubject = $existingCreds | Where-Object {
            ($_.subject -eq "repo:$GitHubOrganization/$GitHubRepository`:ref:refs/heads/$GitHubBranch" -and $_.issuer -eq $githubIssuer)
        }

        if ($duplicateName -or $duplicateSubject) {
            # duplicateSubject = an existing credential already grants the requested
            # repo/branch. That is not a failure - the desired end state already exists,
            # so we treat re-runs as idempotent (and continue to secrets setup when asked).
            #
            # duplicateName without duplicateSubject = the name is taken by a credential
            # for a DIFFERENT repo/branch. The requested credential cannot be created,
            # so this remains a hard error so callers / automation detect the failure.
            if ($duplicateSubject) {
                Write-Warning "A federated credential for this repository already exists:"
                $duplicateCred = $duplicateSubject
            }
            elseif ($duplicateName) {
                Write-Error "A federated credential with the name '$Name' already exists for a different repository or branch. Choose a different -Name or remove the existing credential."
                $duplicateName | ForEach-Object {
                    Write-Host " Name: $($_.name)" -ForegroundColor Yellow
                    Write-Host " Subject: $($_.subject)" -ForegroundColor Yellow
                    Write-Host ""
                }
                return
            }

            $duplicateCred | ForEach-Object {
                Write-Host " Name: $($_.name)" -ForegroundColor Yellow
                Write-Host " Subject: $($_.subject)" -ForegroundColor Yellow
                Write-Host ""
            }

            # If the existing credential already matches the requested repo/branch and the
            # caller asked us to also set secrets, do that work instead of silently returning.
            # This makes `-SetGitHubSecrets` idempotent on re-runs.
            if ($duplicateSubject -and $SetGitHubSecrets) {
                Write-Host "Existing credential matches - proceeding to (re)set GitHub Actions secrets." -ForegroundColor Cyan
                $tenantId = (Get-AzContext).Tenant.Id
                $secretsConfigured = Set-MtGitHubActionsSecret -GitHubRepository "$GitHubOrganization/$GitHubRepository" -ClientId $app.AppId -TenantId $tenantId
                if (-not $secretsConfigured) {
                    Write-MtGitHubSecretsManualInstruction -GitHubOrganization $GitHubOrganization -GitHubRepository $GitHubRepository -ClientId $app.AppId -TenantId $tenantId -AttemptedAutomatic
                }
                return $duplicateSubject
            }

            return
        }

        # Create the federated credential
        $federatedCredential = @{
            name        = $Name
            issuer      = $githubIssuer
            subject     = "repo:$GitHubOrganization/$GitHubRepository`:ref:refs/heads/$GitHubBranch"
            audiences   = @("api://AzureADTokenExchange")
            description = "Federated credential for GitHub Actions in $GitHubOrganization/$GitHubRepository ($GitHubBranch branch) - Created by Maester"
        } | ConvertTo-Json -Depth 3

        Write-Verbose "Creating federated credential with payload: $federatedCredential"

        $createdCredential = Invoke-MtAzureRequest -RelativeUri "applications/$($app.id)/federatedIdentityCredentials" -Method POST -Payload $federatedCredential -Graph

        if ($createdCredential.error) {
            throw "Failed to create federated credential. Error: $($createdCredential.error.message)"
        }

        $tenantId = (Get-AzContext).Tenant.Id

        Write-Host ""
        Write-Host "🎉 Federated credential created successfully!" -ForegroundColor Green
        Write-Host ""

        $secretsConfigured = $false
        if ($SetGitHubSecrets) {
            $secretsConfigured = Set-MtGitHubActionsSecret -GitHubRepository "$GitHubOrganization/$GitHubRepository" -ClientId $app.AppId -TenantId $tenantId
        }

        if (-not $secretsConfigured) {
            $manualParams = @{
                GitHubOrganization = $GitHubOrganization
                GitHubRepository   = $GitHubRepository
                ClientId           = $app.AppId
                TenantId           = $tenantId
            }
            if ($SetGitHubSecrets) { $manualParams['AttemptedAutomatic'] = $true }
            Write-MtGitHubSecretsManualInstruction @manualParams
        }

        return $createdCredential

    } catch {
        Write-Error "Failed to add federated credential: $($_.Exception.Message)"
        throw
    }
}