Providers/Entra/Private/New-EntraDeviceCodeToken.ps1

function New-EntraDeviceCodeToken {
    <#
    .SYNOPSIS
        Signs a human in by device code and returns a delegated access token

    .DESCRIPTION
        This is the bootstrap credential, and it exists because Entra has no equivalent of
        Okta's SSWS token. There is nothing to paste: no long-lived personal API key a human
        can generate and hand to a script. The nearest thing is to sign the human in and act as
        them for exactly as long as it takes to create an application that can act on its own.

        Device code flow is used rather than an interactive browser redirect because it needs
        no listener, no reply URL and no registered application of its own. It prints a code,
        the human types it into a browser anywhere, and this polls until they finish. That
        works identically over SSH, in a container, and on a machine with no browser at all.

        The client is Microsoft Graph PowerShell's first-party application, which is
        pre-consented in every tenant. That is what makes the bootstrap possible without a
        chicken-and-egg problem: registering an application to register an application.

        The token this returns is DELEGATED - it carries the signed-in human's authority, not
        an application's. Everything it can do, they could do in the portal. It is deliberately
        short-lived and never written to disk: the certificate created during bootstrap is the
        durable credential, and this one exists only to create it.

    .PARAMETER TenantId
        Directory (tenant) ID, or a verified domain name

    .PARAMETER ClientId
        Public client to authenticate through. Defaults to Microsoft Graph PowerShell.

    .PARAMETER GraphBaseUri
        Graph endpoint the token is requested for

    .PARAMETER Scope
        The scopes to request. A bare name such as User.ReadWrite.All is taken as a Graph scope
        and prefixed with the endpoint; openid, profile and offline_access are sent as they are;
        '.default' asks for whatever the client has already been consented for. offline_access
        is always included, because without it there is no refresh token. Defaults to .default.

    .PARAMETER TimeoutSeconds
        How long to wait for the human before giving up

    .OUTPUTS
        System.Collections.Hashtable with AccessToken, RefreshToken and ExpiresOn.

    .EXAMPLE
        PS> New-EntraDeviceCodeToken -TenantId $tenant

        DESCRIPTION: Prints a code, waits for the human, returns their token
        OUTPUT: A hashtable carrying the access and refresh tokens
        USE CASE: Called by Connect-EntraEnvironment -Interactive

    .NOTES
        Author: Jeffrey Stuhr
        Blog: https://www.techbyjeff.net
        LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'The sign-in code is an instruction to a human standing at the console, not data. It must not be capturable into a variable, silenced by a caller who redirected the information stream, or lost from a transcript that shows only output - which is exactly what Write-Information, Write-Output and Write-Verbose would each do to it. This and the handover banner are the only Write-Host calls in the module.')]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Changes no state in the tenant. It obtains a token for the human already signing in, and the sign-in itself is the confirmation.')]
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$ClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$GraphBaseUri = 'https://graph.microsoft.com',

        [Parameter()]
        [ValidateRange(60, 1800)]
        [int]$TimeoutSeconds = 900,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]$Scope = @('.default')
    )

    if ($PSVersionTable.PSEdition -eq 'Desktop') {
        $tls12 = [System.Net.SecurityProtocolType]::Tls12
        if (([System.Net.ServicePointManager]::SecurityProtocol -band $tls12) -ne $tls12) {
            [System.Net.ServicePointManager]::SecurityProtocol =
                [System.Net.ServicePointManager]::SecurityProtocol -bor $tls12
        }
    }

    $previousProgress = $ProgressPreference
    $ProgressPreference = 'SilentlyContinue'

    try {
        # offline_access is what earns a refresh token. Without it the bootstrap dies after an
        # hour, which is long enough to be intermittent rather than obviously broken.
        # Every Graph scope carries the resource as a prefix; the OpenID scopes do not. Asking
        # for named scopes rather than .default is what makes a first-party public client show
        # a consent screen for exactly the rights the module needs, so an interactive session
        # can do everything the service app can rather than only what the tenant happened to
        # have consented for it already.
        $openId = @('openid', 'profile', 'offline_access', 'email')
        $requested = foreach ($item in $Scope) {
            if ($openId -contains $item) { $item }
            elseif ($item -eq '.default' -or $item -like 'https://*') { if ($item -eq '.default') { "$GraphBaseUri/.default" } else { $item } }
            else { "$GraphBaseUri/$item" }
        }
        if ($requested -notcontains 'offline_access') { $requested = @($requested) + 'offline_access' }
        $scope = ($requested -join ' ')
        $body = 'client_id={0}&scope={1}' -f [uri]::EscapeDataString($ClientId), [uri]::EscapeDataString($scope)

        try {
            $response = Invoke-WebRequest -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode" `
                -Method POST -Body ([System.Text.Encoding]::UTF8.GetBytes($body)) `
                -ContentType 'application/x-www-form-urlencoded' -UseBasicParsing -ErrorAction Stop
            $device = ([System.Text.Encoding]::UTF8.GetString($response.RawContentStream.ToArray())) | ConvertFrom-Json
        }
        catch {
            throw (New-Object System.Exception(
                "Could not start device code sign-in: $(Get-EntraErrorDetail -ErrorRecord $_)", $_.Exception))
        }

        # Write-Host is the right call here and the only place in the module that uses it.
        # This is an instruction to a human standing at the console, not data: it must not be
        # capturable into a variable, redirected into a transcript as output, or silenced by a
        # caller who suppressed the information stream.
        Write-Host ''
        Write-Host ' Sign in to authorise the bootstrap:' -ForegroundColor Cyan
        Write-Host " 1. Open $($device.verification_uri)"
        Write-Host " 2. Enter the code: $($device.user_code)" -ForegroundColor Yellow
        Write-Host ' 3. Sign in as a Global Administrator of this tenant'
        Write-Host ''
        Write-Host ' Waiting...' -ForegroundColor DarkGray

        $deadline = [DateTimeOffset]::UtcNow.AddSeconds([Math]::Min($TimeoutSeconds, [int]$device.expires_in))
        $interval = [Math]::Max(5, [int]$device.interval)

        $pollBody = 'grant_type={0}&client_id={1}&device_code={2}' -f
            [uri]::EscapeDataString('urn:ietf:params:oauth:grant-type:device_code'),
            [uri]::EscapeDataString($ClientId),
            [uri]::EscapeDataString($device.device_code)

        while ([DateTimeOffset]::UtcNow -lt $deadline) {
            Start-Sleep -Seconds $interval

            try {
                $tokenResponse = Invoke-WebRequest -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
                    -Method POST -Body ([System.Text.Encoding]::UTF8.GetBytes($pollBody)) `
                    -ContentType 'application/x-www-form-urlencoded' -UseBasicParsing -ErrorAction Stop

                $token = ([System.Text.Encoding]::UTF8.GetString($tokenResponse.RawContentStream.ToArray())) | ConvertFrom-Json

                Write-Host ' Signed in.' -ForegroundColor Green
                return @{
                    AccessToken  = $token.access_token
                    RefreshToken = $token.refresh_token
                    ExpiresOn    = [DateTimeOffset]::UtcNow.AddSeconds([int]$token.expires_in)
                    ClientId     = $ClientId
                }
            }
            catch {
                # authorization_pending is the normal state for as long as the human is still
                # typing, so it is not an error. Everything else is.
                $detail = Get-EntraErrorDetail -ErrorRecord $_
                if ($detail -match 'authorization_pending|AADSTS70016') { continue }
                if ($detail -match 'slow_down') { $interval += 5; continue }
                if ($detail -match 'authorization_declined') {
                    Write-Error 'Sign-in was declined.' -ErrorAction Stop
                    return
                }
                if ($detail -match 'expired_token|code_expired') {
                    Write-Error 'The device code expired before sign-in completed.' -ErrorAction Stop
                    return
                }
                throw (New-Object System.Exception("Device code sign-in failed: $detail", $_.Exception))
            }
        }

        Write-Error "Timed out after $TimeoutSeconds seconds waiting for sign-in." -ErrorAction Stop
    }
    finally {
        $ProgressPreference = $previousProgress
    }
}