Get-PowerApp.psm1
#Requires -Version 5.1 # Get-PowerApp Module # Provides caching and management for Power Platform apps retrieval #region Private Functions function Get-CacheFilePath { [CmdletBinding()] param() $cacheDir = Join-Path $HOME '.powerapps' if (-not (Test-Path $cacheDir)) { New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null } return Join-Path $cacheDir 'cache.json' } function Get-CacheData { [CmdletBinding()] param() $cacheFile = Get-CacheFilePath if (Test-Path $cacheFile) { try { $content = Get-Content $cacheFile -Raw -ErrorAction Stop $jsonData = ($content | ConvertFrom-Json) # Convert PSCustomObject to hashtables for dynamic property support $cache = @{ tokens = @{} environments = @{} } if ($jsonData.tokens) { foreach ($prop in $jsonData.tokens.PSObject.Properties) { $cache.tokens[$prop.Name] = $prop.Value } } if ($jsonData.environments) { foreach ($prop in $jsonData.environments.PSObject.Properties) { $cache.environments[$prop.Name] = $prop.Value } } return $cache } catch { Write-Warning "Failed to read cache file: $($_.Exception.Message)" return @{ tokens = @{}; environments = @{} } } } return @{ tokens = @{}; environments = @{} } } function Set-CacheData { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$CacheData ) $cacheFile = Get-CacheFilePath try { $CacheData | ConvertTo-Json -Depth 10 | Set-Content $cacheFile -ErrorAction Stop } catch { Write-Warning "Failed to write cache file: $($_.Exception.Message)" } } function Get-TokenClaims { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Token ) try { $claims = ($Token.Split('.')[1].Replace('-','+').Replace('_','/')) while($claims.Length % 4) { $claims += '=' } $claimsJson = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($claims)) | ConvertFrom-Json return @{ oid = $claimsJson.oid tenantId = $claimsJson.tid expiry = [DateTimeOffset]::FromUnixTimeSeconds($claimsJson.exp).DateTime.ToUniversalTime().ToString('o') name = $claimsJson.Name } } catch { Write-Warning "Failed to extract claims from token: $($_.Exception.Message)" return @{ oid = $null tenantId = $null expiry = [DateTime]::UtcNow.AddHours(1).ToString('o') } } } function Test-TokenExpiry { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$ExpiresAt ) try { $expiryTime = [DateTime]::Parse($ExpiresAt).ToUniversalTime() $currentTime = [DateTime]::UtcNow $bufferTime = $currentTime.AddMinutes(5) # 5-minute buffer return $expiryTime -le $bufferTime } catch { Write-Warning "Failed to parse token expiry time: $ExpiresAt" return $true # Assume expired if we can't parse } } function Get-TokenFromCache { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Oid ) $cache = Get-CacheData if ($cache.tokens -and $cache.tokens[$Oid]) { $tokenData = $cache.tokens[$Oid] if (-not (Test-TokenExpiry -ExpiresAt $tokenData.expiresAt)) { return $tokenData } else { Write-Verbose "Cached token for user $Oid has expired" } } return $null } function Set-TokenInCache { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Oid, [Parameter(Mandatory = $true)] [string]$AccessToken, [Parameter(Mandatory = $true)] [string]$ExpiresAt, [Parameter(Mandatory = $false)] [string]$LoginHint, [Parameter(Mandatory = $true)] [string]$TenantId, [Parameter(Mandatory = $false)] [string]$Name ) $cache = Get-CacheData if (-not $cache.tokens) { $cache.tokens = @{} } $cache.tokens[$Oid] = @{ accessToken = $AccessToken expiresAt = $ExpiresAt loginHint = $LoginHint tenantId = $TenantId lastUsed = [DateTime]::UtcNow.ToString('o') name = $Name } Set-CacheData -CacheData $cache } function Get-EnvironmentsFromCache { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Oid ) $cache = Get-CacheData if ($cache.environments -and $cache.environments[$Oid]) { $envData = $cache.environments[$Oid] # Check if cache is less than 24 hours old if ($envData.lastUpdated) { try { $lastUpdated = [DateTime]::Parse($envData.lastUpdated).ToUniversalTime() $cacheAge = [DateTime]::UtcNow - $lastUpdated if ($cacheAge.TotalHours -lt 24) { return $envData } else { Write-Verbose "Environment cache for user $Oid is stale (age: $($cacheAge.TotalHours.ToString('F1')) hours)" } } catch { Write-Warning "Failed to parse environment cache timestamp: $($envData.lastUpdated)" } } } return $null } function Set-EnvironmentsInCache { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Oid, [Parameter(Mandatory = $true)] [array]$Environments, [Parameter(Mandatory = $false)] [string]$LastSelected ) $cache = Get-CacheData if (-not $cache.environments) { $cache.environments = @{} } $envData = @{ lastUpdated = [DateTime]::UtcNow.ToString('o') environments = $Environments } if ($LastSelected) { $envData.lastSelected = $LastSelected } elseif ($cache.environments[$Oid] -and $cache.environments[$Oid].lastSelected) { # Preserve existing last selected $envData.lastSelected = $cache.environments[$Oid].lastSelected } $cache.environments[$Oid] = $envData Set-CacheData -CacheData $cache } function Get-PpapiUrl { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$BaseGuid, [Parameter(Mandatory = $false)] [string]$Kind = "environment" ) $processedGuid = $BaseGuid.Replace("-", "") $formattedGuid = $processedGuid.Substring(0, $processedGuid.Length - 1) + "." + $processedGuid.Substring($processedGuid.Length - 1) return "https://$formattedGuid.$Kind.api.test.powerplatform.com" } function Build-QueryString { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$QueryParams ) return ($QueryParams.GetEnumerator() | ForEach-Object { [System.Web.HttpUtility]::UrlEncode($_.Key) + '=' + [System.Web.HttpUtility]::UrlEncode($_.Value) }) -join '&' } function Show-CachedUserMenu { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$CacheData ) $users = @() foreach ($oid in $CacheData.tokens.Keys) { $tokenData = $CacheData.tokens[$oid] $users += @{ oid = $oid loginHint = $tokenData.loginHint lastUsed = $tokenData.lastUsed name = $tokenData.name } } # Sort by last used (most recent first) $users = @($users | Sort-Object { [DateTime]::Parse($_.lastUsed) } -Descending) Write-Host "`n=== Cached Users ===" -ForegroundColor Cyan Write-Host "" for ($i = 0; $i -lt $users.Count; $i++) { $user = $users[$i] $displayName = if ($user.loginHint) { $user.loginHint } elseif($user.name) { "$($user.name) ($($user.oid.Substring(0,8))...)" } else { "User ($($user.oid.Substring(0,8))...)" } Write-Host "[$i] $displayName" -ForegroundColor Green if ($user.lastUsed) { $lastUsedDate = [DateTime]::Parse($user.lastUsed).ToLocalTime().ToString('yyyy-MM-dd HH:mm') Write-Host " Last used: $lastUsedDate" -ForegroundColor Gray } Write-Host "" } Write-Host "[$($users.Count)] Authenticate as new user" -ForegroundColor Yellow Write-Host "" do { $selection = Read-Host "Please select a user by index (0-$($users.Count))" if ($selection -match '^\d+$' -and [int]$selection -ge 0 -and [int]$selection -le $users.Count) { $selectedIndex = [int]$selection $valid = $true } else { Write-Host "Invalid selection. Please enter a number between 0 and $($users.Count)." -ForegroundColor Red $valid = $false } } while (-not $valid) if ($selectedIndex -eq $users.Count) { return $null # New user authentication } return $users[$selectedIndex].oid } function Show-EnvironmentMenu { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [array]$Environments, [Parameter(Mandatory = $false)] [string]$LastSelected ) Write-Host "`n=== Available Environments ===" -ForegroundColor Cyan Write-Host "" $defaultIndex = -1 for ($i = 0; $i -lt $Environments.Count; $i++) { $env = $Environments[$i] $displayName = $env.properties.displayName $envId = $env.name $prefix = "[$i]" if ($LastSelected -and $envId -eq $LastSelected) { $prefix = "[$i] *" $defaultIndex = $i } Write-Host "$prefix $displayName" -ForegroundColor Green Write-Host " Environment ID: $envId" -ForegroundColor Gray Write-Host "" } if ($defaultIndex -ge 0) { Write-Host "* = Last selected environment" -ForegroundColor Yellow Write-Host "" } do { $prompt = "Please select an environment by index (0-$($Environments.Count - 1))" if ($defaultIndex -ge 0) { $prompt += " [default: $defaultIndex]" } $selection = Read-Host $prompt # Handle default selection if ([string]::IsNullOrWhiteSpace($selection) -and $defaultIndex -ge 0) { $selection = $defaultIndex.ToString() } if ($selection -match '^\d+$' -and [int]$selection -ge 0 -and [int]$selection -lt $Environments.Count) { $selectedIndex = [int]$selection $valid = $true } else { Write-Host "Invalid selection. Please enter a number between 0 and $($Environments.Count - 1)." -ForegroundColor Red $valid = $false } } while (-not $valid) $selectedEnvironment = $Environments[$selectedIndex] $selectedEnvId = $selectedEnvironment.name $selectedDisplayName = $selectedEnvironment.properties.displayName Write-Host "`n=== Selection Confirmed ===" -ForegroundColor Green Write-Host "Selected: $selectedDisplayName" -ForegroundColor Yellow Write-Host "Environment ID: $selectedEnvId" -ForegroundColor Yellow Write-Host "" return $selectedEnvId } function Get-PowerAppsFromEnvironment { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$EnvironmentId, [Parameter(Mandatory = $true)] [string]$AccessToken ) try { $rpBaseUrl = Get-PpapiUrl -BaseGuid $EnvironmentId $appsQueryParams = @{ 'api-version' = '1' '$expand' = 'unpublishedAppDefinition' } $appsQueryString = Build-QueryString -QueryParams $appsQueryParams $appsUrl = "$rpBaseUrl/powerapps/apps?$appsQueryString" $rpReqId = [System.Guid]::NewGuid() $rpCorrId = [System.Guid]::NewGuid() Write-Verbose "Retrieving Power Apps from: $appsUrl" $response = Invoke-WebRequest -UseBasicParsing -Uri $appsUrl -Headers @{ "authorization" = "Bearer $AccessToken" "x-ms-client-request-id" = $rpReqId "x-ms-correlation-id" = $rpCorrId } return $response.Content } catch { Write-Error "Failed to retrieve Power Apps: $($_.Exception.Message)" return $null } } #endregion #region Public Functions function Get-PowerApp { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$LoginHint, [Parameter(Mandatory = $false)] [switch]$IgnoreTokenCache, [Parameter(Mandatory = $false)] [switch]$IgnoreEnvironmentCache, [Parameter(Mandatory = $false)] [string]$EnvironmentId ) Write-Verbose "Starting Get-PowerApp" # Step 1: Handle authentication and token retrieval $accessToken = $null $oid = $null $tenantId = $null if (-not $IgnoreTokenCache) { $cache = Get-CacheData if ($LoginHint) { # Find cached user by login hint foreach ($cachedOid in $cache.tokens.Keys) { $tokenData = $cache.tokens[$cachedOid] if ($tokenData.loginHint -eq $LoginHint -or $tokenData.name -eq $LoginHint) { $cachedToken = Get-TokenFromCache -Oid $cachedOid if ($cachedToken) { Write-Verbose "Using cached token for $LoginHint" $accessToken = $cachedToken.accessToken $oid = $cachedOid $tenantId = $cachedToken.tenantId break } } } } else { # Show user selection menu if we have cached users if ($cache.tokens -and $cache.tokens.Count -gt 0) { $selectedOid = Show-CachedUserMenu -CacheData $cache if ($selectedOid) { $cachedToken = Get-TokenFromCache -Oid $selectedOid if ($cachedToken) { Write-Verbose "Using cached token for selected user" $accessToken = $cachedToken.accessToken $oid = $selectedOid $tenantId = $cachedToken.tenantId $LoginHint = $cachedToken.loginHint } } } } } # If no cached token found, authenticate if (-not $accessToken) { Write-Host "Authenticating..." -ForegroundColor Yellow try { if ($LoginHint) { $tokenResponse = Get-AzureToken -Scopes "https://api.test.powerplatform.com/.default" -LoginHint $LoginHint } else { $tokenResponse = Get-AzureToken -Scopes "https://api.test.powerplatform.com/.default" } $accessToken = $tokenResponse.AccessToken $claims = Get-TokenClaims $accessToken $oid = $claims.oid $tenantId = $claims.tenantId $expiry = $claims.expiry $name = $claims.name if ($oid -and $tenantId) { # Cache the new token Set-TokenInCache -Oid $oid -AccessToken $accessToken -ExpiresAt $expiry -LoginHint $LoginHint -TenantId $tenantId -Name $name Write-Verbose "Cached new token for user $oid" } } catch { Write-Error "Failed to authenticate: $($_.Exception.Message)" return } } if (-not $accessToken -or -not $oid -or -not $tenantId) { Write-Error "Failed to obtain required authentication information" return } # Step 2: Handle environment retrieval $environments = $null $lastSelected = $null if (-not $IgnoreEnvironmentCache) { $cachedEnvData = Get-EnvironmentsFromCache -Oid $oid if ($cachedEnvData) { Write-Verbose "Using cached environment data" $environments = $cachedEnvData.environments $lastSelected = $cachedEnvData.lastSelected } } # If no cached environments or ignoring cache, fetch from API if (-not $environments) { Write-Host "Retrieving environments..." -ForegroundColor Yellow try { $baseUrl = Get-PpapiUrl -BaseGuid $tenantId -Kind "tenant" $queryParams = @{ '$expand' = 'properties.permissions' 'api-version' = '1' '$top' = '200' '$filter' = "minimumAppPermission eq 'CanEdit' and properties.environmentSku ne 'Teams'" } $queryString = Build-QueryString -QueryParams $queryParams $finalUrl = "$baseUrl/powerapps/environments?$queryString" $sessId = [System.Guid]::NewGuid() $envReqId = [System.Guid]::NewGuid() $response = Invoke-WebRequest -UseBasicParsing -Uri $finalUrl -Headers @{ "method" = "GET" "authorization" = "Bearer $accessToken" "origin" = "https://make.test.powerapps.com" "x-ms-client-request-id" = $envReqId "x-ms-client-session-id" = $sessId } $environmentsData = ($response.Content | ConvertFrom-Json).value if ($environmentsData.Count -eq 0) { Write-Warning "No environments found for the current user" return } # Cache the environments Set-EnvironmentsInCache -Oid $oid -Environments $environmentsData $environments = $environmentsData Write-Verbose "Retrieved and cached $($environments.Count) environments" } catch { Write-Error "Failed to retrieve environments: $($_.Exception.Message)" return } } # Step 3: Handle environment selection $selectedEnvId = $null if ($EnvironmentId) { # Validate the provided environment ID $matchingEnv = $environments | Where-Object { $_.name -eq $EnvironmentId } if ($matchingEnv) { $selectedEnvId = $EnvironmentId Write-Verbose "Using provided environment ID: $EnvironmentId" } else { Write-Warning "Provided environment ID '$EnvironmentId' not found. Showing environment selection." } } if (-not $selectedEnvId) { $selectedEnvId = Show-EnvironmentMenu -Environments $environments -LastSelected $lastSelected # Update last selected in cache if ($selectedEnvId) { $cache = Get-CacheData if ($cache.environments -and $cache.environments[$oid]) { if (-not ($cache.environments.$oid | Get-Member -Name "lastSelected" -MemberType Properties)) { $cache.environments.$oid | Add-Member -MemberType NoteProperty -Name "lastSelected" -Value $selectedEnvId } $cache.environments[$oid].lastSelected = $selectedEnvId Set-CacheData -CacheData $cache } } } if (-not $selectedEnvId) { Write-Error "No environment selected" return } # Step 4: Retrieve Power Apps from selected environment Write-Host "Retrieving Power Apps..." -ForegroundColor Yellow $selectedEnv = $environments | Where-Object { $_.name -eq $selectedEnvId } Write-Host "`n=== Selected Environment ===" -ForegroundColor Green Write-Host "Environment: $($selectedEnv.properties.displayName)" -ForegroundColor Yellow Write-Host "Environment ID: $selectedEnvId" -ForegroundColor Gray Write-Host "" $powerAppsData = Get-PowerAppsFromEnvironment -EnvironmentId $selectedEnvId -AccessToken $accessToken if ($powerAppsData) { Write-Host "=== Power Apps Retrieved Successfully ===" -ForegroundColor Green return $powerAppsData } else { Write-Error "Failed to retrieve Power Apps from environment" return } } function Clear-PowerAppsCache { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] param() $cacheFile = Get-CacheFilePath if (Test-Path $cacheFile) { if ($PSCmdlet.ShouldProcess($cacheFile, "Delete cache file")) { try { Remove-Item $cacheFile -Force Write-Host "PowerApps cache cleared successfully." -ForegroundColor Green } catch { Write-Error "Failed to clear cache: $($_.Exception.Message)" } } } else { Write-Host "No cache file found to clear." -ForegroundColor Yellow } } #endregion # Export module members Export-ModuleMember -Function Get-PowerApp, Clear-PowerAppsCache |