Private/Invoke-DeviceCodeAuth.ps1

function Invoke-DeviceCodeAuth {
    <#
    .SYNOPSIS
        Performs device code flow authentication against Microsoft identity platform.
 
    .DESCRIPTION
        1. Requests a device code from the /devicecode endpoint.
        2. Displays the user code and verification URI to the user.
        3. Polls the /token endpoint at the specified interval until the user completes
           sign-in or the code expires.
        4. Handles authorization_pending, slow_down, and error responses.
 
    .PARAMETER TenantId
        Azure AD / Entra ID tenant ID (GUID or domain).
 
    .PARAMETER ClientId
        Application (client) ID.
 
    .PARAMETER Scopes
        Space-separated scopes to request.
 
    .NOTES
        Author: Nickolaj Andersen & Jan Ketil Skanke
        Contact: @NickolajA @JankeSkanke
        Created: 2026-02-19
 
        Version history:
        1.0.0 - (2026-02-19) Script created
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$ClientId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$Scopes
    )
    Process {
        $deviceCodeEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode"
        $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

        # 1. Request device code
        $deviceCodeBody = @{
            client_id = $ClientId
            scope     = $Scopes
        }

        Write-Verbose -Message "Requesting device code from: $deviceCodeEndpoint"
        try {
            $deviceCodeResponse = Invoke-RestMethod -Uri $deviceCodeEndpoint -Method Post -Body $deviceCodeBody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
        }
        catch [System.Exception] {
            throw "Failed to request device code: $($PSItem.Exception.Message)"
        }

        # 2. Display the user code and verification URI
        Write-Host ""
        Write-Host "[MSGraphRequest] To sign in, use a web browser to open the page $($deviceCodeResponse.verification_uri) and enter the code: " -ForegroundColor Yellow -NoNewline
        Write-Host "$($deviceCodeResponse.user_code)" -ForegroundColor Cyan
        Write-Host ""

        $interval = if ($deviceCodeResponse.interval) { [int]$deviceCodeResponse.interval } else { 5 }
        $expiresIn = if ($deviceCodeResponse.expires_in) { [int]$deviceCodeResponse.expires_in } else { 900 }
        $deadline = (Get-Date).AddSeconds($expiresIn)

        # 3. Poll the token endpoint
        $tokenBody = @{
            client_id   = $ClientId
            grant_type  = 'urn:ietf:params:oauth:grant-type:device_code'
            device_code = $deviceCodeResponse.device_code
        }

        while ((Get-Date) -lt $deadline) {
            Start-Sleep -Seconds $interval

            try {
                $response = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $tokenBody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop
                # Success — user has authenticated
                Write-Verbose -Message "Device code authentication completed successfully."
                return $response
            }
            catch [System.Exception] {
                # Parse the error to determine if we should keep polling
                $errorBody = $null
                try {
                    if ($PSVersionTable.PSVersion.Major -ge 6) {
                        if ($PSItem.ErrorDetails.Message) {
                            $errorBody = $PSItem.ErrorDetails.Message | ConvertFrom-Json
                        }
                    }
                    else {
                        if ($PSItem.Exception.Response) {
                            $streamReader = [System.IO.StreamReader]::new($PSItem.Exception.Response.GetResponseStream())
                            $streamReader.BaseStream.Position = 0
                            $streamReader.DiscardBufferedData()
                            $errorBody = $streamReader.ReadToEnd() | ConvertFrom-Json
                        }
                    }
                }
                catch {
                    # Cannot parse — treat as fatal
                }

                if ($errorBody) {
                    switch ($errorBody.error) {
                        "authorization_pending" {
                            # User hasn't authenticated yet — keep polling
                            Write-Verbose -Message "Waiting for user authentication..."
                            continue
                        }
                        "slow_down" {
                            # Server asked us to slow down — increase interval
                            $interval += 5
                            Write-Verbose -Message "Slowing down polling interval to $interval seconds."
                            continue
                        }
                        "expired_token" {
                            throw "Device code has expired. Please run the command again to get a new code."
                        }
                        "access_denied" {
                            throw "Authentication was denied by the user or administrator."
                        }
                        default {
                            throw "Device code authentication failed: $($errorBody.error) - $($errorBody.error_description)"
                        }
                    }
                }
                else {
                    throw "Device code authentication failed: $($PSItem.Exception.Message)"
                }
            }
        }

        throw "Device code authentication timed out — the code expired before the user completed sign-in."
    }
}