Private/TokenEndpoint.ps1

# Authentik's token, device and revocation endpoints (ADR 0009 section 6).
# These are the only requests whose bodies carry a token, so they're sent
# with HttpClient: the form is built as bytes, with tokens copied straight
# out of their SecureStrings, and the reply's token values are copied
# straight into new SecureStrings. Neither ever becomes a .NET string here,
# and the byte buffers are zeroed afterwards.

# Fields of a reply that hold credentials.
$script:SecretFields = @('access_token', 'refresh_token', 'id_token', 'device_code')

function Add-PaFormValue {
    <#
    .SYNOPSIS
    Appends one form value to a byte list, percent-encoded.
    #>

    [CmdletBinding()]
    param(
        # The list to append to.
        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[byte]] $Bytes,

        # The value: a string, or a SecureString that's never made a string.
        [Parameter(Mandatory)]
        [object] $Value
    )
    $unreserved = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
    if ($Value -is [securestring]) {
        $pointer = [System.Runtime.InteropServices.Marshal]::SecureStringToGlobalAllocUnicode($Value)
        try {
            for ($i = 0; $i -lt $Value.Length; $i++) {
                $c = [System.Runtime.InteropServices.Marshal]::ReadInt16($pointer, $i * 2)
                if ($c -lt 128 -and $unreserved.IndexOf([char]$c) -ge 0) {
                    $Bytes.Add([byte]$c)
                } else {
                    foreach ($b in [System.Text.Encoding]::UTF8.GetBytes([char]$c)) {
                        foreach ($e in [System.Text.Encoding]::ASCII.GetBytes(('%{0:X2}' -f $b))) {
                            $Bytes.Add($e)
                        }
                    }
                }
            }
            $c = 0
        } finally {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeGlobalAllocUnicode($pointer)
        }
    } else {
        $Bytes.AddRange([System.Text.Encoding]::ASCII.GetBytes([uri]::EscapeDataString([string]$Value)))
    }
}

function Read-PaSecretField {
    <#
    .SYNOPSIS
    Copies a JSON string field out of a reply into a SecureString, and blanks it in the buffer.

    .DESCRIPTION
    Finds "name": "value" in the reply's bytes and decodes the value's JSON
    escapes (Authentik's device codes include punctuation such as " and \)
    as it copies it. Token values are ASCII, so other bytes are refused.
    Every byte of the value, escapes included, is overwritten. Returns $null
    when the field isn't there.
    #>

    [CmdletBinding()]
    [OutputType([securestring])]
    param(
        # The reply's bytes; the value is overwritten in place.
        [Parameter(Mandatory)]
        [byte[]] $Buffer,

        # The field's name.
        [Parameter(Mandatory)]
        [string] $Name
    )
    $needle = [System.Text.Encoding]::ASCII.GetBytes('"' + $Name + '"')
    $limit = $Buffer.Length - $needle.Length
    for ($start = 0; $start -le $limit; $start++) {
        $found = $true
        for ($j = 0; $j -lt $needle.Length; $j++) {
            if ($Buffer[$start + $j] -ne $needle[$j]) {
                $found = $false
                break
            }
        }
        if (-not $found) {
            continue
        }
        $i = $start + $needle.Length
        while ($i -lt $Buffer.Length -and $Buffer[$i] -in 9, 10, 13, 32) { $i++ }
        if ($i -ge $Buffer.Length -or $Buffer[$i] -ne 58) { continue }  # :
        $i++
        while ($i -lt $Buffer.Length -and $Buffer[$i] -in 9, 10, 13, 32) { $i++ }
        if ($i -ge $Buffer.Length -or $Buffer[$i] -ne 34) { return $null }  # " (null or another type)
        $i++
        $secure = [securestring]::new()
        $escapes = @{ 34 = 34; 92 = 92; 47 = 47; 98 = 8; 102 = 12; 110 = 10; 114 = 13; 116 = 9 }
        while ($i -lt $Buffer.Length -and $Buffer[$i] -ne 34) {
            $b = $Buffer[$i]
            $Buffer[$i] = 120  # x
            $i++
            if ($b -ge 128) {
                $secure.Dispose()
                throw (New-PaErrorRecord -ErrorId 'token_reply_unreadable' -Message "Authentik's reply couldn't be read." -Category InvalidData)
            }
            if ($b -ne 92) {
                $secure.AppendChar([char]$b)
                continue
            }
            # An escape: \" \\ \/ \b \f \n \r \t or \uXXXX.
            $e = if ($i -lt $Buffer.Length) { $Buffer[$i] } else { 0 }
            if ($e -ne 0) { $Buffer[$i] = 120 }
            $i++
            if ($escapes.ContainsKey([int]$e)) {
                $secure.AppendChar([char]$escapes[[int]$e])
            } elseif ($e -eq 117 -and $i + 4 -le $Buffer.Length) {
                $code = 0
                for ($k = 0; $k -lt 4; $k++) {
                    $code = $code * 16 + [System.Convert]::ToInt32([string][char]$Buffer[$i], 16)
                    $Buffer[$i] = 120
                    $i++
                }
                $secure.AppendChar([char]$code)
                $code = 0
            } else {
                $secure.Dispose()
                throw (New-PaErrorRecord -ErrorId 'token_reply_unreadable' -Message "Authentik's reply couldn't be read." -Category InvalidData)
            }
            $e = 0
        }
        $b = 0
        $secure.MakeReadOnly()
        return $secure
    }
    return $null
}

function Invoke-PaTokenRequest {
    <#
    .SYNOPSIS
    Posts a form to one of Authentik's OAuth endpoints and reads the reply safely.

    .DESCRIPTION
    Returns the reply's fields: credentials (access_token, refresh_token,
    id_token, device_code) as SecureStrings, everything else as read by
    ConvertFrom-Json, plus Status. A reply that isn't JSON (revocation
    answers with an empty body) gives just Status.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        # The endpoint's URL.
        [Parameter(Mandatory)]
        [uri] $Uri,

        # The form: names and values, strings or SecureStrings.
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Form
    )
    $bytes = [System.Collections.Generic.List[byte]]::new()
    $first = $true
    foreach ($key in $Form.Keys) {
        if (-not $first) { $bytes.Add(38) }  # &
        $first = $false
        Add-PaFormValue -Bytes $bytes -Value $key
        $bytes.Add(61)  # =
        Add-PaFormValue -Bytes $bytes -Value $Form[$key]
    }
    $body = $bytes.ToArray()
    $bytes.Clear()
    $reply = $null
    $client = [System.Net.Http.HttpClient]::new()
    $client.Timeout = [timespan]::FromSeconds(30)
    $request = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Post, $Uri)
    try {
        $request.Content = [System.Net.Http.ByteArrayContent]::new($body)
        $request.Content.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::new('application/x-www-form-urlencoded')
        $request.Headers.Accept.ParseAdd('application/json')
        Write-Verbose -Message "POST $($Uri.AbsolutePath)"
        try {
            $response = $client.SendAsync($request).GetAwaiter().GetResult()
        } catch {
            throw (New-PaErrorRecord -ErrorId 'authentik_unreachable' -Message "Authentik can't be reached at $($Uri.GetLeftPart([System.UriPartial]::Authority)): $($_.Exception.GetBaseException().Message)" -Category ConnectionError)
        }
        $reply = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
        $out = @{ Status = [int]$response.StatusCode }
        foreach ($field in $script:SecretFields) {
            $value = Read-PaSecretField -Buffer $reply -Name $field
            if ($null -ne $value) {
                $out[$field] = $value
            }
        }
        # Every credential is now blanked, so the rest can be read as text.
        $text = [System.Text.Encoding]::UTF8.GetString($reply)
        if ($text.TrimStart().StartsWith('{')) {
            $json = $text | ConvertFrom-Json -AsHashtable
            foreach ($key in $json.Keys) {
                if ($key -notin $script:SecretFields) {
                    $out[$key] = $json[$key]
                }
            }
        }
        $out
    } finally {
        [array]::Clear($body)
        if ($null -ne $reply) {
            [array]::Clear($reply)
        }
        $request.Dispose()
        $client.Dispose()
    }
}