Providers/Okta/Public/New-OktaApp.ps1

function New-OktaApp {
    <#
    .EXTERNALHELP TestEnvironment-Help.xml
    .SYNOPSIS
        Creates the seeded app integrations and assigns groups and users to them
    #>


    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string[]]$AppName,

        [Parameter()]
        [switch]$SkipAssignment,

        [Parameter()]
        [switch]$PassThru
    )

    $connection = Get-OktaConnection

    $dataPath = Get-OktaDataPath
    $rows = @(Import-Csv -Path (Join-Path -Path $dataPath -ChildPath 'OktaApps.csv') -Encoding UTF8)
    $groupRows = @(Import-Csv -Path (Join-Path -Path $dataPath -ChildPath 'OktaGroups.csv') -Encoding UTF8)

    if ($AppName) {
        $rows = @($rows | Where-Object { $AppName -contains $_.Name })
        $unknown = @($AppName | Where-Object { $rows.Name -notcontains $_ })
        if ($unknown) { throw "No app definition for: $($unknown -join ', ')" }
    }

    $result = [PSCustomObject]@{
        TotalApps      = $rows.Count
        CreatedApps    = 0
        ExistingApps   = 0
        GroupsAssigned = 0
        UsersAssigned  = 0
        Apps           = @()
        Errors         = @()
    }

    # Resolve both directories once. At this scale the whole mapping costs two calls, and doing
    # it per assignment would cost dozens.
    $groupIdByKey = @{}
    $userIdByLogin = @{}
    if (-not $SkipAssignment) {
        $oktaNameByKey = @{}
        foreach ($groupRow in $groupRows) {
            $oktaNameByKey[$groupRow.Name] = '{0}-{1}' -f $connection.Prefix, $groupRow.DisplayName
        }

        $seededGroups = @(Get-OktaSeededGroup -Prefix $connection.Prefix `
            -SeedMarker $connection.SeedMarker)
        foreach ($group in $seededGroups) {
            $key = @($oktaNameByKey.Keys | Where-Object { $oktaNameByKey[$_] -eq $group.profile.name })
            if ($key.Count -eq 1) { $groupIdByKey[$key[0]] = $group.id }
        }

        $seeded = @(Get-OktaSeededUser -Prefix $connection.Prefix `
            -EmailDomain $connection.EmailDomain)
        foreach ($user in $seeded) { $userIdByLogin[$user.profile.login] = $user.id }
    }

    $apps = [System.Collections.Generic.List[object]]::new()

    foreach ($row in $rows) {
        $label = '{0}-{1}' -f $connection.Prefix, $row.Label

        if (-not $PSCmdlet.ShouldProcess($label, "Create Okta app ($($row.SignOnMode))")) { continue }

        try {
            $existing = @(Invoke-OktaRequest -Method GET -Path '/api/v1/apps' `
                -Query @{ q = $label; limit = 50 } -Paginate |
                Where-Object { $_.label -eq $label })

            if ($existing.Count -gt 0) {
                # Reused rather than updated. An app's settings are bound up with its sign-on
                # mode and, for OIDC, with a client secret already issued; rewriting them on a
                # re-run would rotate credentials nobody asked to rotate. Assignments below are
                # reapplied either way, which is the part that actually matters.
                $app = $existing[0]
                $result.ExistingApps++
                Write-Verbose "Reusing existing app $label"
            }
            else {
                # The CSV URLs carry the module's default domain. Rewriting them to the
                # connection's domain is what keeps the teardown marker working under a custom
                # -EmailDomain, since the URL is the only marker every app type preserves.
                $url = $row.Url -replace [regex]::Escape($script:DefaultSeedDomain),
                    $connection.EmailDomain

                $body = [ordered]@{
                    name       = $row.Template
                    label      = $label
                    signOnMode = $row.SignOnMode
                    # Okta accepts this and then silently discards it for every sign-on mode
                    # except OPENID_CONNECT - verified against a live tenant, and a follow-up
                    # PUT does not help. It is still sent because it IS honoured for OIDC apps,
                    # but Get-OktaSeededApp cannot rely on it and falls back to the URL.
                    profile    = @{ labSeedTag = $connection.SeedTag; labCategory = $row.Category }
                }

                switch ($row.Template) {
                    'bookmark' {
                        $body.settings = @{ app = @{ url = $url; requestIntegration = $false } }
                    }
                    'template_swa' {
                        $body.settings = @{ app = @{
                            url           = $url
                            usernameField = '#txtbox-username'
                            passwordField = '#txtbox-password'
                            buttonField   = 'btn-login'
                        } }
                    }
                    'oidc_client' {
                        $body.credentials = @{
                            oauthClient = @{ token_endpoint_auth_method = 'client_secret_basic' }
                        }
                        $logoutUri = '{0}://{1}/' -f ([uri]$url).Scheme, ([uri]$url).Host
                        $body.settings = @{ oauthClient = @{
                            application_type          = 'web'
                            grant_types               = @('authorization_code')
                            response_types            = @('code')
                            redirect_uris             = @($url)
                            post_logout_redirect_uris = @($logoutUri)
                        } }
                    }
                    default { throw "Unknown app template '$($row.Template)'." }
                }

                $app = Invoke-OktaRequest -Method POST -Path '/api/v1/apps' -Body $body
                $result.CreatedApps++
                Write-Verbose "Created app $label"
            }

            $assignedGroups = @()
            $assignedUsers = @()

            if (-not $SkipAssignment) {
                foreach ($groupKey in @($row.Groups -split ';' | Where-Object { $_ })) {
                    if (-not $groupIdByKey.ContainsKey($groupKey)) {
                        $message = "App '$label' lists group '$groupKey', which does not exist. Skipped."
                        $result.Errors += $message
                        Write-Warning $message
                        continue
                    }

                    # PUT is idempotent: reassigning an already-assigned group is a no-op rather
                    # than an error, so a re-run needs no membership check.
                    $null = Invoke-OktaRequest -Method PUT `
                        -Path "/api/v1/apps/$($app.id)/groups/$($groupIdByKey[$groupKey])"
                    $assignedGroups += $groupKey
                    $result.GroupsAssigned++
                }

                # POST is NOT idempotent here, so existing assignees are read first. Without
                # this, a re-run reports a conflict for every user already assigned.
                $alreadyAssigned = @()
                if ($result.ExistingApps -gt 0 -or $existing.Count -gt 0) {
                    $alreadyAssigned = @(Invoke-OktaRequest -Method GET `
                        -Path "/api/v1/apps/$($app.id)/users" -Paginate |
                        ForEach-Object { $_.id })
                }

                foreach ($loginPrefix in @($row.DirectUsers -split ';' | Where-Object { $_ })) {
                    $login = '{0}@{1}' -f $loginPrefix, $connection.EmailDomain

                    if (-not $userIdByLogin.ContainsKey($login)) {
                        $message = "App '$label' lists user '$login', which does not exist. Skipped."
                        $result.Errors += $message
                        Write-Warning $message
                        continue
                    }

                    $userId = $userIdByLogin[$login]
                    if ($alreadyAssigned -contains $userId) {
                        Write-Verbose "$login is already assigned to $label"
                        continue
                    }

                    $null = Invoke-OktaRequest -Method POST -Path "/api/v1/apps/$($app.id)/users" `
                        -Body @{ id = $userId; scope = 'USER' }
                    $assignedUsers += $login
                    $result.UsersAssigned++
                }
            }

            $apps.Add([PSCustomObject]@{
                Id         = $app.id
                Key        = $row.Name
                Label      = $label
                SignOnMode = $row.SignOnMode
                Category   = $row.Category
                Groups     = $assignedGroups
                DirectUsers = $assignedUsers
            })
        }
        catch {
            $message = "Failed to create app '$label': $($_.Exception.Message)"
            $result.Errors += $message
            Write-Error $message
        }
    }

    $result.Apps = $apps.ToArray()

    Write-Verbose ("Apps: $($result.CreatedApps) created, $($result.ExistingApps) reused, " +
        "$($result.GroupsAssigned) group assignments, $($result.UsersAssigned) direct assignments")

    if ($PassThru) { return $result }
}