Public/New-NMMApiCertificate.ps1

function New-NMMApiCertificate {
    <#
    .SYNOPSIS
        Creates a self-signed certificate for NMM API authentication.
    .DESCRIPTION
        Generates a self-signed certificate for certificate-based authentication with Azure AD.
        Supports multiple export destinations and optional upload to Azure AD app registration.
 
        Export Options:
        - PFX file (-ExportToPfx)
        - PEM file (-ExportToPem)
        - Windows Certificate Store / macOS Keychain (-ExportToCertStore)
        - Azure Key Vault (-ExportToKeyVault)
 
        Azure AD Upload:
        - Use -Upload to automatically add the certificate to your app registration
        - Choose upload authentication method with -UploadMethod (DeviceCode, Interactive, Secret)
    .PARAMETER CertificateName
        Display name for the certificate (default: "NMM-API-Certificate").
    .PARAMETER ValidityMonths
        Certificate validity period in months (default: 12).
    .PARAMETER ExportToPfx
        Path to export PFX file.
    .PARAMETER PfxPassword
        Password for PFX file (SecureString). If not provided, a random password is generated.
    .PARAMETER ExportToPem
        Path to export PEM file (public certificate only).
    .PARAMETER ExportToCertStore
        Export to Windows Certificate Store or macOS Keychain.
    .PARAMETER StoreLocation
        Windows cert store location: CurrentUser or LocalMachine (default: CurrentUser).
    .PARAMETER ExportToKeyVault
        Export to Azure Key Vault.
    .PARAMETER VaultName
        Key Vault name (required with -ExportToKeyVault).
    .PARAMETER Upload
        Upload public certificate to Azure AD app registration.
    .PARAMETER UploadMethod
        Authentication method for upload: DeviceCode, Interactive, or Secret.
    .PARAMETER ApplicationId
        Target Azure AD application ID (default: from ConfigData.json).
    .PARAMETER TenantId
        Azure AD tenant ID (default: from ConfigData.json).
    .PARAMETER UpdateConfig
        Update ConfigData.json with certificate details.
    .OUTPUTS
        PSCustomObject with certificate details.
    .EXAMPLE
        New-NMMApiCertificate -ExportToCertStore -UpdateConfig
        # Creates cert, stores in cert store, updates config
    .EXAMPLE
        New-NMMApiCertificate -ExportToPfx "./nmm-cert.pfx" -Upload -UploadMethod DeviceCode
        # Creates cert, exports to PFX, uploads to Azure AD
    .EXAMPLE
        New-NMMApiCertificate -ExportToKeyVault -VaultName "my-keyvault" -Upload -UploadMethod Secret
        # Creates cert, stores in Key Vault, uploads using existing client secret
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string]$CertificateName = 'NMM-API-Certificate',

        [Parameter()]
        [int]$ValidityMonths = 12,

        [Parameter()]
        [string]$ExportToPfx,

        [Parameter()]
        [SecureString]$PfxPassword,

        [Parameter()]
        [string]$ExportToPem,

        [Parameter()]
        [switch]$ExportToCertStore,

        [Parameter()]
        [ValidateSet('CurrentUser', 'LocalMachine')]
        [string]$StoreLocation = 'CurrentUser',

        [Parameter()]
        [switch]$ExportToKeyVault,

        [Parameter()]
        [string]$VaultName,

        [Parameter()]
        [switch]$Upload,

        [Parameter()]
        [ValidateSet('DeviceCode', 'Interactive', 'Secret')]
        [string]$UploadMethod = 'DeviceCode',

        [Parameter()]
        [string]$ApplicationId,

        [Parameter()]
        [string]$TenantId,

        [Parameter()]
        [switch]$UpdateConfig
    )

    begin {
        # Load config for defaults
        $config = Get-ConfigData

        if (-not $ApplicationId -and $config) {
            $ApplicationId = $config.ClientId
        }
        if (-not $TenantId -and $config) {
            $TenantId = $config.TenantId
        }

        # Validate requirements
        if ($ExportToKeyVault -and -not $VaultName) {
            throw "-VaultName is required when using -ExportToKeyVault"
        }

        if ($Upload -and -not $ApplicationId) {
            throw "-ApplicationId is required for upload (or configure ClientId in ConfigData.json)"
        }

        if ($Upload -and -not $TenantId) {
            throw "-TenantId is required for upload (or configure TenantId in ConfigData.json)"
        }

        # Track export destinations
        $exportedTo = @()
    }

    process {
        Write-Host "Creating self-signed certificate: $CertificateName" -ForegroundColor Cyan

        # Calculate validity dates
        $notBefore = [DateTime]::UtcNow
        $notAfter = $notBefore.AddMonths($ValidityMonths)

        # Create certificate
        $cert = New-SelfSignedCertificateInternal -CertificateName $CertificateName -NotBefore $notBefore -NotAfter $notAfter

        Write-Host "Certificate created successfully" -ForegroundColor Green
        Write-Host " Subject: $($cert.Subject)" -ForegroundColor Gray
        Write-Host " Thumbprint: $($cert.Thumbprint)" -ForegroundColor Gray
        Write-Host " Valid until: $($cert.NotAfter)" -ForegroundColor Gray

        # Export to PFX
        if ($ExportToPfx) {
            Write-Host "Exporting to PFX: $ExportToPfx" -ForegroundColor Cyan

            if (-not $PfxPassword) {
                # Generate random password (cross-platform)
                $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*'
                $randomPassword = -join ((1..16) | ForEach-Object { $chars[(Get-Random -Maximum $chars.Length)] })
                $PfxPassword = ConvertTo-SecureString -String $randomPassword -AsPlainText -Force
                Write-Warning "Generated PFX password: $randomPassword (save this securely!)"
            }

            $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $PfxPassword)
            [System.IO.File]::WriteAllBytes($ExportToPfx, $pfxBytes)
            $exportedTo += "PFX:$ExportToPfx"
            Write-Host " Exported to PFX" -ForegroundColor Green
        }

        # Export to PEM
        if ($ExportToPem) {
            Write-Host "Exporting public certificate to PEM: $ExportToPem" -ForegroundColor Cyan

            $pemContent = "-----BEGIN CERTIFICATE-----`n"
            $pemContent += [System.Convert]::ToBase64String($cert.RawData, [System.Base64FormattingOptions]::InsertLineBreaks)
            $pemContent += "`n-----END CERTIFICATE-----"

            [System.IO.File]::WriteAllText($ExportToPem, $pemContent)
            $exportedTo += "PEM:$ExportToPem"
            Write-Host " Exported public certificate to PEM" -ForegroundColor Green
        }

        # Export to Certificate Store / Keychain
        if ($ExportToCertStore) {
            if ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') {
                Write-Host "Importing to Windows Certificate Store ($StoreLocation\My)" -ForegroundColor Cyan

                $store = [System.Security.Cryptography.X509Certificates.X509Store]::new(
                    [System.Security.Cryptography.X509Certificates.StoreName]::My,
                    [System.Security.Cryptography.X509Certificates.StoreLocation]::$StoreLocation
                )
                $store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite)
                $store.Add($cert)
                $store.Close()

                $exportedTo += "CertStore:$StoreLocation\My"
                Write-Host " Imported to Certificate Store" -ForegroundColor Green
            }
            elseif ($IsMacOS) {
                Write-Host "Importing to macOS Keychain" -ForegroundColor Cyan

                # Export to temp PFX first
                $tempPfx = [System.IO.Path]::GetTempFileName() + ".pfx"
                $tempPassword = [guid]::NewGuid().ToString()
                $tempSecurePassword = ConvertTo-SecureString -String $tempPassword -AsPlainText -Force

                try {
                    $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $tempSecurePassword)
                    [System.IO.File]::WriteAllBytes($tempPfx, $pfxBytes)

                    # Import to keychain
                    & security import $tempPfx -k login.keychain-db -P $tempPassword -T /usr/bin/codesign 2>&1 | Out-Null

                    $exportedTo += "Keychain:login.keychain-db"
                    Write-Host " Imported to Keychain" -ForegroundColor Green
                }
                finally {
                    if (Test-Path $tempPfx) {
                        Remove-Item $tempPfx -Force
                    }
                }
            }
            else {
                Write-Warning "Certificate Store export not supported on Linux. Use -ExportToPfx instead."
            }
        }

        # Export to Key Vault
        if ($ExportToKeyVault) {
            Write-Host "Importing to Azure Key Vault: $VaultName" -ForegroundColor Cyan

            # Check for Az.KeyVault module
            if (-not (Get-Module -ListAvailable -Name Az.KeyVault)) {
                throw "Az.KeyVault module is required. Install with: Install-Module Az.KeyVault"
            }

            Import-Module Az.KeyVault -ErrorAction Stop

            # Export cert to temp PFX for import
            $tempPfx = [System.IO.Path]::GetTempFileName() + ".pfx"
            $tempPassword = [guid]::NewGuid().ToString()
            $tempSecurePassword = ConvertTo-SecureString -String $tempPassword -AsPlainText -Force

            try {
                $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $tempSecurePassword)
                [System.IO.File]::WriteAllBytes($tempPfx, $pfxBytes)

                # Import to Key Vault
                Import-AzKeyVaultCertificate -VaultName $VaultName -Name $CertificateName -FilePath $tempPfx -Password $tempSecurePassword -ErrorAction Stop | Out-Null

                $exportedTo += "KeyVault:$VaultName/$CertificateName"
                Write-Host " Imported to Key Vault" -ForegroundColor Green
            }
            finally {
                if (Test-Path $tempPfx) {
                    Remove-Item $tempPfx -Force
                }
            }
        }

        # Upload to Azure AD
        $uploadedToAzureAD = $false
        if ($Upload) {
            Write-Host "Uploading certificate to Azure AD app registration" -ForegroundColor Cyan
            $uploadedToAzureAD = Publish-CertificateToAzureAD -Certificate $cert -ApplicationId $ApplicationId -TenantId $TenantId -UploadMethod $UploadMethod -Config $config
        }

        # Update ConfigData.json
        if ($UpdateConfig) {
            Write-Host "Updating ConfigData.json" -ForegroundColor Cyan
            Update-ConfigDataWithCertificate -Certificate $cert -ExportedTo $exportedTo -VaultName $VaultName -StoreLocation $StoreLocation
        }

        # Return result
        $result = [PSCustomObject]@{
            Thumbprint        = $cert.Thumbprint
            Subject           = $cert.Subject
            NotBefore         = $cert.NotBefore
            NotAfter          = $cert.NotAfter
            ExportedTo        = $exportedTo
            UploadedToAzureAD = $uploadedToAzureAD
        }

        Write-Host "`nCertificate setup complete!" -ForegroundColor Green

        return $result
    }
}

function New-SelfSignedCertificateInternal {
    <#
    .SYNOPSIS
        Creates a self-signed certificate cross-platform.
    #>

    [CmdletBinding()]
    param(
        [string]$CertificateName,
        [DateTime]$NotBefore,
        [DateTime]$NotAfter
    )

    if ($IsWindows -or $PSVersionTable.PSEdition -eq 'Desktop') {
        # Use Windows cmdlet
        $cert = New-SelfSignedCertificate `
            -Subject "CN=$CertificateName" `
            -CertStoreLocation "Cert:\CurrentUser\My" `
            -KeyExportPolicy Exportable `
            -KeySpec Signature `
            -KeyLength 2048 `
            -KeyAlgorithm RSA `
            -HashAlgorithm SHA256 `
            -NotBefore $NotBefore `
            -NotAfter $NotAfter

        return $cert
    }
    else {
        # Cross-platform using .NET cryptography
        $rsa = [System.Security.Cryptography.RSA]::Create(2048)

        $subjectName = [System.Security.Cryptography.X509Certificates.X500DistinguishedName]::new("CN=$CertificateName")

        $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new(
            $subjectName,
            $rsa,
            [System.Security.Cryptography.HashAlgorithmName]::SHA256,
            [System.Security.Cryptography.RSASignaturePadding]::Pkcs1
        )

        # Add key usage extension
        $request.CertificateExtensions.Add(
            [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new(
                [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature,
                $true
            )
        )

        # Create self-signed certificate
        $cert = $request.CreateSelfSigned($NotBefore, $NotAfter)

        return $cert
    }
}

function Publish-CertificateToAzureAD {
    <#
    .SYNOPSIS
        Publishes certificate public key to Azure AD app registration via Graph API.
    #>

    [CmdletBinding()]
    param(
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [string]$ApplicationId,
        [string]$TenantId,
        [string]$UploadMethod,
        [PSCustomObject]$Config
    )

    try {
        # Get access token for Graph API
        $graphToken = $null

        switch ($UploadMethod) {
            'DeviceCode' {
                Write-Host " Starting device code authentication..." -ForegroundColor Yellow
                $graphToken = Get-GraphTokenDeviceCode -TenantId $TenantId
            }
            'Interactive' {
                Write-Host " Starting interactive authentication..." -ForegroundColor Yellow
                $graphToken = Get-GraphTokenInteractive -TenantId $TenantId
            }
            'Secret' {
                if (-not $Config -or -not $Config.ClientSecret) {
                    throw "Client secret required for Secret upload method. Configure ClientSecret in ConfigData.json"
                }
                Write-Host " Using client secret authentication..." -ForegroundColor Yellow
                $graphToken = Get-GraphTokenClientCredentials -TenantId $TenantId -ClientId $ApplicationId -ClientSecret $Config.ClientSecret
            }
        }

        if (-not $graphToken) {
            throw "Failed to obtain Graph API access token"
        }

        # First, get the application's object ID (different from client ID)
        Write-Host " Looking up application in Azure AD..." -ForegroundColor Gray
        $appResponse = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=appId eq '$ApplicationId'" `
            -Headers @{ Authorization = "Bearer $graphToken" } `
            -Method GET

        if (-not $appResponse.value -or $appResponse.value.Count -eq 0) {
            throw "Application with ID '$ApplicationId' not found in Azure AD"
        }

        $appObjectId = $appResponse.value[0].id
        $existingKeyCredentials = $appResponse.value[0].keyCredentials

        # Prepare key credential
        $keyCredential = @{
            type             = 'AsymmetricX509Cert'
            usage            = 'Verify'
            key              = [System.Convert]::ToBase64String($Certificate.RawData)
            displayName      = $Certificate.Subject
            startDateTime    = $Certificate.NotBefore.ToUniversalTime().ToString('o')
            endDateTime      = $Certificate.NotAfter.ToUniversalTime().ToString('o')
        }

        # Combine with existing credentials
        $allKeyCredentials = @($existingKeyCredentials) + @($keyCredential)

        # Update application
        Write-Host " Uploading certificate to app registration..." -ForegroundColor Gray
        $body = @{
            keyCredentials = $allKeyCredentials
        } | ConvertTo-Json -Depth 10

        Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/applications/$appObjectId" `
            -Headers @{
            Authorization  = "Bearer $graphToken"
            'Content-Type' = 'application/json'
        } `
            -Method PATCH `
            -Body $body

        Write-Host " Certificate uploaded to Azure AD successfully" -ForegroundColor Green
        return $true
    }
    catch {
        Write-Error "Failed to upload certificate to Azure AD: $_"
        return $false
    }
}

function Get-GraphTokenDeviceCode {
    <#
    .SYNOPSIS
        Gets Graph API token using device code flow.
    #>

    [CmdletBinding()]
    param([string]$TenantId)

    $clientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'  # Microsoft Graph PowerShell
    $scope = 'https://graph.microsoft.com/.default offline_access'

    # Request device code
    $deviceCodeResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode" `
        -Method POST `
        -Body @{
        client_id = $clientId
        scope     = $scope
    }

    Write-Host "`n$($deviceCodeResponse.message)" -ForegroundColor Yellow

    # Poll for token
    $interval = $deviceCodeResponse.interval
    $expiresIn = $deviceCodeResponse.expires_in
    $startTime = Get-Date

    while ((Get-Date) -lt $startTime.AddSeconds($expiresIn)) {
        Start-Sleep -Seconds $interval

        try {
            $tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
                -Method POST `
                -Body @{
                client_id   = $clientId
                grant_type  = 'urn:ietf:params:oauth:grant-type:device_code'
                device_code = $deviceCodeResponse.device_code
            }

            return $tokenResponse.access_token
        }
        catch {
            $errorMessage = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
            if ($errorMessage.error -eq 'authorization_pending') {
                continue
            }
            throw
        }
    }

    throw "Device code authentication timed out"
}

function Get-GraphTokenInteractive {
    <#
    .SYNOPSIS
        Gets Graph API token using interactive browser flow.
    #>

    [CmdletBinding()]
    param([string]$TenantId)

    # Check if MSAL.PS module is available
    if (-not (Get-Module -ListAvailable -Name MSAL.PS)) {
        throw "MSAL.PS module is required for interactive authentication. Install with: Install-Module MSAL.PS"
    }

    Import-Module MSAL.PS -ErrorAction Stop

    $clientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'  # Microsoft Graph PowerShell

    $token = Get-MsalToken -ClientId $clientId `
        -TenantId $TenantId `
        -Scopes 'https://graph.microsoft.com/.default' `
        -Interactive

    return $token.AccessToken
}

function Get-GraphTokenClientCredentials {
    <#
    .SYNOPSIS
        Gets Graph API token using client credentials.
    #>

    [CmdletBinding()]
    param(
        [string]$TenantId,
        [string]$ClientId,
        [string]$ClientSecret
    )

    $tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
        -Method POST `
        -Body @{
        client_id     = $ClientId
        client_secret = $ClientSecret
        grant_type    = 'client_credentials'
        scope         = 'https://graph.microsoft.com/.default'
    }

    return $tokenResponse.access_token
}

function Update-ConfigDataWithCertificate {
    <#
    .SYNOPSIS
        Updates ConfigData.json with certificate configuration.
    #>

    [CmdletBinding()]
    param(
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,
        [string[]]$ExportedTo,
        [string]$VaultName,
        [string]$StoreLocation
    )

    # Determine config file path
    $configPath = if ($env:NMM_DEV_MODE -eq 'true') {
        Join-Path $PSScriptRoot "..\Private\Data\ConfigData-Local.json"
    }
    else {
        Join-Path $PSScriptRoot "..\Private\Data\ConfigData.json"
    }

    if (-not (Test-Path $configPath)) {
        Write-Warning "ConfigData.json not found at $configPath. Cannot update."
        return
    }

    $config = Get-Content $configPath -Raw | ConvertFrom-Json

    # Determine certificate source based on export
    $certConfig = @{
        Thumbprint = $Certificate.Thumbprint
    }

    if ($ExportedTo -match 'KeyVault:') {
        $certConfig.Source = 'KeyVault'
        $certConfig.VaultName = $VaultName
        $certConfig.CertificateName = $Certificate.Subject -replace '^CN=', ''
    }
    elseif ($ExportedTo -match 'CertStore:') {
        $certConfig.Source = 'CertStore'
        $certConfig.StoreLocation = $StoreLocation
        $certConfig.StoreName = 'My'
    }
    elseif ($ExportedTo -match 'Keychain:') {
        $certConfig.Source = 'Keychain'
        $certConfig.KeychainPath = 'login.keychain-db'
    }
    elseif ($ExportedTo -match 'PFX:') {
        $pfxPath = ($ExportedTo -match 'PFX:(.+)')[0] -replace '^PFX:', ''
        $certConfig.Source = 'PfxFile'
        $certConfig.Path = $pfxPath
    }

    # Update config object
    $config | Add-Member -NotePropertyName 'AuthMethod' -NotePropertyValue 'Certificate' -Force
    $config | Add-Member -NotePropertyName 'Certificate' -NotePropertyValue ([PSCustomObject]$certConfig) -Force

    # Save config
    $config | ConvertTo-Json -Depth 10 | Set-Content $configPath -Encoding UTF8

    Write-Host " ConfigData.json updated with certificate configuration" -ForegroundColor Green
}