Private/JIMTokenStore.ps1

# Copyright (c) Tetron Limited. All rights reserved.
# Licensed under the Tetron Commercial License. See LICENSE file in the project root.

<#
    Persistent refresh-token storage for the JIM PowerShell module (issue #305).
 
    Only the OAuth refresh token is persisted, never the access token. The refresh
    token is stored in the operating system's native credential store so it is
    encrypted at rest and unlocked automatically as part of the user's OS logon:
 
      - Windows : Credential Manager (DPAPI, per-user) via advapi32 Cred* APIs
      - macOS : login Keychain via the 'security' CLI
      - Linux : libsecret via 'secret-tool' when present
 
    On a system with no usable store (typically headless/SSH Linux without a
    keyring), persistence is unavailable and callers fall back to in-memory
    storage. No bespoke encrypted file is ever written.
 
    To use a cached token in a new session the module re-fetches the OAuth
    configuration (/api/v1/auth/config + OIDC discovery) and performs a
    refresh_token grant, so nothing beyond the refresh token needs to be stored.
#>


# Service / target naming. The cache key (a normalised base URL) is appended so
# multiple JIM instances can be cached independently.
$script:JIMTokenStoreService = 'JIM'
$script:JIMTokenStoreTargetPrefix = 'JIM:'

function Get-JIMTokenCacheKey {
    <#
    .SYNOPSIS
        Derives a stable cache key from a JIM base URL.
 
    .DESCRIPTION
        Normalises the URL to "scheme://host:port" with a lower-cased scheme and
        host and an explicit port. This collapses incidental differences (trailing
        slash, default-port presence, host casing) so the same instance always maps
        to the same key, while keeping distinct schemes, hosts, and ports separate.
 
    .PARAMETER BaseUrl
        The JIM base URL, e.g. 'https://jim.company.com' or 'http://localhost:5200'.
 
    .OUTPUTS
        The normalised cache key string.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$BaseUrl
    )

    $uri = [Uri]$BaseUrl
    # [Uri].Port always returns the effective port (default if unspecified), so the
    # key is explicit regardless of whether the caller included the port.
    return "$($uri.Scheme.ToLowerInvariant())://$($uri.Host.ToLowerInvariant()):$($uri.Port)"
}

function Get-JIMTokenStoreProvider {
    <#
    .SYNOPSIS
        Determines which credential-store provider is available on this system.
 
    .OUTPUTS
        One of 'Windows', 'MacOS', 'LibSecret', or 'None'.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()

    if ($IsWindows -or $env:OS -match 'Windows') {
        return 'Windows'
    }

    if ($IsMacOS) {
        return 'MacOS'
    }

    # Linux (or any other platform): persistence requires libsecret's secret-tool.
    # When it is absent (typical on headless servers / SSH with no keyring), there
    # is no password-free store, so report None and let the caller fall back to
    # in-memory storage.
    if (Get-Command 'secret-tool' -ErrorAction SilentlyContinue) {
        return 'LibSecret'
    }

    return 'None'
}

function Test-JIMTokenPersistenceAvailable {
    <#
    .SYNOPSIS
        Returns $true when a usable credential store is available on this system.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param()

    return (Get-JIMTokenStoreProvider) -ne 'None'
}

function Initialize-JIMWindowsCredentialStore {
    <#
    .SYNOPSIS
        Compiles the Windows Credential Manager interop helper once per session.
    #>

    [CmdletBinding()]
    param()

    if ('JIMCredentialStore' -as [type]) {
        return
    }

    $source = @'
using System;
using System.Runtime.InteropServices;
using System.Text;
 
public static class JIMCredentialStore
{
    private const uint CRED_TYPE_GENERIC = 1;
    private const uint CRED_PERSIST_LOCAL_MACHINE = 2;
    private const int ERROR_NOT_FOUND = 1168;
    // CRED_MAX_CREDENTIAL_BLOB_SIZE = 5 * 512.
    private const int MaxBlobBytes = 2560;
 
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    private struct CREDENTIAL
    {
        public uint Flags;
        public uint Type;
        public string TargetName;
        public string Comment;
        public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
        public uint CredentialBlobSize;
        public IntPtr CredentialBlob;
        public uint Persist;
        public uint AttributeCount;
        public IntPtr Attributes;
        public string TargetAlias;
        public string UserName;
    }
 
    [DllImport("advapi32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredWriteW")]
    private static extern bool CredWrite(ref CREDENTIAL userCredential, uint flags);
 
    [DllImport("advapi32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredReadW")]
    private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credentialPtr);
 
    [DllImport("advapi32", SetLastError = true, EntryPoint = "CredFree")]
    private static extern void CredFree(IntPtr cred);
 
    [DllImport("advapi32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredDeleteW")]
    private static extern bool CredDelete(string target, uint type, uint flags);
 
    [DllImport("advapi32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredEnumerateW")]
    private static extern bool CredEnumerate(string filter, uint flags, out uint count, out IntPtr pCredentials);
 
    public static void Write(string target, string userName, string secret)
    {
        byte[] blob = Encoding.Unicode.GetBytes(secret);
        if (blob.Length > MaxBlobBytes)
        {
            throw new ArgumentException("Secret exceeds the Windows Credential Manager blob size limit of " + MaxBlobBytes + " bytes.");
        }
 
        IntPtr blobPtr = Marshal.AllocHGlobal(blob.Length);
        try
        {
            Marshal.Copy(blob, 0, blobPtr, blob.Length);
            CREDENTIAL cred = new CREDENTIAL();
            cred.Type = CRED_TYPE_GENERIC;
            cred.TargetName = target;
            cred.CredentialBlobSize = (uint)blob.Length;
            cred.CredentialBlob = blobPtr;
            cred.Persist = CRED_PERSIST_LOCAL_MACHINE;
            cred.UserName = userName;
            if (!CredWrite(ref cred, 0))
            {
                throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        finally
        {
            Marshal.FreeHGlobal(blobPtr);
        }
    }
 
    public static string Read(string target)
    {
        IntPtr credPtr;
        if (!CredRead(target, CRED_TYPE_GENERIC, 0, out credPtr))
        {
            int err = Marshal.GetLastWin32Error();
            if (err == ERROR_NOT_FOUND)
            {
                return null;
            }
            throw new System.ComponentModel.Win32Exception(err);
        }
 
        try
        {
            CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL));
            if (cred.CredentialBlobSize == 0)
            {
                return string.Empty;
            }
            byte[] blob = new byte[cred.CredentialBlobSize];
            Marshal.Copy(cred.CredentialBlob, blob, 0, (int)cred.CredentialBlobSize);
            return Encoding.Unicode.GetString(blob);
        }
        finally
        {
            CredFree(credPtr);
        }
    }
 
    public static bool Delete(string target)
    {
        if (!CredDelete(target, CRED_TYPE_GENERIC, 0))
        {
            int err = Marshal.GetLastWin32Error();
            if (err == ERROR_NOT_FOUND)
            {
                return false;
            }
            throw new System.ComponentModel.Win32Exception(err);
        }
        return true;
    }
 
    public static string[] List(string filter)
    {
        uint count;
        IntPtr pCreds;
        if (!CredEnumerate(filter, 0, out count, out pCreds))
        {
            int err = Marshal.GetLastWin32Error();
            if (err == ERROR_NOT_FOUND)
            {
                return new string[0];
            }
            throw new System.ComponentModel.Win32Exception(err);
        }
 
        try
        {
            string[] names = new string[count];
            for (int i = 0; i < count; i++)
            {
                IntPtr credPtr = Marshal.ReadIntPtr(pCreds, i * IntPtr.Size);
                CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL));
                names[i] = cred.TargetName;
            }
            return names;
        }
        finally
        {
            CredFree(pCreds);
        }
    }
}
'@


    Add-Type -TypeDefinition $source -Language CSharp
}

function Invoke-JIMStoreProcess {
    <#
    .SYNOPSIS
        Runs an external credential-store binary, optionally piping a secret to its
        standard input without appending a newline.
 
    .DESCRIPTION
        Uses System.Diagnostics.Process with ArgumentList so arguments are passed
        without shell interpretation (no injection, no quoting issues). Writing the
        secret via stdin (rather than as an argument) keeps it off the process
        command line where the platform store supports it (libsecret does; the macOS
        'security' CLI requires the secret as an argument and is handled separately).
 
    .OUTPUTS
        A PSCustomObject with ExitCode, StdOut, and StdErr.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory)]
        [string]$FilePath,

        [string[]]$Arguments = @(),

        [string]$StdinData
    )

    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = $FilePath
    foreach ($argument in $Arguments) {
        $psi.ArgumentList.Add($argument)
    }
    $hasStdin = $PSBoundParameters.ContainsKey('StdinData')
    $psi.RedirectStandardInput = $hasStdin
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.UseShellExecute = $false

    $process = [System.Diagnostics.Process]::Start($psi)
    if ($hasStdin) {
        # Write the exact secret bytes with no trailing newline so the stored value
        # matches the token precisely.
        $process.StandardInput.Write($StdinData)
        $process.StandardInput.Close()
    }
    $stdOut = $process.StandardOutput.ReadToEnd()
    $stdErr = $process.StandardError.ReadToEnd()
    $process.WaitForExit()

    return [PSCustomObject]@{
        ExitCode = $process.ExitCode
        StdOut   = $stdOut
        StdErr   = $stdErr
    }
}

function Save-JIMToken {
    <#
    .SYNOPSIS
        Persists a refresh token for a JIM instance in the OS credential store.
 
    .DESCRIPTION
        No-op (returns $false) when no credential store is available. Returns $true
        when the token was stored successfully.
 
    .PARAMETER BaseUrl
        The JIM base URL the token belongs to.
 
    .PARAMETER RefreshToken
        The OAuth refresh token to persist.
 
    .OUTPUTS
        [bool] indicating whether the token was persisted.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$BaseUrl,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$RefreshToken
    )

    $key = Get-JIMTokenCacheKey -BaseUrl $BaseUrl
    $provider = Get-JIMTokenStoreProvider

    switch ($provider) {
        'Windows' {
            Initialize-JIMWindowsCredentialStore
            [JIMCredentialStore]::Write("$script:JIMTokenStoreTargetPrefix$key", $key, $RefreshToken)
            Write-Verbose "Persisted refresh token to Windows Credential Manager for $key"
            return $true
        }
        'MacOS' {
            # The macOS 'security' CLI takes the secret as an argument (-w). This is
            # the documented interface; argv is visible only to the same user's
            # processes. -U updates the item if it already exists.
            $result = Invoke-JIMStoreProcess -FilePath 'security' -Arguments @(
                'add-generic-password',
                '-a', $key,
                '-s', $script:JIMTokenStoreService,
                '-U',
                '-w', $RefreshToken
            )
            if ($result.ExitCode -ne 0) {
                throw "Failed to store token in macOS Keychain (exit $($result.ExitCode)): $($result.StdErr.Trim())"
            }
            Write-Verbose "Persisted refresh token to macOS Keychain for $key"
            return $true
        }
        'LibSecret' {
            $result = Invoke-JIMStoreProcess -FilePath 'secret-tool' -Arguments @(
                'store',
                '--label', "JIM refresh token ($key)",
                'service', $script:JIMTokenStoreService,
                'url', $key
            ) -StdinData $RefreshToken
            if ($result.ExitCode -ne 0) {
                throw "Failed to store token via libsecret (exit $($result.ExitCode)): $($result.StdErr.Trim())"
            }
            Write-Verbose "Persisted refresh token to libsecret for $key"
            return $true
        }
        default {
            Write-Verbose "No credential store available; refresh token not persisted"
            return $false
        }
    }
}

function Get-JIMPersistedToken {
    <#
    .SYNOPSIS
        Retrieves a persisted refresh token for a JIM instance, or $null if none.
 
    .PARAMETER BaseUrl
        The JIM base URL whose token should be retrieved.
 
    .OUTPUTS
        The refresh token string, or $null when none is stored / no store exists.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]$BaseUrl
    )

    $key = Get-JIMTokenCacheKey -BaseUrl $BaseUrl
    $provider = Get-JIMTokenStoreProvider

    switch ($provider) {
        'Windows' {
            Initialize-JIMWindowsCredentialStore
            $token = [JIMCredentialStore]::Read("$script:JIMTokenStoreTargetPrefix$key")
            if ([string]::IsNullOrEmpty($token)) { return $null }
            return $token
        }
        'MacOS' {
            $result = Invoke-JIMStoreProcess -FilePath 'security' -Arguments @(
                'find-generic-password',
                '-a', $key,
                '-s', $script:JIMTokenStoreService,
                '-w'
            )
            if ($result.ExitCode -ne 0) { return $null }
            # 'security -w' prints the secret followed by a newline.
            $token = $result.StdOut.TrimEnd("`r", "`n")
            if ([string]::IsNullOrEmpty($token)) { return $null }
            return $token
        }
        'LibSecret' {
            $result = Invoke-JIMStoreProcess -FilePath 'secret-tool' -Arguments @(
                'lookup',
                'service', $script:JIMTokenStoreService,
                'url', $key
            )
            if ($result.ExitCode -ne 0) { return $null }
            $token = $result.StdOut.TrimEnd("`r", "`n")
            if ([string]::IsNullOrEmpty($token)) { return $null }
            return $token
        }
        default {
            return $null
        }
    }
}

function Remove-JIMToken {
    <#
    .SYNOPSIS
        Removes persisted refresh token(s) from the OS credential store.
 
    .DESCRIPTION
        With -BaseUrl, removes the token for that instance. With -All, removes every
        JIM token. No-op when no credential store is available.
 
    .PARAMETER BaseUrl
        The JIM base URL whose token should be removed.
 
    .PARAMETER All
        Remove all persisted JIM tokens.
 
    .OUTPUTS
        [int] the number of tokens removed.
    #>

    # ShouldProcess is intentionally not implemented: this is a private helper, and
    # the user-facing destructive intent is expressed through Disconnect-JIM
    # (its default, -Url, or -All), which is where any confirmation would belong.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding(DefaultParameterSetName = 'Single')]
    [OutputType([int])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'Single')]
        [ValidateNotNullOrEmpty()]
        [string]$BaseUrl,

        [Parameter(Mandatory, ParameterSetName = 'All')]
        [switch]$All
    )

    $provider = Get-JIMTokenStoreProvider
    if ($provider -eq 'None') {
        Write-Verbose "No credential store available; nothing to remove"
        return 0
    }

    if ($All) {
        return Remove-JIMAllPersistedToken -Provider $provider
    }

    $key = Get-JIMTokenCacheKey -BaseUrl $BaseUrl

    switch ($provider) {
        'Windows' {
            Initialize-JIMWindowsCredentialStore
            $removed = [JIMCredentialStore]::Delete("$script:JIMTokenStoreTargetPrefix$key")
            return [int]([bool]$removed)
        }
        'MacOS' {
            $result = Invoke-JIMStoreProcess -FilePath 'security' -Arguments @(
                'delete-generic-password',
                '-a', $key,
                '-s', $script:JIMTokenStoreService
            )
            return [int]($result.ExitCode -eq 0)
        }
        'LibSecret' {
            $result = Invoke-JIMStoreProcess -FilePath 'secret-tool' -Arguments @(
                'clear',
                'service', $script:JIMTokenStoreService,
                'url', $key
            )
            return [int]($result.ExitCode -eq 0)
        }
    }
}

function Remove-JIMAllPersistedToken {
    <#
    .SYNOPSIS
        Removes every persisted JIM token for the given provider.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)]
        [string]$Provider
    )

    switch ($Provider) {
        'Windows' {
            Initialize-JIMWindowsCredentialStore
            $targets = [JIMCredentialStore]::List("$script:JIMTokenStoreTargetPrefix*")
            $count = 0
            foreach ($target in $targets) {
                if ([JIMCredentialStore]::Delete($target)) { $count++ }
            }
            return $count
        }
        'MacOS' {
            # 'security' has no wildcard delete; remove items for our service one at a
            # time until none remain (each delete removes a single matching item).
            $count = 0
            while ($true) {
                $result = Invoke-JIMStoreProcess -FilePath 'security' -Arguments @(
                    'delete-generic-password',
                    '-s', $script:JIMTokenStoreService
                )
                if ($result.ExitCode -ne 0) { break }
                $count++
            }
            return $count
        }
        'LibSecret' {
            # 'secret-tool clear' removes all items matching the given attributes, so
            # clearing by service alone removes every JIM token in one call.
            $result = Invoke-JIMStoreProcess -FilePath 'secret-tool' -Arguments @(
                'clear',
                'service', $script:JIMTokenStoreService
            )
            return [int]($result.ExitCode -eq 0)
        }
    }
}