Public/New-IdleAuthSessionBroker.ps1
|
function New-IdleAuthSessionBroker { <# .SYNOPSIS Creates a simple AuthSessionBroker for use with IdLE providers. .DESCRIPTION Creates an AuthSessionBroker that routes authentication based on user-defined options. The broker is used by steps to acquire credentials at runtime without embedding secrets in workflows or provider construction. This is a convenience function for common scenarios. For advanced scenarios (vault integration, MFA, etc.), implement a custom broker object with an AcquireAuthSession method. .PARAMETER SessionMap Optional hashtable that maps session configurations to auth sessions. Each key is a hashtable representing the AuthSessionOptions pattern, and each value is either: - A direct credential/token (when -AuthSessionType is provided) - A typed descriptor: @{ AuthSessionType = 'Credential'; Credential = $credential } Keys can include AuthSessionName for name-based routing: - @{ AuthSessionName = 'AD'; Role = 'ADAdm' } -> $admCred or @{ AuthSessionType = 'Credential'; Credential = $admAD } - @{ AuthSessionName = 'EXO' } -> $exoToken or @{ AuthSessionType = 'OAuth'; Credential = $exoToken } - @{ Server = 'AADConnect01' } -> $remoteSession - @{ Domain = 'SourceAD' } -> $sourceCred - @{ Environment = 'Production' } -> $prodCred SessionMap is optional if DefaultAuthSession is provided. .PARAMETER DefaultAuthSession Optional default auth session to return when no session options are provided or when the options don't match any entry in SessionMap. Can be a direct credential/token (when -AuthSessionType is provided) or a typed descriptor. At least one of SessionMap or DefaultAuthSession must be provided. .PARAMETER AuthSessionType Optional default authentication session type. When provided, allows simple (untyped) session values in SessionMap and DefaultAuthSession. When not provided, all values must be typed descriptors. Valid values: - 'OAuth': Token-based authentication (e.g., Microsoft Graph, Exchange Online) - 'PSRemoting': PowerShell remoting execution context (e.g., Entra Connect) - 'Credential': Credential-based authentication (e.g., Active Directory, mock providers) .EXAMPLE # Simple single-credential broker (with AuthSessionType) $broker = New-IdleAuthSessionBroker -DefaultAuthSession $admCred -AuthSessionType 'Credential' .EXAMPLE # AuthSessionName-based routing with roles (with AuthSessionType) $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ AuthSessionName = 'AD'; Role = 'ADAdm' } = $tier0Credential @{ AuthSessionName = 'AD'; Role = 'ADRead' } = $readOnlyCredential } -DefaultAuthSession $adminCredential -AuthSessionType 'Credential' .EXAMPLE # OAuth broker with token strings (with AuthSessionType) $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ Role = 'Admin' } = $graphToken } -DefaultAuthSession $graphToken -AuthSessionType 'OAuth' .EXAMPLE # Domain-based broker for multi-forest scenarios (with AuthSessionType) $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ Domain = 'SourceAD' } = $sourceCred @{ Domain = 'TargetAD' } = $targetCred } -AuthSessionType 'Credential' .EXAMPLE # PSRemoting broker for Entra Connect directory sync (with AuthSessionType) $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ Server = 'AADConnect01' } = $remoteSessionCred } -AuthSessionType 'PSRemoting' .EXAMPLE # Environment-based routing (with AuthSessionType) $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ Environment = 'Production' } = $prodCred @{ Environment = 'Test' } = $testCred } -DefaultAuthSession $devCred -AuthSessionType 'Credential' .EXAMPLE # Mixed-type broker for AD (Credential) + EXO (OAuth) - typed descriptors $broker = New-IdleAuthSessionBroker -SessionMap @{ @{ AuthSessionName = 'AD' } = @{ AuthSessionType = 'Credential'; Credential = $adCred } @{ AuthSessionName = 'EXO' } = @{ AuthSessionType = 'OAuth'; Credential = $exoToken } } .OUTPUTS PSCustomObject with AcquireAuthSession method #> [CmdletBinding()] param( [Parameter()] [AllowNull()] [AllowEmptyCollection()] [hashtable] $SessionMap, [Parameter()] [AllowNull()] [object] $DefaultAuthSession, [Parameter()] [ValidateSet('OAuth', 'PSRemoting', 'Credential')] [string] $AuthSessionType ) # Validate: If SessionMap is empty/null, DefaultAuthSession must be provided if (($null -eq $SessionMap -or $SessionMap.Count -eq 0) -and $null -eq $DefaultAuthSession) { throw "SessionMap is empty or null. DefaultAuthSession must be provided when SessionMap is not used." } # Helper function to detect if a value is a typed session descriptor $isTypedSession = { param($value) if ($null -eq $value) { return $false } # Only support hashtable format (not PSCustomObject) if ($value -is [hashtable]) { return ($value.ContainsKey('AuthSessionType') -and $value.ContainsKey('Credential')) } return $false } # Helper function to normalize session value to internal format $normalizeSessionValue = { param($value, $defaultType, $context) if ($null -eq $value) { return $null } # Check if value is typed if (& $isTypedSession $value) { $sessionType = $value.AuthSessionType $credential = $value.Credential # Validate the provided AuthSessionType if ($sessionType -notin @('OAuth', 'PSRemoting', 'Credential')) { throw "Invalid AuthSessionType '$sessionType' in $context. Valid values: 'OAuth', 'PSRemoting', 'Credential'." } return @{ AuthSessionType = $sessionType Credential = $credential } } # Untyped value - use default type if ([string]::IsNullOrEmpty($defaultType)) { throw "Untyped session value found in $context. Either provide -AuthSessionType or use typed format: @{ AuthSessionType = '<type>'; Credential = `$value }" } return @{ AuthSessionType = $defaultType Credential = $value } } # Normalize SessionMap entries $normalizedSessionMap = @{} if ($null -ne $SessionMap -and $SessionMap.Count -gt 0) { foreach ($entry in $SessionMap.GetEnumerator()) { $pattern = $entry.Key $value = $entry.Value # Validate SessionMap key type before using hashtable members if ($null -eq $pattern -or -not ($pattern -is [hashtable])) { $patternType = if ($null -eq $pattern) { 'null' } else { $pattern.GetType().FullName } throw [System.ArgumentException]::new( "Invalid SessionMap key type '$patternType'. SessionMap keys must be hashtables representing the AuthSessionOptions pattern.", 'SessionMap' ) } # Create a readable pattern description for error messages $patternDesc = ($pattern.Keys | ForEach-Object { "$_=$($pattern[$_])" }) -join ', ' $context = "SessionMap entry { $patternDesc }" $normalizedValue = & $normalizeSessionValue $value $AuthSessionType $context $normalizedSessionMap[$pattern] = $normalizedValue } } # Normalize DefaultAuthSession $normalizedDefaultAuthSession = $null if ($null -ne $DefaultAuthSession) { $normalizedDefaultAuthSession = & $normalizeSessionValue $DefaultAuthSession $AuthSessionType 'DefaultAuthSession' } $broker = [pscustomobject]@{ PSTypeName = 'IdLE.AuthSessionBroker' SessionMap = $normalizedSessionMap DefaultAuthSession = $normalizedDefaultAuthSession AuthSessionType = $AuthSessionType } $broker | Add-Member -MemberType ScriptMethod -Name AcquireAuthSession -Value { param( [Parameter(Mandatory)] [AllowEmptyString()] [string] $Name, [Parameter()] [AllowNull()] [hashtable] $Options ) # Empty string signals default session request if ([string]::IsNullOrEmpty($Name)) { if ($null -ne $this.DefaultAuthSession) { $normalized = $this.DefaultAuthSession # Validate type before returning Assert-IdleAuthSessionMatchesType -AuthSessionType $normalized.AuthSessionType -Session $normalized.Credential -SessionName '<default>' return $normalized.Credential } throw "No default auth session configured." } # If SessionMap is null or empty, return default if ($null -eq $this.SessionMap -or $this.SessionMap.Count -eq 0) { if ($null -ne $this.DefaultAuthSession) { $normalized = $this.DefaultAuthSession # Validate type before returning Assert-IdleAuthSessionMatchesType -AuthSessionType $normalized.AuthSessionType -Session $normalized.Credential -SessionName $Name return $normalized.Credential } throw "No SessionMap configured and no default auth session available." } # Matching logic: # 1. If Name provided: try to match entries with AuthSessionName key # 2. If Options provided: match all key/value pairs # 3. Fall back to DefaultAuthSession # 4. Fail with clear error $authSessionNameMatches = @() $legacyMatches = @() foreach ($entry in $this.SessionMap.GetEnumerator()) { $pattern = $entry.Key # Check if pattern includes AuthSessionName if ($pattern.ContainsKey('AuthSessionName')) { # AuthSessionName must match if ($pattern.AuthSessionName -ne $Name) { continue } # If pattern has ONLY AuthSessionName (no other keys) if ($pattern.Keys.Count -eq 1) { # Only match if Options is null or empty if ($null -eq $Options -or $Options.Count -eq 0) { $authSessionNameMatches += $entry } continue } # Pattern has additional keys beyond AuthSessionName # All other keys in pattern must match Options (if Options provided) $matches = $true foreach ($key in $pattern.Keys) { if ($key -eq 'AuthSessionName') { continue # Already checked } # If Options is null or doesn't contain the key, no match if ($null -eq $Options -or -not $Options.ContainsKey($key) -or $Options[$key] -ne $pattern[$key]) { $matches = $false break } } if ($matches) { $authSessionNameMatches += $entry } } else { # Legacy: pattern without AuthSessionName - match based on Options only if ($null -eq $Options -or $Options.Count -eq 0) { continue # No options to match } $matches = $true foreach ($key in $pattern.Keys) { if (-not $Options.ContainsKey($key) -or $Options[$key] -ne $pattern[$key]) { $matches = $false break } } if ($matches) { $legacyMatches += $entry } } } # Prioritize AuthSessionName-based matches over legacy matches $matchingEntries = @() if (@($authSessionNameMatches).Count -gt 0) { $matchingEntries = @($authSessionNameMatches) } else { $matchingEntries = @($legacyMatches) } # Return first match if exactly one found if ($matchingEntries.Count -eq 1) { $normalized = $matchingEntries[0].Value # Validate type before returning Assert-IdleAuthSessionMatchesType -AuthSessionType $normalized.AuthSessionType -Session $normalized.Credential -SessionName $Name return $normalized.Credential } # If multiple matches, this is ambiguous - fail with clear error if ($matchingEntries.Count -gt 1) { $matchDetails = ($matchingEntries | ForEach-Object { $currentEntry = $_ $keyStr = ($currentEntry.Key.Keys | ForEach-Object { "$_=$($currentEntry.Key[$_])" }) -join ', ' "{ $keyStr }" }) -join '; ' throw "Ambiguous auth session match for Name='$Name'. Multiple entries matched: $matchDetails. Provide AuthSessionOptions to disambiguate." } # No match found - fall back to default if ($null -ne $this.DefaultAuthSession) { $normalized = $this.DefaultAuthSession # Validate type before returning Assert-IdleAuthSessionMatchesType -AuthSessionType $normalized.AuthSessionType -Session $normalized.Credential -SessionName $Name return $normalized.Credential } # No match and no default $nameStr = "Name='$Name'" $optionsPart = if ($null -ne $Options -and $Options.Count -gt 0) { $optsStr = ($Options.Keys | ForEach-Object { "$_=$($Options[$_])" }) -join ', ' ", Options={ $optsStr }" } else { "" } throw "No matching auth session found for $nameStr$optionsPart and no default auth session configured." } -Force return $broker } |