Providers/Entra/Public/Get-EntraServiceApp.ps1
|
function Get-EntraServiceApp { <# .SYNOPSIS Reports the bootstrapped service app, and whether its credential still works .DESCRIPTION Answers the question you actually have after a bootstrap: what am I meant to connect as, and does it still work. Both halves are checked independently, because they fail apart. The record on disk says which application and which certificate; the tenant says whether the application still exists and what it is consented for; the certificate store says whether the private key is still here. Any one of the three can be missing while the others look fine - a certificate deleted from the store leaves a perfectly valid application nobody can authenticate as, and an application deleted in the portal leaves a record and a certificate that point at nothing. The certificate is never exported and its private key is never returned. There is nothing here that would be dangerous in a transcript. .PARAMETER TenantId Which tenant's record to read. Defaults to the connected tenant. .PARAMETER TestCredential Attempts a token request with the stored certificate and reports whether it worked .OUTPUTS EntraServiceAppStatus .EXAMPLE PS> Get-EntraServiceApp -TestCredential DESCRIPTION: Reports what is stored and proves the credential still authenticates OUTPUT: The client id, thumbprint, granted permissions and a CredentialWorks flag USE CASE: Working out why a connection stopped working .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding()] [OutputType('EntraServiceAppStatus')] param( [Parameter()] [ValidateNotNullOrEmpty()] [string]$TenantId, [Parameter()] [System.Security.SecureString]$VaultPassword, [Parameter()] [switch]$TestCredential ) if (-not $TenantId) { $connection = Get-EntraConnection $TenantId = $connection.TenantId } $recordPath = Get-TestCredentialPath -TenantId $TenantId if (-not (Test-Path -LiteralPath $recordPath)) { Write-Warning ("No credential record for tenant $TenantId at $recordPath. Run New-TestServiceApp " + "after connecting with -Interactive.") return } $record = $null try { $record = Get-Content -LiteralPath $recordPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Write-Error "The credential record at $recordPath could not be read: $($_.Exception.Message)" -ErrorAction Stop return } # Records written before the vault path existed carry no keyProtection and mean the store. $protection = if ($record.PSObject.Properties['keyProtection'] -and $record.keyProtection) { $record.keyProtection } else { 'CertificateStore' } # The private key, checked separately from the record that names it, and looked for where # the record says it is rather than always in the store. $certificate = $null try { if ($protection -eq 'SecretStore') { # -VaultPassword is threaded through rather than left to the default. Without it # this reported CertificatePresent and CredentialWorks as false on a store whose # password is not the module's own - contradicting a connection that had just # succeeded, which is worse than not reporting at all. $stored = Get-EntraStoredCredential -TenantId $TenantId -VaultPassword $VaultPassword -ErrorAction Stop $certificate = $stored.Certificate } else { $certificate = Get-TestCertificate -Thumbprint $record.certificateThumbprint -ErrorAction Stop } } catch { Write-Verbose "The private key is not usable: $($_.Exception.Message)" } # The application, checked separately again. Only attempted when connected, since reading # it needs a token of its own. $applicationExists = $null $grantedNow = @() if ($script:EntraConnection) { try { $null = Invoke-EntraRequest -Method GET -Path "/applications/$($record.applicationObjectId)" ` -Query @{ '$select' = 'id' } $applicationExists = $true $assignments = @(Invoke-EntraRequest -Method GET -Paginate ` -Path "/servicePrincipals/$($record.servicePrincipalId)/appRoleAssignments") $catalogue = @{} foreach ($permission in (Get-EntraSeedData -Name 'EntraServiceAppPermissions')) { $catalogue[$permission.AppRoleId] = $permission.Permission } $grantedNow = @($assignments | ForEach-Object { if ($catalogue.ContainsKey($_.appRoleId)) { $catalogue[$_.appRoleId] } else { $_.appRoleId } } | Sort-Object) } catch { $applicationExists = $false Write-Verbose "Could not read the application back: $($_.Exception.Message)" } } $credentialWorks = $null if ($TestCredential) { if (-not $certificate) { $credentialWorks = $false } else { try { $probe = @{ TenantId = $TenantId ClientId = $record.clientId Certificate = $certificate AuthMode = 'Certificate' GraphBaseUri = if ($script:EntraConnection) { $script:EntraConnection.GraphBaseUri } else { 'https://graph.microsoft.com' } AccessToken = $null TokenExpiresOn = $null TokenRoles = @() } $null = Get-EntraAccessToken -Connection $probe $credentialWorks = $true } catch { $credentialWorks = $false Write-Verbose "Credential test failed: $($_.Exception.Message)" } } } return [PSCustomObject]@{ PSTypeName = 'EntraServiceAppStatus' TenantId = $record.tenantId TenantName = $record.tenantName DisplayName = $record.displayName ClientId = $record.clientId CertificateThumbprint = $record.certificateThumbprint CertificateExpires = $record.certificateExpires KeyProtection = $protection VaultName = $(if ($record.PSObject.Properties['vaultName']) { $record.vaultName } else { $null }) CertificatePresent = [bool]$certificate ApplicationExists = $applicationExists GrantedAtBootstrap = @($record.grantedPermissions) GrantedNow = $grantedNow CredentialWorks = $credentialWorks RecordPath = $recordPath CreatedOn = $record.createdOn } } |