keycloakTokenManager.ps1

class KeycloakTokenManagerVersion {
    [int]$Major = 1
    [int]$Minor = 4
    [int]$Build = 9
    [int]$Revision = 861

    [string]ToString() {
        return "keycloakTokenManager v$($this.Major).$($this.Minor).$($this.Build).$($this.Revision)"
    }
}

class KeycloakTokenManager {
    hidden [uri]$realmUri = [string]::Empty
    hidden [uri]$realmAdminUri = [string]::Empty
    hidden [PSCustomObject]$realmWellKnownEndpoints = $null
    hidden [string]$realmClientName = "admin-cli"
    hidden [string]$realmClientSecret = [string]::Empty
    hidden [string]$realmName = [string]::Empty
    hidden [string]$userName = [string]::Empty
    hidden [string]$password = [string]::Empty
    hidden [string]$grant_type = "password"

    hidden [hashtable]$impersonations = @{}
    hidden [KeycloakTokenManagerVersion]$version = [KeycloakTokenManagerVersion]::new()

    [string]$default_scope = "openid email profile"

    [KeycloakTokenManagerVersion]Version() {
        return $this.version
    }

    [string]getVersion() {
        return $this.version.ToString()
    }

    hidden [void]getKeycloakUris() {
        $local:keycloakHostUrl = "$($this.realmUri.Scheme)://$($this.realmUri.Host)"

        $local:segments = ($this.realmUri.Segments)
        [int]$local:realmsIndex = $local:segments.IndexOf("realms/")

        $this.realmName = $local:segments[$local:realmsIndex + 1].TrimEnd("/")
        $this.realmUri = "$($local:keycloakHostUrl)$($local:segments[0..$($local:realmsIndex + 1)] -join '')".TrimEnd("/")
        $this.realmAdminUri = "$($local:keycloakHostUrl)$($local:segments[0..$($local:realmsIndex - 1)] -join '')admin/$($local:segments[$($local:realmsIndex)..$($local:realmsIndex + 1)] -join '')".TrimEnd("/")

        $local:wellKnownEndpoints = "$($this.realmUri)/.well-known/openid-configuration"

        $local:result = $null
        if ($global:PSVersionTable.PSVersion.Major -eq 7) {
            $local:result = Invoke-WebRequest -Method Get -Uri $local:wellKnownEndpoints -SkipHttpErrorCheck
        }
        else {
            try {
                $local:result = Invoke-WebRequest -Method Get -Uri $local:wellKnownEndpoints
            }
            catch {
                $local:result = $_
            }
        }
        if ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::OK) {
            $this.realmWellKnownEndpoints = $local:result.content | ConvertFrom-Json # -Depth 100
        }
        else {
            throw "Unable to get data from: $($local:wellKnownEndpoints)",$local:result.Exception
        }
    }

    hidden [PSCustomObject]parseJWTtoken([string]$token) {
        if (!$token.Contains(".") -or !$token.StartsWith("eyJ")) { throw "Invalid token" }
        $local:tokenheader = $token.Split(".")[0].Replace('-', '+').Replace('_', '/')
        while ($local:tokenheader.Length % 4) { $local:tokenheader += "=" }
        $local:tokenPayload = $token.Split(".")[1].Replace('-', '+').Replace('_', '/')
        while ($local:tokenPayload.Length % 4) { $local:tokenPayload += "=" }
        $local:tokenByteArray = [System.Convert]::FromBase64String($local:tokenPayload)
        $local:tokenArray = [System.Text.Encoding]::ASCII.GetString($local:tokenByteArray)
        $local:tokenObj = $local:tokenArray | ConvertFrom-Json
        return $local:tokenObj
    }

    hidden [bool]isTokenValid($decodedToken) {
        [datetime]$local:now = (Get-Date).ToUniversalTime()
        [datetime]$local:tokenExpiresAt = (Get-date 01-01-1970).AddSeconds($decodedToken.exp).AddSeconds(-10)
        return ($local:tokenExpiresAt -gt $local:now)
    }

    hidden [void]requestNewToken() {
        $this.requestNewToken($this.userName)
    }

    hidden [void]requestNewToken([string]$userName) {
        $local:body = $null
        if ($userName -eq $this.userName) {
            switch ($this.grant_type) {
                "authorization_code" {
                    $this.requestNewTokenByBrowser()
                    return
                }
                "password" {
                    $local:body = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
                    $local:body.Add("grant_type", "password")
                    $local:body.Add("client_id", $($this.realmClientName))
                    if (-not [string]::IsNullOrEmpty($this.realmClientSecret)) {
                        $local:body.Add("client_secret", $($this.realmClientSecret))
                    }
                    if (-not [string]::IsNullOrEmpty($this.default_scope)) {
                        $local:body.Add("scope", $this.default_scope)
                    }
                    $local:body.Add("username", $($this.userName))
                    $local:body.Add("password", $($this.password))
                }
            }
        }
        else {
            $local:impersonationAccessToken = $this.getAccessToken()
            if ($this.impersonations[$this.userName].decodedAccessToken.resource_access.'realm-management'.roles.Contains("impersonation")) {
                $local:body = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
                $local:body.Add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
                $local:body.Add("client_id", $($this.realmClientName))
                if (-not [string]::IsNullOrEmpty($this.realmClientSecret)) {
                    $local:body.Add("client_secret", $($this.realmClientSecret))
                }
                if (-not [string]::IsNullOrEmpty($this.default_scope)) {
                    $local:body.Add("scope", $this.default_scope)
                }
                $local:body.Add("requested_subject", $($userName))
                $local:body.Add("subject_token", $($local:impersonationAccessToken))
            }
            else {
                throw "ERROR: User '$($this.userName)' does not own impersonation role."
            }
        }

        $local:result = $null
        if ($global:PSVersionTable.PSVersion.Major -eq 7) {
            $local:result = Invoke-WebRequest -Method Post -Uri $this.realmWellKnownEndpoints.token_endpoint -Body $local:body -SkipHttpErrorCheck
        }
        else {
            try {
                $local:result = Invoke-WebRequest -Method Post -Uri $this.realmWellKnownEndpoints.token_endpoint -Body $local:body
            }
            catch {
                $local:result = $_.Exception.Response
            }
        }

        if ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::OK) {
            $local:tokenObject = ($local:result.Content | ConvertFrom-Json)
            $local:impersonation = @{
                tokenObject         = $local:tokenObject
                decodedAccessToken  = $this.parseJWTtoken($local:tokenObject.access_token)
                decodedRefreshToken = $this.parseJWTtoken($local:tokenObject.refresh_token)
            }
            $this.impersonations[$userName] = $local:impersonation
        }
        else {
            if ($userName -eq $this.userName) {
                throw "ERROR: Failed to authenticate '$($userName)'"
            }
            else {
                throw "ERROR: Failed to impersonate '$($userName)'"
            }
        }
    }

    hidden [void]requestNewTokenByBrowser() {
        [uri]$local:redirect_url = "http://localhost:8081"

        try {
            $local:httpsrvlsnr = New-Object System.Net.HttpListener
            $local:httpsrvlsnr.Prefixes.Add($local:redirect_url.AbsoluteUri)
            $local:httpsrvlsnr.Start()
        }
        catch {
            throw $_
            # throw $_.ErrorDetails.Message
        }

        $local:authorizationUrl = "$($this.realmWellKnownEndpoints.authorization_endpoint)"
        # $local:authorizationUrl += "?redirect_uri=$([System.Web.HttpUtility]::UrlEncode($local:redirect_url))"
        # $local:authorizationUrl += "?redirect_uri=$([uri]::EscapeDataString($local:redirect_url))"
        $local:authorizationUrl += "?redirect_uri=$([uri]::EscapeDataString($local:redirect_url))"
        $local:authorizationUrl += "&client_id=$($this.realmClientName)"
        $local:authorizationUrl += "&response_type=code"

        Start-Process -FilePath $local:authorizationUrl

        while ($local:httpsrvlsnr.IsListening) {
            try {
                $local:ctx = $local:httpsrvlsnr.GetContext();
                $local:ht_query = @{}
                $local:queryString = $local:ctx.Request.Url.Query.TrimStart("?")
                foreach ($queryParameter in ($local:queryString -split '&')) {
                    $local:keyValuePair = $($local:queryParameter + '=') -split '='
                    $local:key = [uri]::UnescapeDataString($local:keyValuePair[0]).Trim()
                    $local:ht_query[$key] = [uri]::UnescapeDataString($local:keyValuePair[1])
                }

                if ($local:ht_query.Contains("code")) {
                    $local:body = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
                    $local:body.Add("grant_type", "authorization_code")
                    $local:body.Add("code", $local:ht_query["code"])
                    $local:body.Add("redirect_uri", $local:redirect_url)
                    $local:body.Add("client_id", $this.realmClientName)
                    if (-not [string]::IsNullOrEmpty($this.realmClientSecret)) {
                        $local:body.Add("client_secret", $($this.realmClientSecret))
                    }
                    if (-not [string]::IsNullOrEmpty($this.default_scope)) {
                        $local:body.Add("scope", $this.default_scope)
                    }

                    $local:result = $null
                    if ($global:PSVersionTable.PSVersion.Major -eq 7) {
                        $local:result = Invoke-WebRequest -Method Post -Uri $($this.realmWellKnownEndpoints.token_endpoint) -Body $local:body -SkipHttpErrorCheck
                    }
                    else {
                        try {
                            $local:result = Invoke-WebRequest -Method Post -Uri $($this.realmWellKnownEndpoints.token_endpoint) -Body $local:body
                        }
                        catch {
                            $local:result = $_.Exception.Response
                        }
                    }
                    if ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::OK) {
                        [byte[]]$local:buffer = $null
                        $local:buffer = [System.Text.Encoding]::UTF8.GetBytes("<html><h1>You can now return to the application.</h1></html>");
                        $local:ctx.Response.ContentLength64 = $local:buffer.Length;
                        $local:ctx.Response.OutputStream.WriteAsync($local:buffer, 0, $local:buffer.Length)
                        $local:httpsrvlsnr.Stop()

                        $local:tokenObject = ($local:result.Content | ConvertFrom-Json)
                        $local:impersonation = @{
                            tokenObject         = $local:tokenObject
                            decodedAccessToken  = $this.parseJWTtoken($local:tokenObject.access_token)
                            decodedRefreshToken = $this.parseJWTtoken($local:tokenObject.refresh_token)
                        }

                        $this.userName = $local:impersonation.decodedAccessToken.preferred_username
                        $this.impersonations[$this.userName] = $local:impersonation
                    }
                    else {
                        throw $local:result.Content
                    }
                }
                else {
                    throw "ERROR: Failed to receive authorization_code"
                }
            }
            catch [System.Net.HttpListenerException] {
                throw $_
            }
            finally {
                $local:httpsrvlsnr.Stop()
                $local:httpsrvlsnr.dispose()
            }
        }
        $local:httpsrvlsnr.dispose()
    }

    hidden [void]refreshToken([string]$userName) {
        if ($this.impersonations.ContainsKey($userName)) {
            $local:impersonation = $this.impersonations[$userName]

            $local:body = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
            $local:body.Add("grant_type", "refresh_token")
            $local:body.Add("client_id", $($this.realmClientName))
            if (-not [string]::IsNullOrEmpty($this.realmClientSecret)) {
                $local:body.Add("client_secret", $($this.realmClientSecret))
            }
            if (-not [string]::IsNullOrEmpty($this.default_scope)) {
                $local:body.Add("scope", $this.default_scope)
            }
            $local:body.Add("refresh_token", $($local:impersonation.tokenObject.refresh_token))

            $local:result = $null
            if ($global:PSVersionTable.PSVersion.Major -eq 7) {
                $local:result = Invoke-WebRequest -Method Post -Uri $this.realmWellKnownEndpoints.token_endpoint -Body $local:body -SkipHttpErrorCheck
            }
            else {
                try {
                    $local:result = Invoke-WebRequest -Method Post -Uri $this.realmWellKnownEndpoints.token_endpoint -Body $local:body
                }
                catch {
                    $local:result = $_.Exception.Response
                }
            }
            if ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::OK) {
                $local:tokenObject = ($local:result.Content | ConvertFrom-Json)
                $local:impersonation = @{
                    tokenObject         = $local:tokenObject
                    decodedAccessToken  = $this.parseJWTtoken($local:tokenObject.access_token)
                    decodedRefreshToken = $this.parseJWTtoken($local:tokenObject.refresh_token)
                }
                $this.impersonations[$userName] = $local:impersonation
            }
            else {
                if ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::BadRequest) {
                    $local:error = $local:result.content | ConvertFrom-Json # -Depth 1
                    if ($local:error.error_description -eq "Session not active") {
                        # {"error":"invalid_grant","error_description":"Session not active"}
                        $this.requestNewToken($userName)
                    }
                }
                else {
                    throw $local:result
                }
            }
        }
        else {
            $this.requestNewToken($userName)
        }
    }

    hidden [PSCustomObject]getToken() {
        return $this.getToken($this.userName)
    }

    hidden [PSCustomObject]getToken($userName) {
        if ($this.impersonations.ContainsKey($userName)) {
            $local:impersonation = $this.impersonations[$userName]

            if ($this.isTokenValid($local:impersonation.decodedAccessToken) -eq $false) {
                if ($this.isTokenValid($local:impersonation.decodedRefreshToken) -eq $false) {
                    $this.requestNewToken($userName)
                }
                else {
                    $this.refreshToken($userName)
                }
            }
        }
        else {
            $this.requestNewToken($userName)
        }
        return $this.impersonations[$userName].tokenObject
    }

    [string]getAccessToken() {
        return $this.getAccessToken($this.userName)
    }

    [string]getAccessToken([string]$userName) {
        return ($this.getToken($userName)).access_token
    }

    [string]getRefreshToken() {
        return $this.getRefreshToken($this.userName)
    }

    [string]getRefreshToken([string]$userName) {
        return ($this.getToken($userName)).refresh_token
    }

    [bool]logout() {
        return $this.logout($this.userName)
    }

    [bool]logout([string]$userName) {
        if ($this.impersonations.ContainsKey($userName)) {
            $local:impersonation = $this.impersonations[$userName]

            $local:body = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
            $local:body.Add("client_id", $($this.realmClientName))
            if (-not [string]::IsNullOrEmpty($this.realmClientSecret)) {
                $local:body.Add("client_secret", $($this.realmClientSecret))
            }
            $local:body.Add("refresh_token", $($local:impersonation.tokenObject.refresh_token))

            $local:url = $this.realmWellKnownEndpoints.end_session_endpoint

            $local:header = $this.getHeader($userName)
            $local:header.Remove("Content-Type") | Out-Null

            $local:result = $null
            if ($global:PSVersionTable.PSVersion.Major -eq 7) {
                $local:result = Invoke-WebRequest -Method Post -Uri $url -Headers $local:header -Body $local:body -SkipHttpErrorCheck
            }
            else {
                try {
                    $local:result = Invoke-WebRequest -Method Post -Uri $url -Headers $local:header -Body $local:body
                }
                catch {
                    $local:result = $_.Exception.Response
                }
            }

            if ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::NoContent) {
                $this.impersonations.Remove($userName)
            }
            return ($local:result.StatusCode -eq [System.Net.HttpStatusCode]::NoContent)
        }
        return $false
    }

    [void]dispose() {
        $local:userNames = $this.impersonations.Keys | Select-Object
        foreach ($local:userName in $local:userNames) {
            if ($local:userName -ne $this.userName) {
                $this.logout($local:userName) | Out-Null
            }
        }
        $this.logout($this.userName) | Out-Null

        $this.realmUri = $null
        $this.realmAdminUri = $null
        $this.realmWellKnownEndpoints = $null
        $this.realmClientName = "admin-cli"
        $this.realmClientSecret = [string]::Empty
        $this.realmName = [string]::Empty
        $this.userName = [string]::Empty
        $this.password = [string]::Empty
        $this.impersonations = @{}
        [GC]::Collect()
    }

    [System.Collections.Generic.Dictionary[[String], [String]]]getHeader() {
        return $this.getHeader($this.userName)
    }

    [System.Collections.Generic.Dictionary[[String], [String]]]getHeader([string]$userName) {
        $local:header = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
        $local:header.Add("Content-Type", "application/json")
        $local:header.Add("Authorization", "Bearer $($this.getAccessToken($userName))")
        return $local:header
    }

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '')]
    KeycloakTokenManager([string]$userName, [string]$password, [uri]$realmUri) {
        $this.userName = $userName
        $this.password = $password
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
    }

    KeycloakTokenManager([string]$userName, [securestring]$password, [uri]$realmUri) {
        $this.userName = $userName
        if ($global:PSVersionTable.PSVersion.Major -eq 7) {
            $this.password = $password | ConvertFrom-SecureString -AsPlainText
        }
        else {
            $local:bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)
            $this.password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($local:bstr)
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($local:bstr)
        }
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
    }

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '')]
    KeycloakTokenManager([string]$userName, [string]$password, [uri]$realmUri, [string]$clientName, [string]$clientSecret) {
        $this.realmClientName = $clientName
        $this.realmClientSecret = $clientSecret
        $this.userName = $userName
        $this.password = $password
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
    }

    KeycloakTokenManager([string]$userName, [securestring]$password, [uri]$realmUri, [string]$clientName, [string]$clientSecret) {
        $this.realmClientName = $clientName
        $this.realmClientSecret = $clientSecret
        $this.userName = $userName
        if ($global:PSVersionTable.PSVersion.Major -eq 7) {
            $this.password = $password | ConvertFrom-SecureString -AsPlainText
        }
        else {
            $local:bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)
            $this.password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($local:bstr)
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($local:bstr)
        }
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
    }

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', '')]
    KeycloakTokenManager([string]$userName, [string]$password, [uri]$realmUri, [string]$clientName) {
        $this.realmClientName = $clientName
        $this.realmClientSecret = [string]::Empty
        $this.userName = $userName
        $this.password = $password
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
    }

    KeycloakTokenManager([string]$userName, [securestring]$password, [uri]$realmUri, [string]$clientName) {
        $this.realmClientName = $clientName
        $this.realmClientSecret = [string]::Empty
        $this.userName = $userName
        if ($global:PSVersionTable.PSVersion.Major -eq 7) {
            $this.password = $password | ConvertFrom-SecureString -AsPlainText
        }
        else {
            $local:bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($password)
            $this.password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($local:bstr)
            [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($local:bstr)
        }
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
    }

    KeycloakTokenManager([uri]$realmUri, [string]$clientName) {
        $this.realmClientName = $clientName
        $this.realmClientSecret = [string]::Empty
        $this.userName = [string]::Empty
        $this.password = [string]::Empty
        $this.grant_type = "authorization_code"
        $this.realmUri = $realmUri
        $this.getKeycloakUris()
        $this.requestNewTokenByBrowser()
    }

    [string]ToString() {
        return $this.getVersion()
    }
}