researchinfra

Vulnerable Playground

Azure/Entra playground to practice enumeration and exploitation

There isn’t much documentation on how to create vulnerable Azure/Entra environments. The only experience I’ve had with Azure/Entra was helping maintain and configure client tenants at an MSP, and being the admin of our own tenant at my last company. I can’t think of a time when I was asked to turn on misconfigurations on purpose. From afar, I’ve always admired security content creators for being able to essentially, in my mind, reverse-engineer a hardened environment into something vulnerable to specific attack paths or exploits. So, this was my first time putting a vulnerable Azure/Entra tenant together. I decided to do it all in PowerShell rather than try to piece something together on a canvas, a mindmap, or just from the hip. I’ll attach both PowerShell scripts I used, for anyone interested in creating their own. As a huge fan of the Metroid universe, all users, groups, applications, etc. are from the franchise. I thought it could be helpful to partially share my experience on building a vulnerable environment with PowerShell and a snippet of the graph behind it in BloodHound. **Disclaimer: I had claude code pull together my whole PowerShell session into a script for convenience, which is shared at the end of the post… Otherwise, it was A LOT of red. :)

So, how I approached the PowerShell method was by using BloodHound edges as my source of truth to determine what had permissions or rights over another object. With that in mind, the easy and fun part came first: figuring out how many users, groups, roles, and apps I needed, then putting names to each of them. So, of course, I pulled up the Metroid Universe wiki and started going HAM. To avoid overcomplicating everything, I had claude code create a table mapping each BloodHound edge to its Graph permission and the affected service principal.

Table of Service Principal -> Graph/BloodHound Edge (snippet):

Service principal Graph permission BloodHound edge What it buys you
Chozo-Memory-Core RoleManagement.ReadWrite.Directory AZMGGrantRole Grant any directory role to anyone — including Global Administrator. The nuclear option.
Aurora-Unit-242 AppRoleAssignment.ReadWrite.All AZMGGrantAppRoles Grant Graph app-roles to any SP, including itself — bootstrap straight up to RoleManagement.ReadWrite.Directory.
Metroid-Incubator Application.ReadWrite.All AZMGAddSecret / AZMGAddOwner Add credentials (or owners) to any app — take over a more privileged SP.
Norfair-Lavafall GroupMember.ReadWrite.All AZMGAddMember Add members to any group, role-assignable ones included.
Ridley-Cybernetics Directory.ReadWrite.All (broad directory write) The kitchen-sink grant; BloodHound treats it as highly privileged.

Now that I had a direction and some inspiration from Sean Metcalf’s Bsides Presentation I started slamming some MsGraph commands using some tooling I’d built back in my previous position for managing Azure tenants. And, of course, I struggled in the very beginning just to get MsGraph to connect to my environment. Why, you may ask? Well, I was too excited after getting it created and proceeded to test some basic MsGraph commands, which all failed. The root cause? Well, the environment took a good 3 hours to fully provision, and I hadn’t accounted for that. I just spun it up and expected everything to work immediately… thanks, Microsoft minutes!

I’ve learned from other security practitioners and a lot of IT administrators to KISS (Keep It Simple, Stupid). So, with that mentality in mind, I approached the setup in 8 steps. And after two hours of struggling, I was able to get my users, groups, roles, and apps into the environment. Here’s what the whole environment looks like in BloodHound after the second deployment script (see below).

BloodHound Graph:

ShortestPath

alt text

Sample Attack Path

alt text

The second PowerShell script that adds additional edges was something I thought of after hearing an internal meeting last week about idempotent code. Truth be told, I’m only a few days old on experiencing that word in the tech world. After some further research into some documentation, I was surprised to know that the concept was rather easy and foundational.. I just had never heard the term used before. But, I was surprised to know how much it mattered in code and environments, especially test environments. Essentially, idempotent code is that it can run or loop as many times as possible and it will leave the tenant in the same state even after running the script. Pretty simple concept, but never heard of the terminology before (I don’t have a developer background).

Code Snippet:

foreach ($e in $NewRoleEdges) {
    $prinId = Get-UserId $e.Upn
    $rid    = Get-RoleDefinitionId $e.Role
    $f      = "roleDefinitionId eq '$rid' and principalId eq '$prinId'"
    $existing = (Invoke-Graph GET ".../roleAssignments?`$filter=$([uri]::EscapeDataString($f))").value
    if ($existing) { Write-Host "    = $($e.Role) -> $($e.Upn) already present, skipping"; continue }
    $body = @{ principalId = $prinId; roleDefinitionId = $rid; directoryScopeId = '/' }
    Invoke-Graph POST ".../roleAssignments" $body | Out-Null
    Write-Host "    + $($e.Role) -> $($e.Upn)"
}

The basics of it is that it checks whether the role assignment already exists before it tries to add anything. If it’s already there, the script skips it and prints an =; if it’s missing, that’s the only time it actually creates it, and prints a +. That’s the whole trick! I can run it once or ten times and the tenant lands in the exact same state either way, no duplicate assignments and no errors on the second pass. For a test environment, this is exactly what I want.. reproducibility. I’m building a tool that exploits BloodHound findings and I need to be able to rollback or re-execute my deployment for validating exploitation. I’ll be sharing more about the tool in later posts, which I’m super excited to get into the nitty-gritty about. However, that’s for another time. Over the next few weeks, I’ll continue to be fine-tuning this environment and will more than likely start adding some Azure resources and a trial P2 license so that I can practice a few more BloodHound edges because some edges have a prerequiste. Anyways, if you made it this far, thanks for reading! Catch you in the next one!

Deployment Script

[CmdletBinding()]
param(
    [Parameter(Mandatory)]
    [string]$TenantDomain,

    [string]$CredentialOutFile = ".\Phazon-Lab-Credentials.csv",

    [switch]$SkipRoleAssignableGroups,
    [switch]$DisableSecurityDefaults,
    [switch]$Destroy
)

$ErrorActionPreference = 'Stop'
$GraphBase  = "https://graph.microsoft.com/v1.0"
$GraphAppId = "00000003-0000-0000-c000-000000000000"

$LabUsers = @(
    @{ Key='motherbrain';  Upn='mother.brain';  Name='Mother Brain'   }
    @{ Key='ridley';       Upn='ridley';        Name='Ridley'         }
    @{ Key='kraid';        Upn='kraid';         Name='Kraid'          }
    @{ Key='parasitex';    Upn='parasite.x';    Name='X Parasite'     }
    @{ Key='larva';        Upn='metroid.larva'; Name='Metroid Larva'  }
    @{ Key='nightmare';    Upn='nightmare';     Name='Nightmare'      }
    @{ Key='phantoon';     Upn='phantoon';      Name='Phantoon'       }
    @{ Key='samus';        Upn='samus.aran';    Name='Samus Aran'     }
    @{ Key='crocomire';    Upn='crocomire';     Name='Crocomire'      }
    @{ Key='goldtorizo';   Upn='gold.torizo';   Name='Gold Torizo'    }
    @{ Key='draygon';      Upn='draygon';       Name='Draygon'        }
)

$LabGroups = @(
    @{ Key='pirate-command'; Name='Space Pirate Command';  RoleAssignable=$true  }
    @{ Key='zebes-guardians';Name='Zebes Guardians';       RoleAssignable=$true  }
    @{ Key='chozo-statues';  Name='Chozo Statues';         RoleAssignable=$false }
    @{ Key='pirate-troopers';Name='Pirate Troopers';       RoleAssignable=$false }
)

$LabApps = @(
    @{ Key='chozo-core';   Name='Chozo-Memory-Core';  GraphPerm='RoleManagement.ReadWrite.Directory' }
    @{ Key='aurora-242';   Name='Aurora-Unit-242';    GraphPerm='AppRoleAssignment.ReadWrite.All'     }
    @{ Key='incubator';    Name='Metroid-Incubator';  GraphPerm='Application.ReadWrite.All'           }
    @{ Key='norfair';      Name='Norfair-Lavafall';   GraphPerm='GroupMember.ReadWrite.All'           }
    @{ Key='ridley-cyber'; Name='Ridley-Cybernetics'; GraphPerm='Directory.ReadWrite.All'             }
)

$RoleAssignments = @(
    @{ Role='Global Administrator';                   Target='motherbrain';     Type='user'  }
    @{ Role='Privileged Authentication Administrator';Target='crocomire';       Type='user'  }
    @{ Role='Application Administrator';              Target='parasitex';       Type='user'  }
    @{ Role='Cloud Application Administrator';        Target='goldtorizo';      Type='user'  }
    @{ Role='Helpdesk Administrator';                Target='kraid';           Type='user'  }
    @{ Role='Privileged Role Administrator';         Target='pirate-command';  Type='group' }
    @{ Role='Privileged Authentication Administrator';Target='zebes-guardians';Type='group' }
)

$AppOwners = @(
    @{ App='chozo-core';   Owner='larva'      }
    @{ App='aurora-242';   Owner='samus'      }
    @{ App='incubator';    Owner='nightmare'  }
    @{ App='norfair';      Owner='draygon'    }
    @{ App='ridley-cyber'; Owner='goldtorizo' }
)

$GroupOwners = @(
    @{ Group='pirate-command';  Owner='ridley'   }
    @{ Group='zebes-guardians'; Owner='phantoon' }
)

$GroupMembers = @(
    @{ Group='chozo-statues'; Member='kraid';           Type='user'  }
    @{ Group='chozo-statues'; Member='ridley';          Type='user'  }
    @{ Group='chozo-statues'; Member='pirate-troopers'; Type='group' }
)

function Invoke-Graph {
    param([string]$Method,[string]$Uri,[object]$Body)
    $params = @{ Method = $Method; Uri = $Uri }
    if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
        $params.Body        = ($Body | ConvertTo-Json -Depth 10 -Compress)
        $params.ContentType = 'application/json'
    }
    Invoke-MgGraphRequest @params
}

function New-LabPassword {

    $u='ABCDEFGHJKLMNPQRSTUVWXYZ'; $l='abcdefghijkmnpqrstuvwxyz'
    $d='23456789'; $s='!@#$%^&*-_=+'
    $all = ($u+$l+$d+$s).ToCharArray()
    $pw  = @($u[(Get-Random -Max $u.Length)],$l[(Get-Random -Max $l.Length)],
             $d[(Get-Random -Max $d.Length)],$s[(Get-Random -Max $s.Length)])
    1..16 | ForEach-Object { $pw += $all[(Get-Random -Max $all.Length)] }
    -join ($pw | Sort-Object {Get-Random})
}

function Get-RoleDefinitionId {
    param([string]$DisplayName)
    $enc = [uri]::EscapeDataString($DisplayName)
    $r = Invoke-Graph GET "$GraphBase/roleManagement/directory/roleDefinitions?`$filter=displayName eq '$enc'"
    if (-not $r.value) { throw "Role definition '$DisplayName' not found." }
    $r.value[0].id
}

function Get-GraphAppRoleId {
    param([string]$Value)
    if (-not $script:GraphSp) {
        $script:GraphSp = (Invoke-Graph GET "$GraphBase/servicePrincipals?`$filter=appId eq '$GraphAppId'").value[0]
    }
    $role = $script:GraphSp.appRoles | Where-Object { $_.value -eq $Value -and $_.allowedMemberTypes -contains 'Application' }
    if (-not $role) { throw "Graph app role '$Value' not found." }
    [pscustomobject]@{ AppRoleId = $role.id; ResourceId = $script:GraphSp.id }
}

$scopes = @(
    'Directory.ReadWrite.All','RoleManagement.ReadWrite.Directory',
    'Application.ReadWrite.All','Group.ReadWrite.All','User.ReadWrite.All',
    'AppRoleAssignment.ReadWrite.All'
)
if ($DisableSecurityDefaults) { $scopes += 'Policy.ReadWrite.SecurityDefaults' }

Write-Host "Connecting to Microsoft Graph (sign in as a Global Admin of $TenantDomain)..." -ForegroundColor Cyan
Connect-MgGraph -Scopes $scopes -NoWelcome

if ($Destroy) {
    Write-Host "`n*** TEARDOWN: removing the Phazon lab ***`n" -ForegroundColor Yellow

    foreach ($a in $LabApps) {
        $app = (Invoke-Graph GET "$GraphBase/applications?`$filter=displayName eq '$($a.Name)'").value
        foreach ($x in $app) {
            try { Invoke-Graph DELETE "$GraphBase/applications/$($x.id)" | Out-Null
                  Write-Host "  removed app   $($a.Name)" } catch {}
        }
    }
    foreach ($g in $LabGroups) {
        $grp = (Invoke-Graph GET "$GraphBase/groups?`$filter=displayName eq '$($g.Name)'").value
        foreach ($x in $grp) {
            try { Invoke-Graph DELETE "$GraphBase/groups/$($x.id)" | Out-Null
                  Write-Host "  removed group $($g.Name)" } catch {}
        }
    }
    foreach ($u in $LabUsers) {
        $upn = "$($u.Upn)@$TenantDomain"
        try { Invoke-Graph DELETE "$GraphBase/users/$upn" | Out-Null
              Write-Host "  removed user  $upn" } catch {}
    }
    Write-Host "`nTeardown complete. (Deleted objects sit in the recycle bin ~30 days.)" -ForegroundColor Green
    Disconnect-MgGraph | Out-Null
    return
}

$ids   = @{}
$creds = @()

Write-Host "`n[1/8] Creating users..." -ForegroundColor Cyan
foreach ($u in $LabUsers) {
    $upn = "$($u.Upn)@$TenantDomain"
    $pw  = New-LabPassword
    $body = @{
        accountEnabled    = $true
        displayName       = $u.Name
        mailNickname      = ($u.Upn -replace '\.','')
        userPrincipalName = $upn
        passwordProfile   = @{ forceChangePasswordNextSignIn = $false; password = $pw }
    }
    $new = Invoke-Graph POST "$GraphBase/users" $body
    $ids[$u.Key] = $new.id
    $creds += [pscustomobject]@{ DisplayName=$u.Name; UserPrincipalName=$upn; Password=$pw }
    Write-Host "    + $($u.Name)  ($upn)"
}

Write-Host "`n[2/8] Creating groups..." -ForegroundColor Cyan
foreach ($g in $LabGroups) {
    $wantRA = $g.RoleAssignable -and -not $SkipRoleAssignableGroups
    $body = @{
        displayName     = $g.Name
        mailEnabled     = $false
        mailNickname    = ($g.Key -replace '[^a-zA-Z0-9]','')
        securityEnabled = $true
    }
    if ($wantRA) { $body.isAssignableToRole = $true }
    try {
        $new = Invoke-Graph POST "$GraphBase/groups" $body
        $ids[$g.Key] = $new.id
        $tag = if ($wantRA) { '[role-assignable]' } else { '' }
        Write-Host "    + $($g.Name) $tag"
    } catch {
        if ($wantRA) {
            Write-Warning "    Could not create role-assignable group '$($g.Name)' (needs Entra ID P1/P2 -- Can use a trial P2 license for 30 days.. otherwise skip). Creating as a normal group; its role-based paths will be skipped."
            $body.Remove('isAssignableToRole')
            $new = Invoke-Graph POST "$GraphBase/groups" $body
            $ids[$g.Key] = $new.id
            $g.RoleAssignable = $false
        } else { throw }
    }
}

Write-Host "`n[3/8] Creating applications + service principals (App Instance Lock OFF)..." -ForegroundColor Cyan
foreach ($a in $LabApps) {
    $appBody = @{
        displayName    = $a.Name
        signInAudience = 'AzureADMyOrg'
        servicePrincipalLockConfiguration = @{ isEnabled = $false }
    }
    $app = Invoke-Graph POST "$GraphBase/applications" $appBody
    $sp  = Invoke-Graph POST "$GraphBase/servicePrincipals" @{ appId = $app.appId }
    $ids["app:$($a.Key)"] = $app.id
    $ids["sp:$($a.Key)"]  = $sp.id
    Write-Host "    + $($a.Name)  (app + SP)"
}

Write-Host "`n[4/8] Granting MS Graph application permissions to service principals..." -ForegroundColor Cyan
foreach ($a in $LabApps) {
    $role = Get-GraphAppRoleId -Value $a.GraphPerm
    $body = @{ principalId = $ids["sp:$($a.Key)"]; resourceId = $role.ResourceId; appRoleId = $role.AppRoleId }
    Invoke-Graph POST "$GraphBase/servicePrincipals/$($ids["sp:$($a.Key)"])/appRoleAssignments" $body | Out-Null
    Write-Host "    + $($a.Name) -> $($a.GraphPerm)"
}

Write-Host "`n[5/8] Assigning Entra directory roles..." -ForegroundColor Cyan
foreach ($r in $RoleAssignments) {
    if ($r.Type -eq 'group') {
        $g = $LabGroups | Where-Object Key -eq $r.Target
        if (-not $g.RoleAssignable) { Write-Warning "    skip '$($r.Role)' -> '$($r.Target)' (group not role-assignable)"; continue }
    }
    if (-not $ids.ContainsKey($r.Target)) { Write-Warning "    skip '$($r.Role)' (missing $($r.Target))"; continue }
    $rid  = Get-RoleDefinitionId -DisplayName $r.Role
    $body = @{ principalId = $ids[$r.Target]; roleDefinitionId = $rid; directoryScopeId = '/' }
    Invoke-Graph POST "$GraphBase/roleManagement/directory/roleAssignments" $body | Out-Null
    Write-Host "    + $($r.Role) -> $($r.Target)"
}

Write-Host "`n[6/8] Setting application owners (AZOwns -> AZAddSecret)..." -ForegroundColor Cyan
foreach ($o in $AppOwners) {
    $ref = @{ '@odata.id' = "$GraphBase/directoryObjects/$($ids[$o.Owner])" }
    Invoke-Graph POST ("$GraphBase/applications/$($ids["app:$($o.App)"])/owners/" + '$ref') $ref | Out-Null
    Write-Host "    + $($o.Owner) owns $($o.App)"
}

Write-Host "`n[7/8] Setting group owners (delegated owner of role-assignable group)..." -ForegroundColor Cyan
foreach ($o in $GroupOwners) {
    $ref = @{ '@odata.id' = "$GraphBase/directoryObjects/$($ids[$o.Owner])" }
    Invoke-Graph POST ("$GraphBase/groups/$($ids[$o.Group])/owners/" + '$ref') $ref | Out-Null
    Write-Host "    + $($o.Owner) owns group $($o.Group)"
}

Write-Host "`n[8/8] Adding regular-group memberships (structural)..." -ForegroundColor Cyan
foreach ($m in $GroupMembers) {
    $ref = @{ '@odata.id' = "$GraphBase/directoryObjects/$($ids[$m.Member])" }
    Invoke-Graph POST ("$GraphBase/groups/$($ids[$m.Group])/members/" + '$ref') $ref | Out-Null
    Write-Host "    + $($m.Member) is member of $($m.Group)"
}

if ($DisableSecurityDefaults) {
    Write-Host "`n[+] Disabling Security Defaults (enables password auth for the lab)..." -ForegroundColor Cyan
    try {
        Invoke-Graph PATCH "$GraphBase/policies/identitySecurityDefaultsEnforcementPolicy" @{ isEnabled = $false } | Out-Null
        Write-Host "    Security Defaults disabled."
    } catch { Write-Warning "    Could not disable Security Defaults: $($_.Exception.Message)" }
}

$creds | Export-Csv -Path $CredentialOutFile -NoTypeInformation -Encoding UTF8
Write-Host "`n=====================================================================" -ForegroundColor Green
Write-Host " Phazon lab deployed. Crown jewel = Mother Brain (Global Administrator)." -ForegroundColor Green
Write-Host " Credentials written to: $CredentialOutFile"                              -ForegroundColor Green
Write-Host "=====================================================================`n"  -ForegroundColor Green

Disconnect-MgGraph | Out-Null

Adding Additional AzureHound Edges

[CmdletBinding()]
param(
    [Parameter(Mandatory)][string]$TenantDomain,
    [switch]$Rollback
)

$ErrorActionPreference = 'Stop'
$GraphBase  = "https://graph.microsoft.com/v1.0"

$FicAppName = 'METROID-PRIME-CORE'
$FicName    = 'phazon-gh-trust'

$NewRoleEdges = @(
    @{ Upn='ridley';    Role='Privileged Role Administrator' }
    @{ Upn='draygon';   Role='Groups Administrator'          }
    @{ Upn='nightmare'; Role='User Administrator'            }
)

function Invoke-Graph {
    param([string]$Method,[string]$Uri,[object]$Body)
    $params = @{ Method = $Method; Uri = $Uri }
    if ($PSBoundParameters.ContainsKey('Body') -and $null -ne $Body) {
        $params.Body        = ($Body | ConvertTo-Json -Depth 10 -Compress)
        $params.ContentType = 'application/json'
    }
    Invoke-MgGraphRequest @params
}
function Get-RoleDefinitionId {
    param([string]$DisplayName)
    $enc = [uri]::EscapeDataString($DisplayName)
    $r = Invoke-Graph GET "$GraphBase/roleManagement/directory/roleDefinitions?`$filter=displayName eq '$enc'"
    if (-not $r.value) { throw "Role definition '$DisplayName' not found." }
    $r.value[0].id
}
function Get-UserId {
    param([string]$LocalPart)
    $upn = "$LocalPart@$TenantDomain"
    $r = Invoke-Graph GET "$GraphBase/users/$upn"
    $r.id
}
function Get-AppByName {
    param([string]$Name)
    (Invoke-Graph GET "$GraphBase/applications?`$filter=displayName eq '$Name'").value
}

$scopes = @('RoleManagement.ReadWrite.Directory','Application.ReadWrite.All',
            'User.Read.All','Directory.Read.All')
Write-Host "Connecting to Microsoft Graph (sign in as a Global Admin of $TenantDomain)..." -ForegroundColor Cyan

Connect-MgGraph -Scopes $scopes -UseDeviceCode -NoWelcome

if ($Rollback) {
    Write-Host "`n*** ROLLBACK ***`n" -ForegroundColor Yellow

    foreach ($e in $NewRoleEdges) {
        try {
            $prinId = Get-UserId $e.Upn
            $rid    = Get-RoleDefinitionId $e.Role
            $f      = "roleDefinitionId eq '$rid' and principalId eq '$prinId'"
            $a   = (Invoke-Graph GET "$GraphBase/roleManagement/directory/roleAssignments?`$filter=$([uri]::EscapeDataString($f))").value
            foreach ($x in $a) {
                Invoke-Graph DELETE "$GraphBase/roleManagement/directory/roleAssignments/$($x.id)" | Out-Null
                Write-Host "  removed $($e.Role) -> $($e.Upn)"
            }
        } catch { Write-Warning "  could not roll back $($e.Role) -> $($e.Upn): $($_.Exception.Message)" }
    }
    foreach ($app in (Get-AppByName $FicAppName)) {
        Invoke-Graph DELETE "$GraphBase/applications/$($app.id)" | Out-Null
        Write-Host "  removed app $FicAppName"
    }
    Write-Host "`nRollback complete." -ForegroundColor Green
    Disconnect-MgGraph | Out-Null
    return
}

Write-Host "`n[1/2] Adding directory-role edges ..." -ForegroundColor Cyan
foreach ($e in $NewRoleEdges) {
    $prinId = Get-UserId $e.Upn
    $rid    = Get-RoleDefinitionId $e.Role
    $f      = "roleDefinitionId eq '$rid' and principalId eq '$prinId'"
    $existing = (Invoke-Graph GET "$GraphBase/roleManagement/directory/roleAssignments?`$filter=$([uri]::EscapeDataString($f))").value
    if ($existing) { Write-Host "    = $($e.Role) -> $($e.Upn) already present, skipping"; continue }
    $body = @{ principalId = $prinId; roleDefinitionId = $rid; directoryScopeId = '/' }
    Invoke-Graph POST "$GraphBase/roleManagement/directory/roleAssignments" $body | Out-Null
    Write-Host "    + $($e.Role) -> $($e.Upn)"
}

Write-Host "`n[2/2] Federated identity credential app (AZAuthenticatesTo)..." -ForegroundColor Cyan
$app = Get-AppByName $FicAppName | Select-Object -First 1
if (-not $app) {
    $app = Invoke-Graph POST "$GraphBase/applications" @{
        displayName    = $FicAppName
        signInAudience = 'AzureADMyOrg'
        servicePrincipalLockConfiguration = @{ isEnabled = $false }
    }
    Write-Host "    + created app $FicAppName"

    Start-Sleep -Seconds 15
} else {
    Write-Host "    = app $FicAppName already exists"
}

$spExists = (Invoke-Graph GET "$GraphBase/servicePrincipals?`$filter=appId eq '$($app.appId)'").value
if (-not $spExists) {
    Invoke-Graph POST "$GraphBase/servicePrincipals" @{ appId = $app.appId } | Out-Null
    Write-Host "    + created service principal"
} else { Write-Host "    = service principal already exists" }

$fics = (Invoke-Graph GET "$GraphBase/applications/$($app.id)/federatedIdentityCredentials").value
if (-not ($fics | Where-Object name -eq $FicName)) {
    $body = @{
        name      = $FicName
        issuer    = 'https://token.actions.githubusercontent.com'
        subject   = 'repo:phazon-labs/metroid-prime:ref:refs/heads/main'
        audiences = @('api://AzureADTokenExchange')
    }
    for ($i=1; $i -le 5; $i++) {
        try {
            Invoke-Graph POST "$GraphBase/applications/$($app.id)/federatedIdentityCredentials" $body | Out-Null
            Write-Host "    + added federated identity credential '$FicName'"
            break
        } catch {
            if ($i -eq 5) { throw }
            Write-Warning "    app not replicated yet, retry $i/5..."; Start-Sleep -Seconds 10
        }
    }
} else { Write-Host "    = FIC '$FicName' already present" }

Disconnect-MgGraph | Out-Null