Public/Connect-UnifiController.ps1

function Connect-UnifiController {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Controller,

        [int]$Port = 443,

        # If omitted, a credential prompt will appear
        [PSCredential]$Credential,

        # TOTP code for accounts with 2FA enabled.
        # If not supplied and the controller requires 2FA, you will be prompted interactively.
        [string]$Token,

        # Disable TLS certificate validation — required for controllers using self-signed certs
        [switch]$SkipCertificateCheck,

        # Persist controller URL, username, and TLS setting to disk (password is never saved)
        [switch]$Save
    )

    # Normalize to a full URL
    $baseUrl = if ($Controller -match '^https?://') {
        $Controller.TrimEnd('/')
    } else {
        "https://${Controller}:${Port}"
    }

    if (-not $Credential) {
        $Credential = Get-Credential -Message "Unifi credentials for $baseUrl"
        if (-not $Credential) {
            Write-Warning "No credentials provided."
            return
        }
    }

    # -------------------------------------------------------------------------
    # Step 1 — credentials only. TOTP never goes here; it belongs in step 2.
    # -------------------------------------------------------------------------
    $loginBody = @{
        username = $Credential.UserName
        password = $Credential.GetNetworkCredential().Password
        remember = $true
    } | ConvertTo-Json -Compress

    Write-Verbose "Step 1: POST $baseUrl/api/login (user: $($Credential.UserName))"

    $step1Params = @{
        Method          = 'POST'
        Uri             = "$baseUrl/api/login"
        Body            = $loginBody
        ContentType     = 'application/json'
        SessionVariable = 'webSession'
        ErrorAction     = 'Stop'
    }
    if ($SkipCertificateCheck) { $step1Params.SkipCertificateCheck = $true }

    $response = $null
    try {
        $response = Invoke-WebRequest @step1Params
    }
    catch {
        $errBody  = $null
        $serverMsg = $null
        if ($_.ErrorDetails.Message) {
            try {
                $errBody   = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop
                $serverMsg = $errBody.meta.msg
            } catch {}
        }

        Write-Verbose "Step 1 error: $serverMsg"
        Write-Verbose "Raw response body: $($_.ErrorDetails.Message)"

        if ($serverMsg -eq 'api.err.Ubic2faTokenRequired') {

            # ------------------------------------------------------------------
            # Step 2 — re-submit credentials + TOTP. The mfa_cookie JWT from Step 1
            # is a server-side signal only and is not sent back.
            # ------------------------------------------------------------------
            $defaultMfaId   = $errBody.data[0].default_mfa
            $authenticators = $errBody.data[0].authenticators

            Write-Verbose "Step 2: 2FA challenge received (default method: $defaultMfaId)"

            $totpToken = if ($Token) {
                Write-Verbose "Using token supplied via -Token parameter"
                $Token
            } else {
                Write-Host "2FA required. Registered authenticators:" -ForegroundColor Yellow
                foreach ($auth in $authenticators) {
                    $marker = if ($auth.id -eq $defaultMfaId) { ' <-- default' } else { '' }
                    Write-Host " [$($auth.type.ToUpper())] $($auth.name)$marker"
                }
                Read-Host "Enter your 2FA code"
            }

            # Re-send full credentials with the TOTP code appended.
            # mfa_cookie is NOT sent back — it was a server signal only.
            # strict:true is required by the Ubiquiti SSO flow.
            $mfaBody = @{
                username       = $Credential.UserName
                password       = $Credential.GetNetworkCredential().Password
                remember       = $true
                strict         = $true
                ubic_2fa_token = $totpToken
            } | ConvertTo-Json -Compress

            Write-Verbose "Step 2: POST $baseUrl/api/login (fields: username, password, remember, strict, ubic_2fa_token)"

            $step2Params = @{
                Method          = 'POST'
                Uri             = "$baseUrl/api/login"
                Body            = $mfaBody
                ContentType     = 'application/json'
                SessionVariable = 'webSession'
                ErrorAction     = 'Stop'
            }
            if ($SkipCertificateCheck) { $step2Params.SkipCertificateCheck = $true }

            try {
                $response = Invoke-WebRequest @step2Params
            }
            catch {
                $mfaMsg = $null
                if ($_.ErrorDetails.Message) {
                    try {
                        $mfaMsg = ($_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction Stop).meta.msg
                    } catch {}
                }
                Write-Error "2FA verification failed$(if ($mfaMsg) { " ($mfaMsg)" }). The code may have expired — try again immediately after reading it from your authenticator."
                Write-Verbose "Raw step 2 exception: $_"
                return
            }

        } else {
            # Not a 2FA challenge — report the error cleanly
            if ($serverMsg) {
                switch -Exact ($serverMsg) {
                    'api.err.Invalid' {
                        Write-Error "Login failed: invalid credentials for $baseUrl.`nIf using a Ubiquiti cloud account, the username is your SSO email address."
                        break
                    }
                    default {
                        Write-Error "Login failed ($serverMsg) on $baseUrl."
                    }
                }
            } else {
                Write-Error "Connection to $baseUrl failed. Run with -Verbose for details."
            }
            return
        }
    }

    # -------------------------------------------------------------------------
    # Both step 1 (no 2FA) and step 2 (2FA complete) land here
    # -------------------------------------------------------------------------
    $parsed = $response.Content | ConvertFrom-Json
    if ($parsed.meta.rc -ne 'ok') {
        Write-Error "Login rejected: $($parsed.meta.msg)"
        return
    }

    # Dump all response headers and cookies under -Verbose to aid CSRF debugging
    Write-Verbose "Login response headers:"
    foreach ($h in $response.Headers.GetEnumerator()) {
        Write-Verbose " $($h.Key): $($h.Value -join ', ')"
    }
    Write-Verbose "Session cookies for $baseUrl :"
    foreach ($c in $webSession.Cookies.GetCookies([uri]$baseUrl)) {
        $preview = if ($c.Value.Length -gt 60) { $c.Value.Substring(0,60) + '...' } else { $c.Value }
        Write-Verbose " $($c.Name) = $preview"
    }

    # UniFi requires X-Csrf-Token header on all write operations (PUT/POST/DELETE).
    # The token is returned at login — check the response header first, then cookies.
    $csrfToken = $null
    $csrfHeader = $response.Headers['X-Csrf-Token']
    if ($csrfHeader) {
        $csrfToken = @($csrfHeader)[0]
        Write-Verbose "CSRF token found in response header."
    }
    if (-not $csrfToken) {
        $csrfCookie = $webSession.Cookies.GetCookies([uri]$baseUrl) |
                      Where-Object Name -eq 'csrf_token' |
                      Select-Object -First 1
        if ($csrfCookie) {
            $csrfToken = $csrfCookie.Value
            Write-Verbose "CSRF token found in 'csrf_token' cookie."
        }
    }
    if (-not $csrfToken) {
        Write-Verbose "WARNING: No CSRF token found — write operations may return 401."
    }

    $script:UnifiSession = $webSession
    $script:UnifiConfig  = @{
        ControllerUrl        = $baseUrl
        Username             = $Credential.UserName
        DefaultSite          = 'default'
        SkipCertificateCheck = $SkipCertificateCheck.IsPresent
        ConnectedAt          = (Get-Date -Format 'o')
        CsrfToken            = $csrfToken
    }

    if ($Save) {
        Write-UnifiConfig -Config $script:UnifiConfig
        Write-Verbose "Config saved to $(Get-UnifiConfigPath)"
    }

    Write-Host "Connected to $baseUrl as $($Credential.UserName)" -ForegroundColor Green
}