OktaPS.psm1
## CLASSES ## class Actor { [ValidateNotNullOrEmpty()] [string]$id [string]$type [string]$alternateId [string]$displayName [hashtable]$detailEntry Actor([object]$hashtable) { $this.id = $hashtable.id $this.type = $hashtable.type $this.alternateId = $hashtable.alternateId $this.displayName = $hashtable.displayName $this.detailEntry = $hashtable.detailEntry } [string] ToString() { return $this.displayName } } class Target { [ValidateNotNullOrEmpty()] [string]$id [string]$type [string]$alternateId [string]$displayName [object]$detailEntry Target([object]$hashtable) { $this.id = $hashtable.id $this.type = $hashtable.type $this.alternateId = $hashtable.alternateId $this.displayName = $hashtable.displayName $this.detailEntry = $hashtable.detailEntry } [string] ToString() { return $this.displayName } } class UserAgent { [string]$browser [string]$os [string]$rawUserAgent UserAgent([object]$hashtable) { $this.browser = $hashtable.browser $this.os = $hashtable.os $this.rawUserAgent = $hashtable.rawUserAgent } } class Request { [IpAddress[]]$ipChain Request([object]$hashtable) { $this.ipChain = $hashtable.ipChain } } class Geolocation { [string]$lat [string]$lon Geolocation([object]$hashtable) { $this.lat = $hashtable.lat $this.lon = $hashtable.lon } } class GeographicalContext { [Geolocation]$geolocation [string]$city [string]$state [string]$country [string]$postalCode GeographicalContext([object]$hashtable) { $this.geolocation = $hashtable.geolocation $this.city = $hashtable.city $this.state = $hashtable.state $this.country = $hashtable.country $this.postalCode = $hashtable.postalCode } } class Client { [string]$id [UserAgent]$userAgent [GeographicalContext]$geographicalContext [string]$zone [string]$ipAddress [string]$device Client([object]$hashtable) { $this.id = $hashtable.id $this.userAgent = $hashtable.userAgent $this.geographicalContext = $hashtable.geographicalContext $this.zone = $hashtable.zone $this.ipAddress = $hashtable.ipAddress $this.device = $hashtable.device } } class Outcome { [string]$result [string]$reason Outcome([object]$hashtable) { $this.result = $hashtable.result $this.reason = $hashtable.reason } [string] ToString() { return $this.result } } class Transaction { [string]$id [string]$type [hashtable]$detail Transaction([object]$hashtable) { $this.id = $hashtable.id $this.type = $hashtable.type $this.detail = $hashtable.detail } } class DebugContext { [hashtable]$debugData DebugContext([object]$hashtable) { $this.debugData = $hashtable.debugData } } class Issuer { [string]$id [string]$type Issuer([object]$hashtable) { $this.id = $hashtable.id $this.type = $hashtable.type } } class AuthenticationContext { [string]$authenticationProvider [int]$authenticationStep [string]$credentialProvider [string]$credentialType [Issuer]$issuer [string]$externalSessionId [string]$interface AuthenticationContext([object]$hashtable) { $this.authenticationProvider = $hashtable.authenticationProvider $this.authenticationStep = $hashtable.authenticationStep $this.credentialProvider = $hashtable.credentialProvider $this.credentialType = $hashtable.credentialType $this.issuer = $hashtable.issuer $this.externalSessionId = $hashtable.externalSessionId $this.interface = $hashtable.interface } } class SecurityContext { [int]$asNumber [string]$asOrg [string]$isp [string]$domain [bool]$isProxy SecurityContext([object]$hashtable) { $this.asNumber = $hashtable.asNumber $this.asOrg = $hashtable.asOrg $this.isp = $hashtable.isp $this.domain = $hashtable.domain $this.isProxy = $hashtable.isProxy } } class IpAddress { [string]$ip [GeographicalContext]$geographicalContext [string]$version [string]$source IpAddress([object]$hashtable) { $this.ip = $hashtable.ip $this.geographicalContext = $hashtable.geographicalContext $this.version = $hashtable.version $this.source = $hashtable.source } } class LogEvent { [string]$uuid [datetime]$published [string]$eventType [string]$version [string]$severity [string]$legacyEventType [string]$displayMessage [Actor]$actor [Client]$client [Request]$request [Outcome]$outcome [Target[]]$target [Transaction]$transaction [DebugContext]$debugContext [AuthenticationContext]$authenticationContext [SecurityContext]$securityContext LogEvent([object]$hashtable) { $this.uuid = $hashtable.uuid $this.published = $hashtable.published $this.eventType = $hashtable.eventType $this.version = $hashtable.version $this.severity = $hashtable.severity $this.legacyEventType = $hashtable.legacyEventType $this.displayMessage = $hashtable.displayMessage $this.actor = $hashtable.actor $this.target = $hashtable.target $this.client = $hashtable.client $this.request = $hashtable.request $this.outcome = $hashtable.outcome $this.transaction = $hashtable.transaction $this.debugContext = $hashtable.debugContext $this.authenticationContext = $hashtable.authenticationContext $this.securityContext = $hashtable.securityContext } } class OktaDateTime { [DateTime] $Value OktaDateTime([DateTime] $value) { $this.Value = $value } static [OktaDateTime] Parse([string] $value) { return [OktaDateTime]::new([DateTime]::Parse($value)) } [string] ToString() { return $this.Value.ToString() } } enum GroupType { OKTA_GROUP APP_GROUP BUILT_IN } class OktaGroup { [hashtable] $_embedded [hashtable] $_links [DateTime] $created [ValidateNotNullOrEmpty()] [string] $id [DateTime] $lastMembershipUpdated [DateTime] $lastUpdated [string[]] $objectClass [PSCustomObject] $profile [GroupType] $type [string[]] $source OktaUser([string]$name) { Get-OktaGroup -Name $name -ErrorAction Stop } OktaGroup([object]$hashtable) { # $this._embedded = $hashtable._embedded # $this._links = $hashtable._links $this.created = $hashtable.created ?? [DateTime]::MinValue $this.id = $hashtable.id $this.lastMembershipUpdated = $hashtable.lastMembershipUpdated ?? [DateTime]::MinValue $this.lastUpdated = $hashtable.lastUpdated ?? [DateTime]::MinValue $this.objectClass = $hashtable.objectClass $this.profile = $hashtable.profile $this.type = $hashtable.type ?? "OKTA_GROUP" $this.source = $hashtable.source.id } } Class OktaUser { [ValidateNotNullorEmpty()] [string] $id [string] $status [string] $firstName [string] $lastName [string] $login [string] $enabled [datetime] $created [datetime] $activated [datetime] $statusChanged [datetime] $lastLogin [datetime] $lastUpdated [datetime] $passwordChanged [string] $type # [OktaGroup] $_groups [string] $_links [hashtable] $_profile OktaUser([string]$identity) { $that = Get-OktaUser -Identity $identity -ErrorAction Stop foreach($key in $that.psobject.properties.name) { try { $this.$key = $that.$key } catch { $this | Add-Member -NotePropertyName $key -NotePropertyValue $that.$key } } } OktaUser( [string]$id, [string]$status, [string]$firstName, [string]$lastName, [string]$login, [datetime]$created, [datetime]$activated, [datetime]$statusChanged, [datetime]$lastLogin, [datetime]$lastUpdated, [datetime]$passwdChanged # [string]$type ){ $this.id = $id $this.status = $status $this.firstName = $firstName $this.lastName = $lastName $this.login = $login $this.enabled = $status -in @("STAGED", "PROVISIONED", "ACTIVE", "RECOVERY", "PASSWORD_EXPIRED", "LOCKED_OUT", "SUSPENDED") ? $True : $False $this.created = $created $this.activated = $activated $this.statusChanged = $statusChanged $this.lastLogin = $lastLogin $this.lastUpdated = $lastUpdated $this.passwordChanged = $passwdChanged # $this.type = $type } } ## PRIVATE MODULE FUNCTIONS AND DATA ## Function Clear-OktaAuthentication { Remove-Variable -Scope Script -Name "Okta*" } Function Connect-OktaAPI { [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $OktaDomain, # Parameter help description [Parameter(Mandatory)] [String] $API ) # use IWR to create a web session variable $null = Invoke-WebRequest -Uri $OktaDomain -SessionVariable OktaSSO $OktaSSO.Headers.Add("Authorization", "SSWS $API") # test API key is valid Try { $null = Invoke-WebRequest -Uri "$OktaDomain/api/v1/users/me" -Method "GET" -WebSession $OktaSSO } Catch { throw } Set-OktaAuthentication -AuthorizationMode "SSWS" -Session $OktaSSO -Domain $OktaDomain Return } Function Connect-OktaCredential { [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $OktaDomain, # Parameter help description [Parameter(Mandatory)] [System.Management.Automation.PSCredential] $Credential ) $okta_authn = Invoke-RestMethod -Uri "$OktaDomain/api/v1/authn" -Method "POST" -Body (@{ "username" = $Credential.GetNetworkCredential().username "password" = $Credential.GetNetworkCredential().password "option" = @{ "multiOptionalFactorEnroll" = "false" "warnBeforePasswordExpired" = "false" } } | ConvertTo-Json) -ContentType "application/json" -SessionVariable OktaSSO switch ($okta_authn.status) { SUCCESS { $session_token = $okta_authn.sessionToken } MFA_REQUIRED { Write-Host "MFA is required" # if more than one mfa, prompt, or select the first one # https://developer.okta.com/docs/reference/api/factors/#supported-factors-for-providers $factorList = $okta_authn._embedded.factors If($factorList.count -gt 1) { $availableFactors = $factorList | ForEach-Object { $_.provider.ToLower() + "::" + $_.factorType } $chosenFactorIndex = Read-OktaFactorPrompt -AvailableFactors $availableFactors } else { $chosenFactorIndex = 0 } $chosenFactor = $factorList[$chosenFactorIndex].provider.ToLower() + "::" + $factorList[$chosenFactorIndex].factorType switch ($chosenFactor) { "okta::push" { $session_token = Send-OktaFactorProviderOkta -VerifyUrl $factorList[$chosenFactorIndex]._links.verify.href -StateToken $okta_authn.stateToken } "okta::token:software:totp" { $totp = Read-Host "Enter code from Okta Verify app" $session_token = Send-OktaFactorProviderOkta -VerifyUrl $factorList[$chosenFactorIndex]._links.verify.href -StateToken $okta_authn.stateToken -Passcode $totp } "duo::web" { $session_token = Send-OktaFactorProviderDuo -VerifyUrl $factorList[$chosenFactorIndex]._links.verify.href -StateToken $okta_authn.stateToken } Default { Write-Error "Unknown factor type: $factorType" Return } } } Default { Write-Host "Authentication failed, unknown error." Return $okta.status } } # Trade session token for a session cookie $null = Invoke-WebRequest -Method "GET" -Uri "$OktaDomain/login/sessionCookieRedirect?token=$session_token&redirectUrl=$OktaDomain" -SkipHttpErrorCheck -WebSession $OktaSSO -MaximumRedirection 5 $session = Invoke-RestMethod -Method "GET" -Uri "$OktaDomain/api/v1/sessions/me" -WebSession $OktaSSO # Get an XSRF Token # $OktaAdminDomain = Get-OktaAdminDomain -Domain $OktaDomain $dashboard = Invoke-WebRequest -Method "GET" -Uri "$OktaAdminDomain/admin/dashboard" -WebSession $OktaSSO If($dashboard.content -match '(?:id="_xsrfToken".*?>)(?<xsrfToken>.*?)(?:<)') { If($Matches.xsrfToken.Length -gt 0) { $Script:OktaXSRF = $Matches.xsrfToken } else { Write-Warning "XSRF token length is 0. Some Okta endpoints might not be available." } } else { Write-Warning "Unable to get XSRF token. Some Okta endpoints might not be available." } Set-OktaAuthentication -AuthorizationMode "Credential" -Session $OktaSSO -Domain $OktaDomain -ExpiresAt $session.expiresAt -Username $Credential.GetNetworkCredential().username } Function Connect-OktaPrivateKey { [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $OktaDomain, # Parameter help description [Parameter(Mandatory)] [String] $ClientId, # Parameter help description [Parameter(Mandatory)] [String[]] $Scopes, # Parameter help description [Parameter(Mandatory)] [String] $PrivateKey ) $OAuthUrl = "$OktaDomain/oauth2/v1/token" $payload = @{ "aud" = $OAuthUrl "iss" = $ClientId "sub" = $ClientId } $jwt = New-JsonWebToken -Claims $payload -HashAlgorithm SHA256 -PrivateKey $PrivateKey # Get an limited lifetime access token $auth = Invoke-RestMethod -Method "POST" -Uri $OAuthUrl -Body @{ "grant_type" = "client_credentials" "scope" = $Scopes -join " " "client_assertion_type" = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" "client_assertion" = $jwt } # use IWR to create a web session variable $null = Invoke-WebRequest -Uri $OktaDomain -SessionVariable OktaSSO $OktaSSO.Headers.Add("Authorization", "$($auth.token_type) $($auth.access_token)") Set-OktaAuthentication -AuthorizationMode "PrivateKey" -Session $OktaSSO -Domain $OktaDomain -ExpiresIn $auth.expires_in Return } Function ConvertTo-OktaActor { [CmdletBinding()] param ( [Parameter()] [Object] $Actor ) $Actor | ForEach-Object { If($null -eq $_) { return } [Actor]@{ id = $_.id type = $_.type alternateId = $_.alternateId displayName = $_.displayName detailEntry = $_.detailEntry } } } Function ConvertTo-OktaApp { [CmdletBinding()] param ( [Parameter()] [PSCustomObject[]] $InputObject ) $InputObject | ForEach-Object { Add-Member -InputObject $_ -TypeName "Okta.App" } Return $InputObject } Function ConvertTo-OktaAppUser { # Takes Okta User API response and formats it in Okta.AppUser object. Pulls profile values up to top level. [CmdletBinding()] param ( [Parameter()] [PSCustomObject[]] $InputObject ) $OktaAppUserCollection = @(foreach($OktaAppUser in $InputObject) { $OktaAppUserObject = [PSCustomObject]@{ PSTypeName = 'Okta.AppUser' id = $OktaAppUser.id externalId = $OktaAppUser.externalId created = $OktaAppUser.created lastUpdated = $OktaAppUser.lastUpdated scope = $OktaAppUser.scope status = $OktaAppUser.status statusChanged = $OktaAppUser.statusChanged passwordChanged = $OktaAppUser.passwordChanged syncstate = $OktaAppuser.syncState username = $OktaAppUser.credentials.userName _links = $OktaAppUser._links } $properties = $OktaAppUser.psobject.properties.name $appProfile = $OktaAppUser.profile.psobject.properties.name # pull profile attributes up one level $appProfile | ForEach-Object { # Append profile_ to attribute name if exists in properties If($_ -in $properties) { $_ = "profile_$($_)" } $OktaAppUserObject | Add-Member -MemberType NoteProperty -Name $_ -Value $OktaAppUser.profile.$_ } $OktaAppUserObject }) Return $OktaAppUserCollection } Function ConvertTo-OktaGroup { [CmdletBinding()] param ( [Parameter()] [Object[]] $InputObject ) Process { foreach($OktaGroup in $InputObject) { try { $OktaGroupObject = [OktaGroup]$OktaGroup } catch { throw [System.ArgumentException] "Invalid input object. Could not create OktaGroup object." } # pull profile values to top-level for convenience # if profile attr collides with exisitng OktaGroup property, prepend "profile_" before the attr $groupProfile = @{} $properties = $OktaGroupObject.psobject.properties.name $attributes = $OktaGroup.profile.psobject.properties $attributes | Foreach-Object { If($_.Name -in $properties) { $groupProfile["profile_$($_.Name)"] = $_.Value } else { $groupProfile[$_.Name] = $_.Value } } # -NotePropertyMembers faster than individually looping -NotePropertyName $OktaGroupObject | Add-Member -NotePropertyMembers $groupProfile $OktaGroupObject } } } Function ConvertTo-OktaLogEvent { [CmdletBinding()] param ( [Parameter()] [Object[]] $LogEvent ) $LogEvent | ForEach-Object { If($null -eq $_) { return } $actor = ConvertTo-OktaActor $_.actor $target = ConvertTo-OktaTarget $_.target [LogEvent]@{ uuid = $_.uuid published = $_.published.ToLocalTime() eventType = $_.eventType version = $_.version severity = $_.severity legacyEventType = $_.legacyEventType displayMessage = $_.displayMessage actor = $actor target = @($target) client = [Client]$_.client request = [Request]$_.request outcome = [Outcome]$_.outcome # transaction = [Transaction]$_.transaction # debugContext = [DebugContext]$_.debugContext authenticationContext = [AuthenticationContext]$_.authenticationContext securityContext = [SecurityContext]$_.securityContext } } } Function ConvertTo-OktaTarget { [CmdletBinding()] param ( [Parameter()] [Object] $Target ) $Target | ForEach-Object { If($null -eq $_) { return } [Target]@{ id = $_.id type = $_.type alternateId = $_.alternateId displayName = $_.displayName detailEntry = $_.detailEntry } } } Function ConvertTo-OktaUser { # Takes Okta User API response and formats it in OktaUser object. Pulls profile values up to top level. [CmdletBinding()] param ( [Parameter(ValueFromPipeline, Mandatory)] [PSCustomObject[]] $InputObject ) Process { foreach($OktaUser in $InputObject) { try { $OktaUserObject = [OktaUser]::new( $OktaUser.id, $OktaUser.status, $OktaUser.profile.firstName, $OktaUser.profile.lastName, $OktaUser.profile.login, ($OktaUser.created ?? [DateTime]::MinValue), ($OktaUser.activated ?? [DateTime]::MinValue), ($OktaUser.statusChanged ?? [DateTime]::MinValue), ($OktaUser.lastLogin ?? [DateTime]::MinValue), ($OktaUser.lastUpdated ?? [DateTime]::MinValue), ($OktaUser.passwordChanged ?? [DateTime]::MinValue) # $OktaUser.type ) } catch { throw [System.ArgumentException] "Invalid input object. Could not create OktaUser object." } # pull profile values to top-level for convinience # if profile attr collides with exisitng OktaUser property, prepend "profile_" before the attr # raw profile attr are also set in _profile $userProfile = @{} $properties = $OktaUserObject.psobject.properties.name $attributes = $OktaUser.profile.psobject.properties | Where-Object { $_.name -notin @("firstName", "lastName", "login")} $attributes| Foreach-Object { If($_.Name -in $properties) { $userProfile["profile_$($_.Name)"] = $_.Value } else { $userProfile[$_.Name] = $_.Value } } # -NotePropertyMembers faster than individually looping -NotePropertyName $OktaUserObject | Add-Member -NotePropertyMembers $userProfile $OktaUserObject } } } Function Get-OktaAdminDomain { [CmdletBinding()] param ( [Parameter()] [String] $Domain ) $Uri = [System.Uri]$Domain $domainParts = $Uri.Host.Split('.') $domainParts[0] = $domainParts[0] + "-admin" $OktaAdminDomain = $domainParts -join '.' Return "https://$OktaAdminDomain" } Function Get-OktaConfig { [CmdletBinding()] param ( [Parameter()] [ArgumentCompleter({ OktaConfigPathArgumentCompleter @args })] [String] $Path ) # If a path is provided, use it If(-not [String]::IsNullOrEmpty($Path)) { If(Test-Path $Path) { Return $Path } # if the path is absolute, no use in searching for it If([System.IO.Path]::IsPathRooted($Path)) { Write-Error "Could not find Okta config file at $Path" Return } } $config = OktaConfigPathArgumentCompleter -wordToComplete $Path If($config.Count -eq 0) { Write-Error "Could not find Okta config file" Return } Return $config[0].FullName } Function Get-OktaPrivateVariables { Get-Variable -Name "Okta*" -Scope Script } Function Invoke-OktaRequest { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [String] $Method = "GET", [Parameter(Mandatory)] [String] $Endpoint, [Parameter()] [Hashtable] $Headers, [Parameter()] [Hashtable] $Query, [Parameter()] [Hashtable] $Body, [Parameter()] [Switch] $PassThru, [Parameter()] [Switch] $NoPagination ) $webrequest_parameters = @{} $built_headers = @{} # Check cache for valid session cookies and expiration If($Script:OktaSSO) { If(-not (Test-OktaAuthentication)) { Update-OktaAuthentication } } else { Connect-Okta } $webrequest_parameters['WebSession'] = $Script:OktaSSO If($Script:OktaXSRF) { $built_headers['X-Okta-XsrfToken'] = $Script:OktaXSRF } # Query parameters If($Query) { $url_builder = @{} Foreach($k in $Query.Keys) { $url_builder[$k] = $Query[$k] } $querystring = New-HttpQueryString -QueryParameter $url_builder $Endpoint = $Endpoint + "?" + $querystring } # Body If($Body) { $built_headers['Accept'] = "application/json" $built_headers['Content-Type'] = "application/json" $webrequest_parameters['Body'] = $Body | ConvertTo-Json -Depth 99 } # Build request headers Foreach($k in $Headers.Keys) { $built_headers[$k] = $Headers[$k] Write-Debug "Adding header to request ${k}: $Headers[$k]" } # Request # TODO: Add ability to send request to OktaDomain or OktaAdminDomain (default) $request_uri = "$Script:OktaAdminDomain/$Endpoint" if ($PSCmdlet.ShouldProcess($request_uri)) { # supports pagination $next = $True $return = while($next) { $Script:OktaDebugLastRequestUri = $request_uri try { $response = Invoke-WebRequest -Method $Method -Uri $request_uri -Headers $built_headers -SkipHeaderValidation @webrequest_parameters } catch [Microsoft.PowerShell.Commands.HttpResponseException] { If($_.Exception.Response.StatusCode -eq 429) { Write-Debug "X-Rate-Limit-Limit: $($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Limit'))" Write-Debug "X-Rate-Limit-Remaining: $($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Remaining'))" Write-Debug "X-Rate-Limit-Reset: $($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Reset'))" $limit_reset = [System.DateTimeOffset]::FromUnixTimeSeconds($_.Exception.Response.Headers.GetValues('X-Rate-Limit-Reset')[0]) $offset = $limit_reset.Offset.TotalSeconds Write-Host "Okta Rate Limit Exceeded. $($limit_reset.LocalDateTime.ToString())" # TODO: wait until time elapses and continue Start-Sleep -Seconds $offset } } catch { Write-Host "Unknown error occurred." Throw $_ } # Response If($PassThru) { $response } elseif(($response.StatusCode -ge 200) -and ($response.StatusCode -le 299)) { $response.Content | ConvertFrom-Json } else { # uncaught status code, return the raw and exit Return $response } # pagination If($response.RelationLink.ContainsKey('next') -and ($NoPagination -eq $False)) { $request_uri = $response.RelationLink['next'] } else { $next = $False } } } Return $return } function New-HttpQueryString { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] [Hashtable] $QueryParameter ) $join = @( foreach($k in $QueryParameter.Keys) { # https://stackoverflow.com/questions/46336763/c-sharp-net-how-to-encode-url-space-with-20-instead-of $fv_pair = "{0}={1}" -f $k, [System.Uri]::EscapeDataString($QueryParameter[$k]) $fv_pair } ) $querystring = $join -join '&' return $querystring } # Copyright 2020 Anthony Guimelli # Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: # The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # # Adapted from https://github.com/anthonyg-1/PSJsonWebToken # Removed unused parameters and added only private key signing function New-JsonWebToken { <# .SYNOPSIS Generates a JSON Web Token. .DESCRIPTION Generates a signed JSON Web Token (JWS) with options for specifying a JWK URI in the header. .PARAMETER Claims The claims for the token expressed as a hash table. .PARAMETER HashAlgorithm The hash algorthim for the signature. Acceptable values are SHA256, SHA384, and SHA512. Default value is SHA256. .PARAMETER PrivateKey The private key to use for signing the token. .OUTPUTS System.String The JSON Web Token is returned as a base64 URL encoded string. .LINK https://tools.ietf.org/html/rfc7519 https://tools.ietf.org/html/rfc7515 https://tools.ietf.org/html/rfc7517 #> [CmdletBinding()] [OutputType([System.String])] Param ( [Parameter(Mandatory = $true, Position = 0)] [ValidateNotNullOrEmpty()] [System.Collections.Hashtable]$Claims, [Parameter(Mandatory = $false, Position = 1)] [ValidateSet("SHA256", "SHA384", "SHA512")] [String]$HashAlgorithm = "SHA256", [Parameter(Mandatory = $true, Position = 2)] [ValidateNotNullOrEmpty()] [String]$PrivateKey ) PROCESS { [string]$jwt = "" #1. Construct header: $rsaAlg = @{ "SHA256" = "RS256" "SHA384" = "RS384" "SHA512" = "RS512" } $header = [ordered]@{typ = "JWT"; alg = $rsaAlg[$HashAlgorithm] ?? "RS256"} | ConvertTo-JwtPart #2. Construct payload for RSA: $payload = New-JwtPayloadString -Claims $Claims #3. Concatenate encoded header and payload seperated by a full stop: $jwtSansSig = "{0}.{1}" -f $header, $payload #4. Generate signature for concatenated header and payload: [string]$rsaSig = "" try { $rsaSig = New-JwtRsaSignature -JsonWebToken $jwtSansSig -HashAlgorithm $HashAlgorithm -PrivateKey $PrivateKey # $rsaSig = New-JwtSignature -JsonWebToken $jwtSansSig -HashAlgorithm $HashAlgorithm -SigningCertificate $SigningCertificate } catch { $cryptographicExceptionMessage = $_.Exception.Message $CryptographicException = New-Object -TypeName System.Security.Cryptography.CryptographicException -ArgumentList $cryptographicExceptionMessage Write-Error -Exception $CryptographicException -Category SecurityError -ErrorAction Stop # Write-Error -Exception $_.Exception -Category InvalidArgument -ErrorAction Stop } #5. Construct jws: $jwt = "{0}.{1}" -f $jwtSansSig, $rsaSig return $jwt } } function New-JwtPayloadString { [CmdletBinding()] [OutputType([System.String])] Param ( [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, Position=0)][HashTable]$Claims, [Parameter(Mandatory=$false,Position=1)] [ValidateRange(1,300)] [System.Int32]$NotBeforeSkew ) PROCESS { [string]$payload = "" $_claims = [ordered]@{} $now = Get-Date $currentEpochTime = Convert-DateTimeToEpoch -DateTime $now $notBefore = $currentEpochTime if ($PSBoundParameters.ContainsKey("NotBeforeSkew")) { $notBefore = Convert-DateTimeToEpoch -DateTime ($now.AddSeconds(-$NotBeforeSkew)) } $futureEpochTime = Convert-DateTimeToEpoch -DateTime ($now.AddSeconds($TimeToLive)) $_claims.Add("iat", $currentEpochTime) $_claims.Add("nbf", $notBefore) $_claims.Add("exp", $futureEpochTime) foreach ($entry in $Claims.GetEnumerator()) { if (-not($_claims.Contains($entry.Key))) { $_claims.Add($entry.Key, $entry.Value) } } $payload = $_claims | ConvertTo-JwtPart return $payload } } function Convert-DateTimeToEpoch { <# .SYNOPSIS Converts a System.DateTime to an epoch (unix) time stamp. .EXAMPLE Convert-DateTimeToEpoch Returns the current datetime as epoch. .EXAMPLE $iat = Convert-DateTimeToEpoch $nbf = (Get-Date).AddMinutes(-3) | Convert-DateTimeToEpoch $exp = (Get-Date).AddMinutes(10) | Convert-DateTimeToEpoch $jwtPayload = @{sub="username@domain.com";iat=$iat;nbf=$nbf;exp=$exp} $jwtPayloadSerializedAndEncoded = $jwtPayload | ConvertTo-JwtPart Generates JWT payload with an iat claim of the current datetime, an nbf claim skewed three minutes in the past, and an expiration of ten minutes in the future from the current datetime. .PARAMETER DateTime A System.DateTime. Default value is current date and time. .INPUTS System.DateTime .OUTPUTS System.Int64 .LINK https://en.wikipedia.org/wiki/Unix_time ConvertTo-JwtPart #> [CmdletBinding()] [Alias('GetEpoch')] [OutputType([System.Int64])] Param ( [Parameter(Mandatory=$false, ValueFromPipeline=$true, Position=0)][ValidateNotNullOrEmpty()][Alias("Date")][DateTime]$DateTime=(Get-Date) ) PROCESS { $dtut = $DateTime.ToUniversalTime() [TimeSpan]$ts = New-TimeSpan -Start (Get-Date "01/01/1970") -End $dtut [Int64]$secondsSinceEpoch = [Math]::Floor($ts.TotalSeconds) return $secondsSinceEpoch } } function ConvertTo-JwtPart { <# .SYNOPSIS Converts an object to a base 64 URL encoded compressed JSON string. .DESCRIPTION Converts an object to a base 64 URL encoded compressed JSON string. Useful when constructing a JWT header or payload from a InputObject prior to serialization. .PARAMETER InputObject Specifies the object to convert to a JWT part. Enter a variable that contains the object, or type a command or expression that gets the objects. You can also pipe an object to ConvertTo-JwtPart. .EXAMPLE $jwtHeader = @{typ="JWT";alg="HS256"} $encodedHeader = $jwtHeader | ConvertTo-JwtPart Constructs a JWT header from the hashtable defined in the $jwtHeader variable, serializes it to JSON, and base 64 URL encodes it. .EXAMPLE $header = @{typ="JWT";alg="HS256"} $payload = @{sub="someone.else@company.com";title="person"} $encodedHeader = $header | ConvertTo-JwtPart $encodedPayload = $payload | ConvertTo-JwtPart $jwtSansSignature = "{0}.{1}" -f $encodedHeader, $encodedPayload $hmacSignature = New-JwtHmacSignature -JsonWebToken $jwtSansSignature -Key "secret" $jwt = "{0}.{1}" -f $jwtSansSignature, $hmacSignature Constructs a header and payload from InputObjects, serializes and encodes them and obtains an HMAC signature from the resulting joined values. .INPUTS System.Object .OUTPUTS System.String .LINK New-JwtHmacSignature New-JsonWebToken Test-JsonWebToken #> [CmdletBinding()] [OutputType([System.String])] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true, Position = 0)] [ValidateNotNullOrEmpty()][System.Object]$InputObject ) BEGIN { $argumentExceptionMessage = "Unable to serialize and base64 URL encode passed InputObject." $ArgumentException = New-Object -TypeName ArgumentException -ArgumentList $argumentExceptionMessage } PROCESS { [string]$base64UrlEncodedString = "" try { $base64UrlEncodedString = $InputObject | ConvertTo-Json -Depth 25 -Compress | ConvertTo-Base64UrlEncodedString } catch { Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop } return $base64UrlEncodedString } } Function New-JwtRsaSignature { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory=$true,ValueFromPipeline=$false,Position=0)] [ValidateLength(16,131072)][Alias("JWT", "Token")][String]$JsonWebToken, [Parameter(Position=1,Mandatory=$true)] [ValidateSet("SHA256","SHA384","SHA512")] [String]$HashAlgorithm, [Parameter(Mandatory=$true,Position=2)] [String]$PrivateKey ) BEGIN { $decodeExceptionMessage = "Unable to decode JWT." $ArgumentException = New-Object -TypeName ArgumentException -ArgumentList $decodeExceptionMessage } PROCESS { [string]$stringSig = "" # Test JWT structure: [bool]$isValidJwt = Test-JwtStructure -JsonWebToken $JsonWebToken if (-not($isValidJwt)) { Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop } # JWT should only have 2 parts right now: if (($JsonWebToken.Split(".").Count) -ne 2) { Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop } # Create an instance of the RSAPKCS1SignatureFormatter class that will ultimately be used to generate the signature: $rsaSigFormatter = [System.Security.Cryptography.RSAPKCS1SignatureFormatter]::new() # Create an instance of the RSACryptoServiceProvider class that will be used to import the private key in a usable format: $rsaProvider = [System.Security.Cryptography.RSACryptoServiceProvider]::new() # Split the private key into blocks and convert the second block to a byte array: $privateKeyBlocks = $privateKey.Split("-", [System.StringSplitOptions]::RemoveEmptyEntries) $privateKeyBytes = [System.Convert]::FromBase64String($privateKeyBlocks[1]) $rsaProvider.ImportPkcs8PrivateKey($privateKeyBytes, [ref] $null) # Set the RSA key to use for signing: $rsaSigFormatter.SetKey($rsaProvider) # Set the RSA hash algorithm based on the RsaHashAlgorithm passed: $rsaSigFormatter.SetHashAlgorithm($HashAlgorithm.ToString()) # Convert the incoming string $JsonWebToken into a byte array: [byte[]]$message = [System.Text.Encoding]::UTF8.GetBytes($JsonWebToken) # The byte array that will contain the resulting hash to be signed: [byte[]]$messageDigest = $null # Create a SHA256, SHA384 or SHA512 hash and assign it to the messageDigest variable: switch ($HashAlgorithm) { "SHA256" { $shaAlg = [System.Security.Cryptography.SHA256]::Create() $messageDigest = $shaAlg.ComputeHash($message) break } "SHA384" { $shaAlg = [System.Security.Cryptography.SHA384]::Create() $messageDigest = $shaAlg.ComputeHash($message) break } "SHA512" { $shaAlg = [System.Security.Cryptography.SHA512]::Create() $messageDigest = $shaAlg.ComputeHash($message) break } default { $shaAlg = [System.Security.Cryptography.SHA512]::Create() $messageDigest = $shaAlg.ComputeHash($message) break } } # Create the signature: [byte[]]$sigBytes = $null try { $sigBytes = $rsaSigFormatter.CreateSignature($messageDigest) } catch { $signingErrorMessage = "Unable to sign $JsonWebToken with certificate with thumbprint {0}. Ensure that CSP for this certificate is 'Microsoft Enhanced RSA and AES Cryptographic Provider' and try again. Additional error info: {1}" -f $thumbprint, $_.Exception.Message Write-Error -Exception ([CryptographicException]::new($signingErrorMessage)) -Category SecurityError -ErrorAction Stop } # Return the Base64 URL encoded signature: $stringSig = ConvertTo-Base64UrlEncodedString -Bytes $sigBytes return $stringSig } } function Test-JwtStructure { <# .SYNOPSIS Tests a JWT for structural validity. .DESCRIPTION Validates that a JSON Web Token is structurally valid by returing a boolean indicating if the passed JWT is valid or not. .PARAMETER JsonWebToken Contains the JWT to structurally validate. .PARAMETER VerifySignaturePresent Determines if the passed JWT has three parts (signature being the third). .EXAMPLE $jwtSansSig = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEyMzQ1Njc4OTAsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlfQ" Test-JwtStructure -JsonWebToken $jwtSansSig Validates the structure of a JWT without a signature. .EXAMPLE $jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.VG6H-orYnMLknmJajHx1HW9SftqCWeqE3TQ1UArx3Mk" Test-JwtStructure -JsonWebToken $jwt Validates the structure of a JWT with a signature. .NOTES By default a passed JWT's header and payload should base 64 URL decoded JSON. The VerifySignaturePresent switch ensures that all three parts exist seperated by a full-stop (header, payload, signature). .OUTPUTS System.Boolean .LINK https://tools.ietf.org/html/rfc7519 https://en.wikipedia.org/wiki/RSA_(cryptosystem) https://en.wikipedia.org/wiki/HMAC #> [CmdletBinding()] [OutputType([System.Boolean])] Param ( [ Parameter(Mandatory = $true, ValueFromPipeline = $false, Position = 0)] [ValidateLength(16, 131072)] [System.String]$JsonWebToken, [Parameter(Mandatory = $false, ValueFromPipeline = $false, Position = 1)] [Switch]$VerifySignaturePresent ) PROCESS { $arrayCellCount = $JsonWebToken.Split(".") | Measure-Object | Select-Object -ExpandProperty Count if ($PSBoundParameters.ContainsKey("VerifySignaturePresent")) { if ($arrayCellCount -lt 3) { return $false } else { $jwtSignature = $JsonWebToken.Split(".")[2] if ($jwtSignature.Length -le 8) { return $false } } } else { if ($arrayCellCount -lt 2) { return $false } } # Test deserialization against header: $jwtHeader = $JsonWebToken.Split(".")[0] if ($jwtHeader.Length -le 8) { return $false } [string]$jwtHeaderDecoded = "" try { $jwtHeaderDecoded = $jwtHeader | ConvertFrom-Base64UrlEncodedString } catch { return $false } $jwtHeaderDeserialized = $null try { $jwtHeaderDeserialized = $jwtHeaderDecoded | ConvertFrom-Json -ErrorAction Stop } catch { return $false } # Per RFC 7515 section 4.1.1, alg is the only required parameter in a JWT header: if ($null -eq $jwtHeaderDeserialized.alg) { return $false } # Test deserialization against payload: $jwtPayload = $JsonWebToken.Split(".")[1] if ($jwtPayload.Length -le 8) { return $false } [string]$jwtPayloadDecoded = "" try { $jwtPayloadDecoded = $jwtPayload | ConvertFrom-Base64UrlEncodedString } catch { return $false } try { $jwtPayloadDecoded | ConvertFrom-Json -ErrorAction Stop | Out-Null } catch { return $false } return $true } } function ConvertFrom-Base64UrlEncodedString { <# .SYNOPSIS Decodes a base 64 URL encoded string. .DESCRIPTION Decodes a base 64 URL encoded string such as a JWT header or payload. .PARAMETER InputString The string to be base64 URL decoded. .PARAMETER AsBytes Instructions this function to return the result as a byte array as opposed to a default string. .EXAMPLE "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" | ConvertFrom-Base64UrlEncodedString Decodes a JWT header. .INPUTS System.String A string is received by the InputString parameter. .OUTPUTS System.String Returns a base 64 URL decoded string for the given input. .LINK https://tools.ietf.org/html/rfc4648#section-5 #> [CmdletBinding()] [OutputType([System.String], [System.Byte[]])] [Alias('b64d', 'Decode')] param ( [Parameter(Position=0,Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)] [string]$InputString, [Parameter(Position=1,Mandatory=$false,ValueFromPipeline=$false,ValueFromPipelineByPropertyName=$false)] [switch]$AsBytes ) BEGIN { $argumentExceptionMessage = "The input is not a valid Base-64 string as it contains a non-base 64 character, more than two padding characters, or an illegal character among the padding characters." $ArgumentException = New-Object -TypeName System.ArgumentException -ArgumentList $argumentExceptionMessage } PROCESS { try { $output = $InputString $output = $output.Replace('-', '+') # 62nd char of encoding $output = $output.Replace('_', '/') # 63rd char of encoding switch ($output.Length % 4) # Pad with trailing '='s { 0 { break }# No pad chars in this case 2 { $output += "=="; break } # Two pad chars 3 { $output += "="; break } # One pad char default { Write-Error -Exception ([ArgumentException]::new("Illegal base64url string!")) -Category InvalidArgument -ErrorAction Stop } } # Byte array conversion: [byte[]]$convertedBytes = [Convert]::FromBase64String($output) if ($PSBoundParameters.ContainsKey("AsBytes")) { return $convertedBytes } else { # String to be returned: $decodedString = [System.Text.Encoding]::ASCII.GetString($convertedBytes) return $decodedString } } catch { Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop } } } function ConvertTo-Base64UrlEncodedString { <# .SYNOPSIS Base 64 URL encodes an input string. .DESCRIPTION Base 64 URL encodes an input string required for the payload or header of a JSON Web Token (JWT). .PARAMETER InputString The string to be base64 URL encoded. .PARAMETER Bytes The byte array derived from a string to be base64 URL encoded. .EXAMPLE $jwtPayload = '{"role":"Administrator","sub":"first.last@company.com","jti":"545a310d890F47B9b1F5dc104f782ABD","iat":1551286711,"nbf":1551286711,"exp":1551287011}' ConvertTo-Base64UrlEncodedString -InputString $jwtPayload Base 64 URL encodes a JSON value. .INPUTS System.String A string is received by the InputString parameter. .OUTPUTS System.String Returns a base 64 URL encoded string for the given input. .LINK https://tools.ietf.org/html/rfc4648#section-5 #> [CmdletBinding()] [Alias('b64e', 'Encode')] [OutputType([System.String])] param ( [Parameter(Position=0,ParameterSetName="String",Mandatory=$true,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)] [string]$InputString, [Parameter(Position=1,ParameterSetName="Byte Array",Mandatory=$false,ValueFromPipeline=$false,ValueFromPipelineByPropertyName=$false)] [byte[]]$Bytes ) PROCESS { [string]$base64UrlEncodedString = "" if ($PSBoundParameters.ContainsKey("Bytes")) { try { $output = [Convert]::ToBase64String($Bytes) $output = $output.Split('=')[0] # Remove any trailing '='s $output = $output.Replace('+', '-') # 62nd char of encoding $output = $output.Replace('/', '_') # 63rd char of encoding $base64UrlEncodedString = $output } catch { $ArgumentException = New-Object -TypeName System.ArgumentException -ArgumentList $_.Exception.Message Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop } } else { try { $encoder = [System.Text.UTF8Encoding]::new() [byte[]]$inputBytes = $encoder.GetBytes($InputString) $base64String = [Convert]::ToBase64String($inputBytes) [string]$base64UrlEncodedString = "" $base64UrlEncodedString = $base64String.Split('=')[0] # Remove any trailing '='s $base64UrlEncodedString = $base64UrlEncodedString.Replace('+', '-'); # 62nd char of encoding $base64UrlEncodedString = $base64UrlEncodedString.Replace('/', '_'); # 63rd char of encoding } catch { $ArgumentException = New-Object -TypeName System.ArgumentException -ArgumentList $_.Exception.Message Write-Error -Exception $ArgumentException -Category InvalidArgument -ErrorAction Stop } } return $base64UrlEncodedString } } Function OktaConfigPathArgumentCompleter { [CmdletBinding()] param ( $commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters ) # Search in one of the following locations in order of precedence: # https://developer.okta.com/docs/guides/implement-oauth-for-okta-serviceapp/main/ # 1. Environment variables (in this case, cmdlet parameters) # 2. An okta.yaml file in a .okta folder in the application or project's root directory # 3. An okta.yaml file in a .okta folder in the current user's home directory (~/.okta/okta.yaml or %userprofile%\.okta\okta.yaml) $location = @( (Join-Path (Get-Location) ".okta"), (Join-Path $($env:HOME ?? $env:USERPROFILE) ".okta") ) $configs = Foreach($path in $location) { Get-ChildItem -Path $path -Filter "okta.yaml" -Recurse Get-ChildItem -Path $path -Filter "okta.yml" -Recurse Get-ChildItem -Path $path -Filter "*.yaml" -Recurse | Where-Object { $_.name -notlike "okta.yaml" } Get-ChildItem -Path $path -Filter "*.yml" -Recurse | Where-Object { $_.name -notlike "okta.yml" } } $configs | Where-Object { $_.name -like "*$wordToComplete*" } } # Accepts an array of factors and prompts the user to select one, the default is the first one. Returns a string of the # selected factor. Function Read-OktaFactorPrompt { [CmdletBinding()] param ( [Parameter(Mandatory)] [String[]] $AvailableFactors ) $title = "Verify it's you with a security method" $question = "Select from the following options" $choices = @(Foreach($f in $AvailableFactors) { Switch($f) { "duo::web" { [System.Management.Automation.Host.ChoiceDescription]::new("&Duo Security", "Get a push notification from Duo") } "okta::push" { [System.Management.Automation.Host.ChoiceDescription]::new("&Okta Verify Push", "Get a push notification from Okta Verify") } "okta::token:software:totp" { [System.Management.Automation.Host.ChoiceDescription]::new("Okta Verify &Code", "Enter a code from Okta Verify") } Default { Write-Error "Unknown factor type: $f" } } }) $index = $host.ui.PromptForChoice($title, $question, $choices, 0) Return $index } Function Send-OktaFactorProviderDuo { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [String] $VerifyUrl, [Parameter(Mandatory=$true)] [String] $StateToken ) $okta_verify = Invoke-RestMethod -Uri $VerifyUrl -Method "POST" -Body (@{ "stateToken" = $StateToken } | ConvertTo-Json) -ContentType "application/json" -WebSession $OktaSSO # Get Duo settings from Okta $duo = $okta_verify._embedded.factor._embedded.verification $duo_signature = $duo.signature.split(':') $duo_tx = $duo_signature[0] $duo_app = $duo_signature[1] # Get Duo session ID $duo_prompt = "" $duo_prompt_sid = "" While($duo_prompt.StatusCode -ne 302) { $duo_prompt_params = @{} if($duo_prompt_sid -ne "") { $duo_prompt_params = @{ body = @{ "sid" = $duo_prompt_sid } ContentType = "application/x-www-form-urlencoded" MaximumRedirection = 0 SkipHttpErrorCheck = $True ErrorAction = "SilentlyContinue" } } $duo_prompt = Invoke-WebRequest -Method "POST" -Uri "https://$($duo.host)/frame/web/v1/auth?tx=$duo_tx&parent=http://0.0.0.0:3000/duo&v=2.1" -WebSession $OktaSSO @duo_prompt_params If($duo_prompt.StatusCode -eq 302) { $duo_prompt_sid = $duo_prompt.Headers.Location.split('=')[1] $duo_prompt_sid = [System.Web.HttpUtility]::UrlDecode($duo_prompt_sid) } else { $duo_prompt_sid = ($duo_prompt.Content | ConvertFrom-Html).SelectSingleNode("//input[@name='sid']").Attributes["value"].DeEntitizeValue } } # Send a Duo push to default phone1 $duo_push = Invoke-RestMethod -Method "POST" -Uri "https://$($duo.host)/frame/prompt" -Body @{ "sid" = $duo_prompt_sid "device" = "phone1" "factor" = "Duo Push" "out_of_date" = "False" } -ContentType "application/x-www-form-urlencoded" -WebSession $OktaSSO -SkipHttpErrorCheck Write-Host "Push notification sent to: phone1" $duo_push_txid = $duo_push.response.txid $duo_approved = $false while(-not $duo_approved) { $duo_push = Invoke-RestMethod -Method "POST" -Uri "https://$($duo.host)/frame/status" -WebSession $OktaSSO -Body @{ sid = $duo_prompt_sid txid = $duo_push_txid } switch ($duo_push.response.status_code) { pushed { $duo_approved = $false Write-Verbose $duo_push.response.status } allow { $duo_cookie = Invoke-RestMethod -Method "POST" -Uri "https://$($duo.host)$($duo_push.response.result_url)" -WebSession $OktaSSO -Body @{ sid = $duo_prompt_sid } $duo_approved = $true } Default { $duo_approved = $false Write-Error "Failed to push 2fa: $($duo_push.response.status)" } } } $okta_callback = Invoke-RestMethod -Method "POST" -Uri $duo._links.complete.href -Body @{ "id" = $okta_authn._embedded.factors[0].id "stateToken" = $okta_authn.stateToken "sig_response" = "$($duo_cookie.response.cookie):$duo_app" } -ContentType "application/x-www-form-urlencoded" -WebSession $OktaSSO # If($okta_callback) { $res = Invoke-RestMethod -Uri $VerifyUrl -Method "POST" -Body (@{ "stateToken" = $StateToken } | ConvertTo-Json) -ContentType "application/json" -WebSession $OktaSSO $session_token = $res.sessionToken # } Return $session_token } Function Send-OktaFactorProviderOkta { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [String] $VerifyUrl, [Parameter(Mandatory=$true)] [String] $StateToken, [Parameter()] [String] $Passcode ) # Verify request body, add the passcode if it's provided # create it outside of the loop since it is only set once $req_body = @{ "stateToken" = $StateToken } If($Passcode) { $req_body["passCode"] = $Passcode } $status = "" while($status -ne "SUCCESS") { switch($status) { "CANCELLED" { Write-Warning "Push notification cancelled" Return } "ERROR" { Write-Warning "Push notification error" Return } "FAILED" { Write-Warning "Push notification failed" Return } "REJECTED" { Write-Warning "Push notification rejected" Return } "TIMEOUT" { Write-Warning "Push notification timed out" Return } "TIME_WINDOW_EXCEEDED" { Write-Warning "Push notification time window exceeded" Return } } $okta_verify = Invoke-RestMethod -Uri $VerifyUrl -Method "POST" -Body ($req_body | ConvertTo-Json) -ContentType "application/json" -WebSession $OktaSSO $status = $okta_verify.status } $session_token = $okta_verify.sessionToken Return $session_token } Function Set-OktaAuthentication { [CmdletBinding(DefaultParameterSetName = "SSWS")] param ( # Parameter help description [Parameter(Mandatory)] [ValidateSet("SSWS", "PrivateKey", "Credential")] [String] $AuthorizationMode, [Parameter(Mandatory)] [Microsoft.PowerShell.Commands.WebRequestSession] $Session, # Parameter help description [Parameter(Mandatory)] [String] $Domain, # Parameter help description [Parameter(ParameterSetName="PrivateKey", Mandatory)] [Int32] $ExpiresIn, # Parameter help description [Parameter(ParameterSetName="Credentials", Mandatory)] [System.DateTime] $ExpiresAt, [Parameter(ParameterSetName="Credentials", Mandatory)] [String] $Username ) $Script:OktaAuthorizationMode = $AuthorizationMode Write-Verbose "Setting OktaAuthorizationMode to $Script:OktaAuthorizationMode" $Script:OktaSSO = $Session Write-Verbose "Setting OktaSSO web session" $Script:OktaDomain = $Domain Write-Verbose "Setting OktaDomain to $Script:OktaDomain" $Uri = [System.Uri]$Domain $domainParts = $Uri.Host.Split('.') $Script:OktaOrg = $domainParts[0] Write-Verbose "Setting OktaOrg to $Script:OktaOrg" $Script:OktaAdminDomain = Get-OktaAdminDomain -Domain $Domain Write-Verbose "Setting OktaAdminDomain to $Script:OktaAdminDomain" If($PSCmdlet.ParameterSetName -eq "Credentials") { $Script:OktaSSOExpirationUTC = $ExpiresAt Write-Verbose "Setting OktaSSOExpirationUTC to $Script:OktaSSOExpirationUTC" $Script:OktaUsername = $Username Write-Verbose "Setting OktaUsername to $Script:OktaUsername" } If($PSCmdlet.ParameterSetName -eq "PrivateKey") { $now = (Get-Date).AddSeconds($ExpiresIn).ToUniversalTime() $Script:OktaSSOExpirationUTC = $now Write-Verbose "Setting OktaSSOExpirationUTC to $Script:OktaSSOExpirationUTC" } } Function Set-OktaConfig { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [String] $Path, [Parameter(Mandatory=$true)] $Config ) If(Test-Path $Path) { Write-Warning "Okta config file already exists at $Path, would you like to overwrite it?" -WarningAction Inquire } else { $null = New-Item -ItemType File -Path $Path -Force } @{ okta = @{ client = $saveConfig } } | ConvertTo-Yaml -OutFile $Path -Force } Function Test-OktaAuthentication { [CmdletBinding()] param () If(-not $Script:OktaSSO) { Write-Verbose "No Okta SSO session found" Return $False } If($Script:OktaAuthorizationMode -eq "SSWS") { Write-Verbose "API key does not expire" Return $True } $NowUTC = (Get-Date).ToUniversalTime() If($Script:OktaSSOExpirationUTC -gt $NowUTC) { Write-Verbose "Time left until expires: $(($Script:OktaSSOExpirationUTC - $NowUTC).tostring())" Return $True } else { Write-Verbose "Token expired: $Script:OktaSSOExpirationUTC UTC" Return $False } } Function Update-OktaAuthentication { Switch($Script:OktaAuthorizationMode) { "PrivateKey" { Connect-Okta } "Credential" { try { $session = Invoke-RestMethod -Method "POST" -Uri "$OktaDomain/api/v1/sessions/me/lifecycle/refresh" -WebSession $Script:OktaSSO -ContentType "application/json" -ErrorAction SilentlyContinue } catch { Write-Verbose "cached expiration expired, trying to renew session" } If($session.status -eq "ACTIVE") { $Script:OktaSSOExpirationUTC = $session.expiresAt Write-Verbose "session renewed, updated expiration to $Script:OktaSSOExpirationUTC UTC" Break } Write-Host "Okta session expired ($Script:OktaSSOExpirationUTC UTC)" # Remove-Variable OktaSSO,OktaSSOExpirationUTC -Scope Script Connect-Okta -OrgUrl $Script:OktaDomain -Credential $Script:OktaUsername } "SSWS" { Write-Verbose "API key does not expire, no refresh needed" } Default { Connect-Okta } } } Function Write-OktaAdminAPIWarning { If(-not $Script:SuppressAdminAPIWarning) { Write-Warning "This makes unoffical admin API calls to Okta, see README for more info. To stop these warnings, use `Set-OktaAdminAPIWarning -Disable`." -WarningAction Inquire } } ## PUBLIC MODULE FUNCTIONS AND DATA ## Function Add-OktaAppMember { [CmdletBinding()] param ( [Parameter()] [OktaUser[]] $Identity, [Parameter()] [String] $App ) begin { $OktaApp = Get-OktaApp -App $App -ErrorAction Stop } process { foreach($user in $Identity) { Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/apps/$($OktaApp.Id)/users" -Body @{ id = $user.Id scope = "USER" } } } } Function Add-OktaGroupMember { [CmdletBinding()] param ( [Parameter()] [String] $Group, [Parameter()] [OktaUser[]] $Members ) $OktaGroup = Get-OktaGroup -Name $Group -ErrorAction Stop $GroupId = $OktaGroup.id Foreach($member in $Members) { If($member.id) { Write-Verbose "Adding $($member.login) to $($OktaGroup.Name)" Invoke-OktaRequest -Method "PUT" -Endpoint "api/v1/groups/$GroupId/users/$($member.id)" } } } # process to determine what auth method to use, recommended to only use one # method and not save both api and credential to config file. so far not able # to export a websession to file. I'm assuming this is because the [System.Net.CookieContainer] # is only a pointer to the actual cookies stored somewhere else # # 1. check config for auth method in order of -session-, apikey, credentials # 2. -if session is:- # 2a. -valid, use- # 2b. -expired, try to renew session with credentials- # 3. if api key set, use api # 4. if username is set, prompt for credentials # 4a. -save session to config- # 5. if -save param is set, overwrite config with new # Register the argument completer Register-ArgumentCompleter -CommandName 'Connect-Okta' -ParameterName 'Config' -ScriptBlock { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) OktaConfigPathArgumentCompleter -commandName $commandName -parameterName $parameterName -wordToComplete $wordToComplete -commandAst $commandAst -fakeBoundParameter $fakeBoundParameter } Function Connect-Okta { [CmdletBinding(DefaultParameterSetName='SavedConfig')] param ( # Okta organization url beginning with https:// [Parameter(ParameterSetName = 'CredentialAuth', Mandatory=$True)] [Parameter(ParameterSetName = 'APIAuth', Mandatory=$True)] [Alias("OktaDomain")] [ValidatePattern("^https://", ErrorMessage="URL must begin with https://")] [String] $OrgUrl, # Okta admin credentials [Parameter(ParameterSetName = 'CredentialAuth', Mandatory=$True)] [PSCredential] $Credential, # Okta API key [Parameter(ParameterSetName = 'APIAuth', Mandatory=$True)] [String] $API, # Save authentication to .yaml config [Parameter(ParameterSetName = 'CredentialAuth')] [Parameter(ParameterSetName = 'CredentialAuthSave')] [Parameter(ParameterSetName = 'APIAuth')] [Parameter(ParameterSetName = 'APIAuthSave')] [Switch] $Save = $false, # Path to save .yaml config file, defaults to the user's home directory ~/.okta/okta.yaml [Parameter(ParameterSetName = 'CredentialAuthSave')] [Parameter(ParameterSetName = 'APIAuthSave')] [String] $SavePath = (Join-Path $($env:HOME ?? $env:USERPROFILE) ".okta\okta.yaml"), # Path to .yaml config file [Parameter(ParameterSetName = 'SavedConfig', Position=0)] [ArgumentCompleter({ OktaConfigPathArgumentCompleter @args })] [String] $Config ) Switch($PSCmdlet.ParameterSetName) { "SavedConfig" { $oktaYAMLPath = Get-OktaConfig -Path $Config If(-not [String]::IsNullOrEmpty($oktaYAMLPath)) { Write-Verbose "Connecting to Okta using config file: $oktaYAMLPath" $yaml = Get-Content $oktaYAMLPath | ConvertFrom-Yaml $yamlConfig = $yaml.okta.client If($yamlConfig.authorizationMode -eq "PrivateKey") { $OrgUrl = $yamlConfig.orgUrl $ClientId = $yamlConfig.clientId $Scopes = $yamlConfig.scopes $PrivateKey = $yamlConfig.privateKey $AuthFlow = "PrivateKey" } ElseIf(($yamlConfig.authorizationMode -eq "SSWS") -or (-not [String]::IsNullOrEmpty($yamlConfig.token))) { $OrgUrl = $yamlConfig.orgUrl $API = $yamlConfig.token $AuthFlow = "SSWS" } ElseIf(-not [String]::IsNullOrEmpty($yamlConfig.username)) { $OrgUrl = $yamlConfig.orgUrl $Credential = Get-Credential $yamlConfig.username $AuthFlow = "Credential" Write-Verbose $OrgUrl } Else { Write-Error "Unknown authorization mode: $($yamlConfig.authorizationMode)" Write-Error "Defaulting to credential auth method" $OrgUrl = Read-Host -Prompt "Enter your Okta organization url (with https://)" $AuthFlow = "Credential" } } Else { $OrgUrl = Read-Host -Prompt "Enter your Okta organization url (with https://)" $AuthFlow = "Credential" } } "APIAuth" { $AuthFlow = "SSWS" } "CredentialAuth" { $AuthFlow = "Credential" } } Clear-OktaAuthentication Switch($AuthFlow) { "SSWS" { Write-Verbose "Using API auth method" Connect-OktaAPI -OktaDomain $OrgUrl -API $API -ErrorAction Stop $saveConfig = @{ orgUrl = $OrgUrl authorizationMode = "SSWS" token = $API } } "PrivateKey" { Write-Verbose "Using OAuth 2.0 private key auth method" Connect-OktaPrivateKey -OktaDomain $OrgUrl -ClientId $ClientId -Scopes $Scopes -PrivateKey $PrivateKey -ErrorAction Stop } "Credential" { Write-Verbose "Using Credential auth method" Connect-OktaCredential -OktaDomain $OrgUrl -Credential $Credential -ErrorAction Stop $saveConfig = @{ orgUrl = $OrgUrl username = $Credential.UserName } } Default { Write-Error "Unknown authentication flow: $AuthFlow" } } Write-Host "Connected to $OrgUrl" If($Save) { Set-OktaConfig -Path $SavePath -Config $saveConfig } } Function Disconnect-Okta { [CmdletBinding()] param () Write-Verbose "Disconnecting Okta session" # do we need a valid session to delete? If($Script:OktaDomain) { Invoke-RestMethod -Method "DELETE" -Uri "$Script:OktaDomain/api/v1/sessions/me" -WebSession $Script:OktaSSO -ContentType "application/json" -ErrorAction SilentlyContinue } Clear-OktaAuthentication } Function Enable-OktaGroupRule { [CmdletBinding()] param ( [Parameter()] [String] $Rule ) $activatedRule = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/groups/rules/${ruleId}/lifecycle/activate" Return $activatedRule } Function Get-OktaApp { [CmdletBinding()] param ( [Parameter(ParameterSetName="ByApp")] [String] $App, [Parameter(ParameterSetName="ByIdentity")] [OktaUser] $Identity, [Parameter(ParameterSetName="ByGroup")] [String] $Group ) If($PSCmdlet.ParameterSetName -like "ByApp") { # attempt Id search, fail attempt Name search If($App -like "0oa*") { $response = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/apps/$App" -ErrorAction SilentlyContinue Write-Verbose "Tried Id search, nothing found." } # attempt Name search If($null -eq $response) { $response = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/apps" -Query @{ q = $App } } } ElseIf($PSCmdlet.ParameterSetName -like "ByIdentity") { Write-Error "Not Implemented" # TODO } ElseIf($PSCmdlet.ParameterSetName -like "ByGroup") { Write-Error "Not-Implemented" # TODO } If($response) { $OktaApp = ConvertTo-OktaApp -InputObject $response Return $OktaApp } else { Throw "No Okta app found." } } Function Get-OktaAppMember { [CmdletBinding()] param ( [Parameter(ParameterSetName="ByAppId", Mandatory)] [String] $App, [Parameter(ParameterSetName="ByOktaApp", ValueFromPipeline, Mandatory)] [PSTypeName("Okta.App")] $InputObject ) If($PSCmdlet.ParameterSetName -eq "ByAppId") { $OktaApp = Get-OktaApp -App $App } $response = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/apps/$($OktaApp.id)/users" -Query @{ limit = 500 } $OktaUser = Convertto-OktaAppUser -InputObject $response Return $OktaUser } Function Get-OktaCount { # unofficial api Write-OktaAdminAPIWarning $return = [ordered]@{ "users" = [ordered]@{ "total" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=EVERYONE").count "active" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=ACTIVATED").count "password_reset" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=PASSWORD_RESET").count "locked_out" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=LOCKED_OUT").count "suspended" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=SUSPENDED").count "deactivated" = (Invoke-OktaRequest -Method "GET" -Endpoint "api/internal/people/count?filter=DEACTIVATED").count } } $return } Function Get-OktaFactor { [CmdletBinding()] param ( [Parameter(ParameterSetName="ByIdentity", Position=0, ValueFromPipeline, Mandatory)] [OktaUser[]] $Identity ) Begin { $i = 0 } Process { Foreach($u in $Identity) { Write-Verbose "Looking up factor for $($u.login)" # count is not available when pipelining so we cannot show a progress bar, alternative to # show a count of processed objects $ObjectPct = $Identity.Count -gt 1 ? $i/($Identity.Count)*100 : -1 Write-Progress -Activity "Getting enrolled factors" -Status $i -PercentComplete $ObjectPct Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/users/$($u.Id)/factors" $i++ } } End { } } Function Get-OktaGroup { [CmdletBinding(DefaultParameterSetName='AllGroups')] param ( # Specifies an ID or name of an Okta group to retrieve. If searching by ID, it must be an exact match and only # one result will be returned. If searching by name, it will conduct a starts with query, and if multiple matches # all groups will be returned. [Parameter(ParameterSetName="GetGroup", Mandatory=$true, Position=0)] [String] $Name, # Specifies a specific type of group to search for. If no Type is specified, the default is to search for all # types. Does not apply when searching by ID. [Parameter()] [ValidateSet("OKTA_GROUP","APP_GROUP","BUILT_IN")] [String] $Type, # Specifies the number of Group results in a page [Parameter(ParameterSetName="AllGroups")] [Parameter(ParameterSetName="GetGroup")] [Int] $Limit = 10000 ) $query = @{} $query["limit"] = $Limit If($Type) { $query["filter"] = "type eq `"$Type`"" } switch ($PsCmdlet.ParameterSetName) { "AllGroups" { $group = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/groups" -Query $query -ErrorAction SilentlyContinue } "GetGroup" { # try matching group id $group = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/groups/$Name" -ErrorAction SilentlyContinue If(-not $group) { # try matching group name $query["q"] = $Name $group = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/groups" -Query $query -ErrorAction SilentlyContinue } } } If(-not $group) { Throw "Group not found: $Name" } $GroupObject = Foreach($g in $group) { ConvertTo-OktaGroup -InputObject $g } Return $GroupObject } Function Get-OktaGroupMember { [CmdletBinding()] param ( [Parameter(Mandatory=$true)] [OktaGroup] $Group ) $members = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/groups/$($Group.Id)/users" $OktaUsers = Foreach($m in $members) { ConvertTo-OktaUser -InputObject $m } Return $OktaUsers } Function Get-OktaLogs { <# .SYNOPSIS Fetches a list of ordered log events from your Okta organization's system log .DESCRIPTION The Okta System Log records system events that are related to your organization in order to provide an audit trail that can be used to understand platform activity and to diagnose problems. .NOTES Information or caveats about the function e.g. 'This function is not supported in Linux' .LINK https://developer.okta.com/docs/reference/api/system-log .EXAMPLE Get-OktaLogs -Limit 15 Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines .EXAMPLE $logs = Get-OktaLogs -NoPrompt Use -NoPrompt when assigning the output of Get-OktaLogs to a variable .EXAMPLE Get-OktaUser anna.unstoppable | Get-OktaLogs Get logs for a specific user #> [CmdletBinding(DefaultParameterSetName="ByFilter")] param ( # Filters the lower time bound of the log events published property for bounded queries or persistence time for polling queries [Parameter(ParameterSetName="ByFilter")] [Parameter(ParameterSetName="ByUser")] [datetime] $Since = (Get-Date).AddMinutes(-15), # Filters the upper time bound of the log events published property for bounded queries or persistence time for polling queries [Parameter(ParameterSetName="ByFilter")] [Parameter(ParameterSetName="ByUser")] [datetime] $Until = (Get-Date), # Filter Expression that filters the results [Parameter(ParameterSetName="ByFilter")] [String] $Filter, # Filters the log events results by one or more exact keywords [Parameter(ParameterSetName="ByFilter")] [String] $Keyword, # The order of the returned events that are sorted by published [Parameter(ParameterSetName="ByFilter")] [Parameter(ParameterSetName="ByUser")] [ValidateSet("ASCENDING", "DESCENDING")] [String] $Sort = "ASCENDING", # Sets the number of results that are returned in the response [Parameter(ParameterSetName="ByFilter")] [Parameter(ParameterSetName="ByUser")] [int] $Limit = 1000, # Not supported, Disable color output for consoles without ANSI support # [Parameter()] # [switch] # $NoColor, # Get logs for a specific user, cannot be combined with the Filter parameter [Parameter(ParameterSetName="ByUser", ValueFromPipeline)] [PSTypeName("OktaUser")] $OktaUser ) Switch($PSCmdlet.ParameterSetName) { "ByUser" { $Filter = "actor.id eq ""$($OktaUser.id)"" or target.id eq ""$($OktaUser.id)""" } } $query = @{ since = $Since.ToString("o") until = $Until.ToString("o") filter = $Filter q = $Keyword sortOrder = $Sort limit = $Limit } $response = Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/logs" -Query $query Return ConvertTo-OktaLogEvent -LogEvent $response } Function Get-OktaUser { [CmdletBinding(DefaultParameterSetName='AllUsers')] [OutputType("OktaUser")] param ( [Parameter(ParameterSetName = 'GetUser', Mandatory, Position=0, HelpMessage="Okta user ID, login, or short-login")] [String[]] $Identity, [Parameter(ParameterSetName = 'ListUserFilter', Mandatory, HelpMessage="Okta API filter criteria. https://developer.okta.com/docs/reference/api/users/#list-users-with-a-filter")] [String] $Filter, [Parameter(ParameterSetName = 'ListUserSearch', Mandatory, HelpMessage="Okta API search criteria. https://developer.okta.com/docs/reference/api/users/#list-users-with-search")] [String] $Search, [Parameter(ParameterSetName = 'ListUserFind', Mandatory, HelpMessage="Okta API search criteria. https://developer.okta.com/docs/reference/api/users/#find-users")] [String] $Find, [Parameter(ParameterSetName = 'GetUser')] [Parameter(ParameterSetName = 'ListUserFilter')] [Parameter(ParameterSetName = 'ListUserSearch')] [String] $Properties, [Parameter(ParameterSetName = 'GetUser')] [Parameter(ParameterSetName = 'ListUserFilter')] [Parameter(ParameterSetName = 'ListUserSearch')] [Int] $Limit = 10, # By default all users return a list of all users that do not have a status of DEPROVISIONED, this switch also returns DEPROVISIONED users [Parameter(ParameterSetName = 'AllUsers')] [Switch] $IncludeDeprovisioned ) $request_args = @{} # by default Okta sends over all properties, so selecting only a subset of properties does not # have any performance gains of network bandwidth. can implement it in the future for memory # saving If($Properties -eq "*") { $groups_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users/$Identity/groups" } else { # less expensive request? # https://developer.okta.com/docs/reference/api/users/#content-type-header-fields $request_args['Headers'] = @{ "Content-Type" = "application/json; okta-response=omitCredentials,omitCredentialsLinks,omitTransitioningToStatus" } } switch ($PsCmdlet.ParameterSetName) { "GetUser" { $user_query = Foreach($i in $Identity) { $query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users/$i" @request_args -ErrorAction Stop If($null -eq $query) { Write-Error "Could not find user $i" Continue } $groups_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users/$i/groups" -ErrorAction SilentlyContinue If($groups_query) { $query | Add-Member -MemberType NoteProperty -Name "_groups" -Value $groups_query } $query } } "ListUserSearch" { $url_builder = @{} $url_builder['limit'] = $Limit $url_builder['search'] = $Search.Replace("'","`"") #okta query has to be in double-quotes $querystring = New-HttpQueryString -QueryParameter $url_builder $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop } "ListUserFilter" { $url_builder = @{} $url_builder['limit'] = $Limit $url_builder['filter'] = $Filter $querystring = New-HttpQueryString -QueryParameter $url_builder $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop } "ListUserFind" { $url_builder = @{} $url_builder['limit'] = $Limit $url_builder['q'] = $Find $querystring = New-HttpQueryString -QueryParameter $url_builder $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop } "AllUsers" { $url_builder = @{} $url_builder['limit'] = 200 $querystring = New-HttpQueryString -QueryParameter $url_builder $user_query = Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop If($IncludeDeprovisioned) { $url_builder['filter'] = 'status eq "DEPROVISIONED"' $querystring = New-HttpQueryString -QueryParameter $url_builder $user_query += Invoke-OktaRequest -Method "GET" -Endpoint "api/v1/users?$querystring" @request_args -ErrorAction Stop } } } # Return $user_query If($null -ne $user_query) { $OktaUser = ConvertTo-OktaUser -InputObject $user_query Return $OktaUser } Return } Function New-OktaGroup { [CmdletBinding()] param ( [Parameter(Mandatory)] [String] $Name, [Parameter()] [String] $Description ) $reqBody = @{ "profile" = @{ "name" = $Name } } If($Description) { $reqBody['profile']['description'] = $Description } $response = Invoke-OktaRequest -Method "POST" -Endpoint "api/v1/groups" -Body $reqBody $GroupObject = ConvertTo-OktaGroup -InputObject $response Return $GroupObject } Function New-OktaGroupRule { [CmdletBinding()] param ( [ValidateLength(1,50)] [Parameter(Mandatory)] [string] $Name, # Parameter help description [Parameter(Mandatory)] [string] $Expression, # Parameter help description [Parameter(Mandatory)] [String[]] $Group, # Parameter help description [Parameter()] [Switch] $Activate ) $newRule = Invoke-OktaRequest -Method "POST" -Endpoint "api/v1/groups/rules" -Body @{ "type" = "group_rule" "name" = $Name "conditions" = @{ # "people": { # "users": { # "exclude": [ # "00u22w79JPMEeeuLr0g4" # ] # }, # "groups": { # "exclude": [] # } # }, "expression" = @{ "value" = $Expression.Replace("'","`"") #expression has to be in double-quotes "type" = "urn:okta:expression:1.0" } } "actions" = @{ "assignUserToGroups" = @{ "groupIds" = $Group } } } If($Activate) { $ruleId = $newRule.Id $activatedRule = Enable-OktaGroupRule -Rule $ruleId # $activatedRule = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/groups/rules/${ruleId}/lifecycle/activate" Return $activatedRule } else { Return $newRule } } Function New-OktaUser { [CmdletBinding()] param ( [Parameter()] [String] $FirstName, # Parameter help description [Parameter(Mandatory)] [String] $LastName, # Parameter help description [Parameter(Mandatory)] [String] $Email, # Parameter help description [Parameter()] [Switch] $Activate ) If($Activate) { $url_builder = @{} $url_builder['activate'] = $true $querystring = New-HttpQueryString -QueryParameter $url_builder } Invoke-OktaRequest -Method "POST" -Endpoint "api/v1/users?$querystring" -Body @{ "profile" = @{ "firstName" = $FirstName "lastName" = $LastName "email" = $Email "login" = $Email } } -Verbose -Debug } Function Remove-OktaGroupMember { [CmdletBinding()] param ( [Parameter()] [String] $Group, [Parameter()] [String[]] $Members ) $OktaGroup = Get-OktaGroup -Identity $Group -ErrorAction Stop $GroupId = $OktaGroup.id Foreach($memberId in $Members) { # Invoke-OktaRequest -Method "GET" -Endpoint "/api/v1/users/$member" Write-Verbose "Removing $memberId from $($OktaGroup.Name)" Invoke-OktaRequest -Method "DELETE" -Endpoint "/api/v1/groups/$GroupId/users/$memberId" } } Function Reset-OktaUserPassword { [CmdletBinding()] param ( [Parameter(Position=0, ValueFromPipeline, Mandatory)] [OktaUser] $Identity, [Parameter()] [Switch] $AsPlainText ) $response = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/users/$($Identity.id)/lifecycle/expire_password" -Query @{"tempPassword" = "true"} $tempPassword = $response.tempPassword If($AsPlainText) { Return $tempPassword } else { Return ($tempPassword | ConvertTo-SecureString -AsPlainText) } } Function Set-OktaAdminAPIWarning { [CmdletBinding()] param ( [Parameter(ParameterSetName="Enable", Mandatory)] [Switch] $Enable, [Parameter(ParameterSetName="Disable", Mandatory)] [Switch] $Disable ) If($Enable) { $Script:SuppressAdminAPIWarning = $False } If($Disable) { $Script:SuppressAdminAPIWarning = $True } } Function Set-OktaUser { [CmdletBinding(DefaultParameterSetName="SingleProfileAttribute")] param( [Parameter(Mandatory=$true)] [OktaUser]$User, # Specify a single profile attribute name to set on the Okta user. [Parameter(ParameterSetName="SingleProfileAttribute", Mandatory=$true)] [String] $ProfileAttributeName, # Specify a single profile attribute value to set on the Okta user. [Parameter(ParameterSetName="SingleProfileAttribute", Mandatory=$true)] [String] $ProfileAttributeValue, # Specify a hashtable of profile attributes name value pairs to set on the Okta user. [Parameter(ParameterSetName="HashtableProfileAttribute", Mandatory=$true)] [Hashtable] $ProfileAttribute, # Indicates if unspecified properties should be deleted from the Okta user's profile. [Parameter()] [Switch] $Force ) $Method = "POST" If($Force) { $Method = "PUT" } If($PSCmdlet.ParameterSetName -eq "SingleProfileAttribute") { $ProfileAttribute = @{ $ProfileAttributeName = $ProfileAttributeValue } } $Body = @{ "profile" = $ProfileAttribute } Invoke-OktaRequest -Method $Method -Endpoint "api/v1/users/$($User.id)" -Body $Body } Function Set-OktaUserPassword { [CmdletBinding(DefaultParameterSetName="PasswordChange")] param ( [Parameter(ParameterSetName="PasswordChange", Position=0, ValueFromPipeline, Mandatory)] [Parameter(ParameterSetName="PasswordReset", Position=0, ValueFromPipeline, Mandatory)] [OktaUser] $Identity, [Parameter(ParameterSetName="PasswordChange", Mandatory)] [SecureString] $OldPassword, [Parameter(ParameterSetName="PasswordChange", Mandatory)] [SecureString] $NewPassword, [Parameter(ParameterSetName="PasswordReset", Mandatory)] [Switch] $Reset, [Parameter(ParameterSetName="PasswordReset")] [Switch] $AsPlainText ) switch($PSCmdlet.ParameterSetName) { "PasswordChange" { $null = Invoke-OktaRequest -Method "POST" -Endpoint "/api/v1/users/$($Identity.id)/credentials/change_password" -Body @{ "oldPassword" = @{ "value" = (ConvertFrom-SecureString $OldPassword -AsPlainText) } "newPassword" = @{ "value" = (ConvertFrom-SecureString $NewPassword -AsPlainText) } } Return } "PasswordReset" { If($AsPlainText) { $tempPassword = Reset-OktaUserPassword -Identity $Identity -AsPlainText } else { $tempPassword = Reset-OktaUserPassword -Identity $Identity } Return $tempPassword } Default { throw [System.ArgumentOutOfRangeException] "Unknown parameter set: $($PSCmdlet.ParameterSetName)" } } } |