diff --git a/examples/Web-AuthDigest.ps1 b/examples/Web-AuthDigest.ps1 index 7c26eef53..2ea531958 100644 --- a/examples/Web-AuthDigest.ps1 +++ b/examples/Web-AuthDigest.ps1 @@ -9,7 +9,29 @@ .EXAMPLE To run the sample: ./Web-AuthDigest.ps1 - Invoke-RestMethod -Uri http://localhost:8081/users -Method Get + # Define the URI and credentials + $uri = [System.Uri]::new("http://localhost:8081/users") + $username = "morty" + $password = "pickle" + + # Create a credential cache and add Digest authentication + $credentialCache = [System.Net.CredentialCache]::new() + $networkCredential = [System.Net.NetworkCredential]::new($username, $password) + $credentialCache.Add($uri, "Digest", $networkCredential) + + # Create the HTTP client handler with the credential cache + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.Credentials = $credentialCache + + # Create the HTTP client + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + # Send the GET request and capture the response + $response = $httpClient.GetStringAsync($uri).Result + + # Display the response + $response + .LINK https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthDigest.ps1 @@ -45,7 +67,7 @@ Start-PodeServer -Threads 2 { # setup digest auth New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { param($username, $params) - +write-podehost "username=$username" # here you'd check a real user storage, this is just for example if ($username -ieq 'morty') { return @{ @@ -57,12 +79,13 @@ Start-PodeServer -Threads 2 { Password = 'pickle' } } - +write-podehost 'no auth' return $null } # GET request to get list of users (since there's no session, authentication will always happen) - Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ScriptBlock { + Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ScriptBlock { + write-podehsot '1' Write-PodeJsonResponse -Value @{ Users = @( @{ diff --git a/examples/Web-AuthManualErrorHandling.ps1 b/examples/Web-AuthManualErrorHandling.ps1 index 98b37b462..efc76cdd1 100644 --- a/examples/Web-AuthManualErrorHandling.ps1 +++ b/examples/Web-AuthManualErrorHandling.ps1 @@ -68,8 +68,19 @@ Start-PodeServer { return @{ Success = $false; User = $key; Reason = 'Not existing user' } } + + New-PodeAuthScheme -ApiKey | Add-PodeAuth -Name 'APIKey_standard' -Sessionless -ScriptBlock { + param($key) + + # Validate API key + if ($key -eq 'test_user') { + return @{ Success = $true; User = 'test_user'; UserId = 1 } + } + + } + # Define an API route with manual authentication error handling - Add-PodeRoute -PassThru -Method 'Get' -Path '/api/v3/' -Authentication 'APIKey' -NoMiddlewareAuthentication -ScriptBlock { + Add-PodeRoute -PassThru -Method 'Get' -Path '/api/v3/whoami' -Authentication 'APIKey' -NoMiddlewareAuthentication -ScriptBlock { # Manually invoke authentication $auth = Invoke-PodeAuth -Name 'APIKey' @@ -95,4 +106,31 @@ Start-PodeServer { } | Set-PodeOARouteInfo -Summary 'Who am I' -Tags 'auth' -OperationId 'whoami' -PassThru | Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content @{ 'application/json' = (New-PodeOABoolProperty -Name 'Success' -Default $true | New-PodeOAStringProperty -Name 'Username' | New-PodeOAIntProperty -Name 'UserId' | New-PodeOAObjectProperty ) } -PassThru | Add-PodeOAResponse -StatusCode 401 -Description 'Authentication failure' -Content @{ 'application/json' = (New-PodeOABoolProperty -Name 'Success' -Default $false | New-PodeOAStringProperty -Name 'Username' | New-PodeOAStringProperty -Name 'Message' | New-PodeOAObjectProperty ) } + + Add-PodeRoute -PassThru -Method 'Get' -Path '/api/v3/whoami_standard' -Authentication 'APIKey_standard' -ErrorContentType 'application/json' -ScriptBlock { + # Manually invoke authentication + # $auth = Invoke-PodeAuth -Name 'APIKey' + + # Log authentication details for debugging + Write-PodeHost $auth -Explode + + # If authentication succeeds, return user details + if ($auth.Success) { + Write-PodeJsonResponse -StatusCode 200 -Value @{ + Success = $true + Username = $auth.User + UserId = $auth.UserId + } + } + else { + # Handle authentication failures with a custom error response + Write-PodeJsonResponse -StatusCode 401 -Value @{ + Success = $false + Message = $auth.Reason + Username = $auth.User + } + } + } | Set-PodeOARouteInfo -Summary 'Who am I (default auth)' -Tags 'auth' -OperationId 'whoami_standard' -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content @{ 'application/json' = (New-PodeOABoolProperty -Name 'Success' -Default $true | New-PodeOAStringProperty -Name 'Username' | New-PodeOAIntProperty -Name 'UserId' | New-PodeOAObjectProperty ) } -PassThru | + Add-PodeOAResponse -StatusCode 401 -Description 'Authentication failure' -Content @{ 'application/json' = (New-PodeOABoolProperty -Name 'Success' -Default $false | New-PodeOAStringProperty -Name 'Username' | New-PodeOAStringProperty -Name 'Message' | New-PodeOAObjectProperty ) } } diff --git a/my modified authentication - Private.ps1 b/my modified authentication - Private.ps1 new file mode 100644 index 000000000..5979dc87d --- /dev/null +++ b/my modified authentication - Private.ps1 @@ -0,0 +1,2544 @@ +function Get-PodeAuthBasicType { + return { + param($options) + + # get the auth header + $header = (Get-PodeHeader -Name 'Authorization') + if ($null -eq $header) { + return @{ + Message = 'No Authorization header found' + Code = 401 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`"" } + } + } + + # ensure the first atom is basic (or opt override) + $atoms = $header -isplit '\s+' + if ($atoms.Length -lt 2) { + return @{ + Message = 'Invalid Authorization header' + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`"" } + } + } + + if ($atoms[0] -ine $options.HeaderTag) { + return @{ + Message = "Header is not for $($options.HeaderTag) Authorization" + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`"" } + } + } + + # decode the auth header + try { + $enc = [System.Text.Encoding]::GetEncoding($options.Encoding) + } + catch { + return @{ + Message = 'Invalid encoding specified for Authorization' + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`"" } + } + } + + try { + $decoded = $enc.GetString([System.Convert]::FromBase64String($atoms[1])) + } + catch { + return @{ + Message = 'Invalid Base64 string found in Authorization header' + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`"" } + } + } + + # validate and return user/result + $index = $decoded.IndexOf(':') + $username = $decoded.Substring(0, $index) + $password = $decoded.Substring($index + 1) + + # build the result + $result = @($username, $password) + + # convert to credential? + if ($options.AsCredential) { + $passSecure = ConvertTo-SecureString -String $password -AsPlainText -Force + $creds = [pscredential]::new($username, $passSecure) + $result = @($creds) + } + + # return data for calling validator + return $result + } +} + +function Get-PodeAuthOAuth2Type { + return { + param($options, $schemes) + + # set default scopes + if (($null -eq $options.Scopes) -or ($options.Scopes.Length -eq 0)) { + $options.Scopes = @('openid', 'profile', 'email') + } + + $scopes = ($options.Scopes -join ' ') + + # if there's an error, fail + if (![string]::IsNullOrWhiteSpace($WebEvent.Query['error'])) { + return @{ + Message = $WebEvent.Query['error'] + Code = 401 + IsErrored = $true + } + } + + # set grant type + $hasInnerScheme = (($null -ne $schemes) -and ($schemes.Length -gt 0)) + $grantType = 'authorization_code' + if ($hasInnerScheme) { + $grantType = 'password' + } + + # if there's a code query param, or inner scheme, get access token + if ($hasInnerScheme -or ![string]::IsNullOrWhiteSpace($WebEvent.Query['code'])) { + try { + # ensure the state is valid + if ((Test-PodeSessionsInUse) -and ($WebEvent.Query['state'] -ne $WebEvent.Session.Data['__pode_oauth_state__'])) { + return @{ + Message = 'OAuth2 state returned is invalid' + Code = 401 + IsErrored = $true + } + } + + # build tokenUrl query with client info + $body = "client_id=$($options.Client.ID)" + $body += "&grant_type=$($grantType)" + + if (![string]::IsNullOrEmpty($options.Client.Secret)) { + $body += "&client_secret=$([System.Web.HttpUtility]::UrlEncode($options.Client.Secret))" + } + + # add PKCE code verifier + if ($options.PKCE.Enabled) { + $body += "&code_verifier=$($WebEvent.Session.Data['__pode_oauth_code_verifier__'])" + } + + # if there's an inner scheme, get the username/password, and set query + if ($hasInnerScheme) { + $body += "&username=$($schemes[-1][0])" + $body += "&password=$($schemes[-1][1])" + $body += "&scope=$([System.Web.HttpUtility]::UrlEncode($scopes))" + } + + # otherwise, set query for auth_code + else { + $redirectUrl = Get-PodeOAuth2RedirectHost -RedirectUrl $options.Urls.Redirect + $body += "&code=$($WebEvent.Query['code'])" + $body += "&redirect_uri=$([System.Web.HttpUtility]::UrlEncode($redirectUrl))" + } + + # POST the tokenUrl + try { + $result = Invoke-RestMethod -Method Post -Uri $options.Urls.Token -Body $body -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop + } + catch [System.Net.WebException], [System.Net.Http.HttpRequestException] { + $response = Read-PodeWebExceptionInfo -ErrorRecord $_ + $result = ($response.Body | ConvertFrom-Json) + } + + # was there an error? + if (![string]::IsNullOrWhiteSpace($result.error)) { + return @{ + Message = "$($result.error): $($result.error_description)" + Code = 401 + IsErrored = $true + } + } + + # get user details - if url supplied + if (![string]::IsNullOrWhiteSpace($options.Urls.User.Url)) { + try { + $user = Invoke-RestMethod -Method $options.Urls.User.Method -Uri $options.Urls.User.Url -Headers @{ Authorization = "Bearer $($result.access_token)" } + } + catch [System.Net.WebException], [System.Net.Http.HttpRequestException] { + $response = Read-PodeWebExceptionInfo -ErrorRecord $_ + $user = ($response.Body | ConvertFrom-Json) + } + + if (![string]::IsNullOrWhiteSpace($user.error)) { + return @{ + Message = "$($user.error): $($user.error_description)" + Code = 401 + IsErrored = $true + } + } + } + elseif (![string]::IsNullOrWhiteSpace($result.id_token)) { + try { + $user = ConvertFrom-PodeJwt -Token $result.id_token -IgnoreSignature + } + catch { + $user = @{ Provider = 'OAuth2' } + } + } + else { + $user = @{ Provider = 'OAuth2' } + } + + # return the user for the validator + return @($user, $result.access_token, $result.refresh_token, $result) + } + finally { + if ($null -ne $WebEvent.Session.Data) { + # clear state + $WebEvent.Session.Data.Remove('__pode_oauth_state__') + + # clear PKCE + if ($options.PKCE.Enabled) { + $WebEvent.Session.Data.Remove('__pode_oauth_code_verifier__') + } + } + } + } + + # redirect to the authUrl - only if no inner scheme supplied + if (!$hasInnerScheme) { + # get the redirectUrl + $redirectUrl = Get-PodeOAuth2RedirectHost -RedirectUrl $options.Urls.Redirect + + # add authUrl query params + $query = "client_id=$($options.Client.ID)" + $query += '&response_type=code' + $query += "&redirect_uri=$([System.Web.HttpUtility]::UrlEncode($redirectUrl))" + $query += '&response_mode=query' + $query += "&scope=$([System.Web.HttpUtility]::UrlEncode($scopes))" + + # add csrf state + if (Test-PodeSessionsInUse) { + $guid = New-PodeGuid + $WebEvent.Session.Data['__pode_oauth_state__'] = $guid + $query += "&state=$($guid)" + } + + # build a code verifier for PKCE, and add to query + if ($options.PKCE.Enabled) { + $guid = New-PodeGuid + $codeVerifier = "$($guid)-$($guid)" + $WebEvent.Session.Data['__pode_oauth_code_verifier__'] = $codeVerifier + + $codeChallenge = $codeVerifier + if ($options.PKCE.CodeChallenge.Method -ieq 'S256') { + $codeChallenge = ConvertTo-PodeBase64UrlValue -Value (Invoke-PodeSHA256Hash -Value $codeChallenge) -NoConvert + } + + $query += "&code_challenge=$($codeChallenge)" + $query += "&code_challenge_method=$($options.PKCE.CodeChallenge.Method)" + } + + # are custom parameters already on the URL? + $url = $options.Urls.Authorise + if (!$url.Contains('?')) { + $url += '?' + } + else { + $url += '&' + } + + # redirect to OAuth2 endpoint + Move-PodeResponseUrl -Url "$($url)$($query)" + return @{ IsRedirected = $true } + } + + # hmm, this is unexpected + return @{ + Message = 'Well, this is awkward...' + Code = 500 + IsErrored = $true + } + } +} + +function Get-PodeOAuth2RedirectHost { + param( + [Parameter()] + [string] + $RedirectUrl + ) + + if ($RedirectUrl.StartsWith('/')) { + if ($PodeContext.Server.IsIIS -or $PodeContext.Server.IsHeroku) { + $protocol = Get-PodeHeader -Name 'X-Forwarded-Proto' + if ([string]::IsNullOrWhiteSpace($protocol)) { + $protocol = 'https' + } + + $domain = "$($protocol)://$($WebEvent.Request.Host)" + } + else { + $domain = Get-PodeEndpointUrl + } + + $RedirectUrl = "$($domain.TrimEnd('/'))$($RedirectUrl)" + } + + return $RedirectUrl +} + +function Get-PodeAuthClientCertificateType { + return { + param($options) + $cert = $WebEvent.Request.ClientCertificate + + # ensure we have a client cert + if ($null -eq $cert) { + $message = 'No client certificate supplied' + return @{ + Message = $message + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`", error=`"invalid_request`", error_description=`"$message`"" } + } + } + + # ensure the cert has a thumbprint + if ([string]::IsNullOrWhiteSpace($cert.Thumbprint)) { + $message = 'Invalid client certificate supplied: missing thumbprint' + return @{ + Message = $message + Code = 400 + Headers = @{'WWW-Authenticate' = "TLS realm=`"$($options.Realm)`", error=`"invalid_token`", error_description=`"$message`"" } + } + } + + # ensure the cert hasn't expired, or has it even started + $now = [datetime]::UtcNow + if (($cert.NotAfter -lt $now) -or ($cert.NotBefore -gt $now)) { + $message = 'Client certificate supplied is expired or not valid yet' + return @{ + Message = $message + Code = 403 + Headers = @{'WWW-Authenticate' = "TLS realm=`"$($options.Realm)`", error=`"invalid_token`", error_description=`"$message`"" } + } + } + + # return data for calling validator + return @($cert, $WebEvent.Request.ClientCertificateErrors) + } +} + +function Get-PodeAuthApiKeyType { + return { + param($options) + + # get api key from appropriate location + $apiKey = [string]::Empty + + switch ($options.Location.ToLowerInvariant()) { + 'header' { + $apiKey = Get-PodeHeader -Name $options.LocationName + } + + 'query' { + $apiKey = $WebEvent.Query[$options.LocationName] + } + + 'cookie' { + $apiKey = Get-PodeCookieValue -Name $options.LocationName + } + } + # 400 if no key + if ([string]::IsNullOrWhiteSpace($apiKey)) { + $message = "No $($options.LocationName) $($options.Location) found" + return @{ + Message = $message + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`", error=`"invalid_request`", error_description=`"$message`"" } + } + } + + # build the result + $apiKey = $apiKey.Trim() + $result = @($apiKey) + + # convert as jwt? + if ($options.AsJWT) { + try { + $payload = ConvertFrom-PodeJwt -Token $apiKey -Secret $options.Secret + Test-PodeJwt -Payload $payload + } + catch { + if ($_.Exception.Message -ilike '*jwt*') { + return @{ + Message = $_.Exception.Message + Code = 400 + Headers = @{'WWW-Authenticate' = "Bearer realm=`"$($options.Realm)`", error=`"invalid_request`", error_description=`"$($_.Exception.Message)`"" } + } + } + + throw + } + + $result = @($payload) + } + + # return the result + return $result + } +} + +function Get-PodeAuthBearerType { + return { + <# + .SYNOPSIS + Validates the Bearer token in the Authorization header. + + .DESCRIPTION + This function processes the Authorization header, verifies the presence of a Bearer token, + and optionally decodes it as a JWT. It returns appropriate HTTP response codes + as per RFC 6750 (OAuth 2.0 Bearer Token Usage). + + .PARAMETER $options + A hashtable containing the following keys: + - Realm: The authentication realm. + - Scopes: Expected scopes for the token. + - HeaderTag: The expected Authorization header tag (e.g., 'Bearer'). + - AsJWT: Boolean indicating if the token should be processed as a JWT. + - Secret: Secret key for JWT verification. + + .OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - Header: HTTP response header for authentication challenges. + - Challenge: Optional authentication challenge. + + .NOTES + The function adheres to RFC 6750, which mandates: + - 401 Unauthorized for missing or invalid authentication credentials. + - 400 Bad Request for malformed requests. + + RFC 6750 HTTP Status Code Usage + # | Scenario | Recommended Status Code | + # |-------------------------------------------|-------------------------| + # | No Authorization header provided | 401 Unauthorized | + # | Incorrect Authorization header format | 401 Unauthorized | + # | Wrong authentication scheme used | 401 Unauthorized | + # | Token is empty or malformed | 400 Bad Request | + # | Invalid JWT signature | 401 Unauthorized | + #> + + param($options) + write-podehost "I'm here" + # Define common WWW-Authenticate header with placeholders + $authHeaderBase = "Bearer realm=`"$($options.Realm)`", error=`"{0}`", error_description=`"{1}`"" + + # Get the Authorization header + $header = (Get-PodeHeader -Name 'Authorization') + + # If no Authorization header is provided, return 401 Unauthorized + if ($null -eq $header) { + $message = 'No Authorization header found' + return @{ + Message = $message + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) + Code = 401 # RFC 6750: Missing credentials should return 401 + Header = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # Ensure the first part of the header is 'Bearer' + $atoms = $header -isplit '\s+' + if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' + return @{ + Message = $message + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) + Code = 401 # RFC 6750: Invalid credentials format should return 401 + Header = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + if ($atoms[0] -ine $options.HeaderTag) { + $message = "Authorization header is not $($options.HeaderTag)" + return @{ + Message = $message + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) + Code = 401 # RFC 6750: Wrong authentication scheme should return 401 + Header = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # 400 Bad Request if no token is provided + $token = $atoms[1] + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 400 # RFC 6750: Malformed request should return 400 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # Trim and build the result + $token = $token.Trim() + $result = @($token) + + # Convert to JWT if required + if ($options.AsJWT) { + try { + $payload = ConvertFrom-PodeJwt -Token $token -Secret $options.Secret + Test-PodeJwt -Payload $payload + } + catch { + if ($_.Exception.Message -ilike '*jwt*') { + return @{ + Message = $_.Exception.Message + Code = 401 # RFC 6750: Invalid token should return 401 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_token', $_.Exception.Message } + } + } + + throw + } + + $result = @($payload) + } + write-podehost "I'm here2" + # Return the validated result + return $result + } +} + + +function Get-PodeAuthBearerPostValidator { + return { + param($token, $result, $options) + + # if there's no user, fail with challenge + if (($null -eq $result) -or ($null -eq $result.User)) { + return @{ + Message = 'User not found' + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_token) + Code = 401 + } + } + + # check for an error and description + if (![string]::IsNullOrWhiteSpace($result.Error)) { + return @{ + Message = 'Authorization failed' + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType $result.Error -ErrorDescription $result.ErrorDescription) + Code = 401 + } + } + + # check the scopes + $hasAuthScopes = (($null -ne $options.Scopes) -and ($options.Scopes.Length -gt 0)) + $hasTokenScope = ![string]::IsNullOrWhiteSpace($result.Scope) + + # 403 if we have auth scopes but no token scope + if ($hasAuthScopes -and !$hasTokenScope) { + return @{ + Message = 'Invalid Scope' + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType insufficient_scope) + Code = 403 + } + } + + # 403 if we have both, but token not in auth scope + if ($hasAuthScopes -and $hasTokenScope -and ($options.Scopes -notcontains $result.Scope)) { + return @{ + Message = 'Invalid Scope' + Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType insufficient_scope) + Code = 403 + } + } + + # return result + return $result + } +} + +function New-PodeAuthBearerChallenge { + param( + [Parameter()] + [string[]] + $Scopes, + + [Parameter()] + [ValidateSet('', 'invalid_request', 'invalid_token', 'insufficient_scope')] + [string] + $ErrorType, + + [Parameter()] + [string] + $ErrorDescription + ) + + $items = @() + if (($null -ne $Scopes) -and ($Scopes.Length -gt 0)) { + $items += "scope=`"$($Scopes -join ' ')`"" + } + + if (![string]::IsNullOrWhiteSpace($ErrorType)) { + $items += "error=`"$($ErrorType)`"" + } + + if (![string]::IsNullOrWhiteSpace($ErrorDescription)) { + $items += "error_description=`"$($ErrorDescription)`"" + } + + return ($items -join ', ') +} + +<# +.SYNOPSIS + Validates the Digest token in the Authorization header. + +.DESCRIPTION + This function processes the Authorization header, verifies the presence of a Digest token, + and optionally decodes it. It returns appropriate HTTP response codes + as per RFC 7616 (HTTP Digest Access Authentication). + +.PARAMETER $options + A hashtable containing the following keys: + - Realm: The authentication realm. + - Nonce: A unique value provided by the server to prevent replay attacks. + - HeaderTag: The expected Authorization header tag (e.g., 'Digest'). + - Algorithm: The hashing algorithm used (e.g., MD5, SHA-256). + +.OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - Header: HTTP response header for authentication challenges. + - Challenge: Optional authentication challenge. + +.NOTES + The function adheres to RFC 7616, which mandates: + - 401 Unauthorized for missing or invalid authentication credentials. + - 400 Bad Request for malformed requests. + + - RFC 7616 HTTP Status Code Usage + | Scenario | Recommended Status Code | + |-------------------------------------------|-------------------------| + | No Authorization header provided | 401 Unauthorized | + | Incorrect Authorization header format | 401 Unauthorized | + | Wrong authentication scheme used | 401 Unauthorized | + | Token is empty or malformed | 400 Bad Request | + | Invalid digest response | 401 Unauthorized | + + #> +function Get-PodeAuthDigestType { + return { + param($options) + write-podehost "I'm here1" + # Define common WWW-Authenticate header with placeholders + $authHeaderBase = "Digest realm=`"$($options.Realm)`", nonce=`"$($options.Nonce)`", algorithm=`"$($options.Algorithm)`", error=`"{0}`", error_description=`"{1}`"" + + # Get the Authorization header - send challenge if missing + $header = (Get-PodeHeader -Name 'Authorization') + if ($null -eq $header) { + $message = 'No Authorization header found' + write-podehost $message + return @{ + Message = $message + Code = 401 # RFC 7616: Missing credentials should return 401 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # If auth header isn't digest, send challenge + $atoms = $header -isplit '\s+' + if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' + write-podehost $message + return @{ + Message = $message + Code = 401 # RFC 7616: Invalid credentials format should return 401 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + if ($atoms[0] -ine $options.HeaderTag) { + $message = "Authorization header is not $($options.HeaderTag)" + write-podehost $message + return @{ + Message = $message + Code = 401 # RFC 7616: Wrong authentication scheme should return 401 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # Parse the other atoms of the header (after the scheme), return 400 if none + $params = ConvertFrom-PodeAuthDigestHeader -Parts ($atoms[1..$($atoms.Length - 1)]) + if ($params.Count -eq 0) { + $message = 'Invalid Authorization header' + write-podehost $message + return @{ + Message = $message + Code = 400 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # If no username then 401 and challenge + if ([string]::IsNullOrWhiteSpace($params.username)) { + $message = 'Authorization header is missing username' + write-podehost $message + return @{ + Message = $message + Challenge = (New-PodeAuthDigestChallenge) + Code = 401 + Header = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + # Return 400 if domain doesn't match request domain + if ($WebEvent.Path -ine $params.uri) { + $message = 'Invalid Authorization header' + write-podehost $message + return @{ + Message = $message + Code = 400 + Headers = @{'WWW-Authenticate' = $authHeaderBase -f 'invalid_request', $message } + } + } + + write-podehost "I'm here2" + # Return data for calling validator + return @($params.username, $params) + } +} + + +function Get-PodeAuthDigestPostValidator { + return { + param($username, $params, $result, $options) + + # if there's no user or password, fail with challenge + if (($null -eq $result) -or ($null -eq $result.User) -or [string]::IsNullOrWhiteSpace($result.Password)) { + return @{ + Message = 'User not found' + Challenge = (New-PodeAuthDigestChallenge) + Code = 401 + } + } + + # generate the first hash + $hash1 = Invoke-PodeMD5Hash -Value "$($params.username):$($params.realm):$($result.Password)" + + # generate the second hash + $hash2 = Invoke-PodeMD5Hash -Value "$($WebEvent.Method.ToUpperInvariant()):$($params.uri)" + + # generate final hash + $final = Invoke-PodeMD5Hash -Value "$($hash1):$($params.nonce):$($params.nc):$($params.cnonce):$($params.qop):$($hash2)" + + # compare final hash to client response + if ($final -ne $params.response) { + return @{ + Message = 'Hashes failed to match' + Challenge = (New-PodeAuthDigestChallenge) + Code = 401 + } + } + + # hashes are valid, remove password and return result + $null = $result.Remove('Password') + return $result + } +} + +function ConvertFrom-PodeAuthDigestHeader { + param( + [Parameter()] + [string[]] + $Parts + ) + + if (($null -eq $Parts) -or ($Parts.Length -eq 0)) { + return @{} + } + + $obj = @{} + $value = ($Parts -join ' ') + + @($value -isplit ',(?=(?:[^"]|"[^"]*")*$)') | ForEach-Object { + if ($_ -imatch '(?\w+)=["]?(?[^"]+)["]?$') { + $obj[$Matches['name']] = $Matches['value'] + } + } + + return $obj +} + +function New-PodeAuthDigestChallenge { + $items = @('qop="auth"', 'algorithm="MD5"', "nonce=`"$(New-PodeGuid -Secure -NoDashes)`"") + return ($items -join ', ') +} + +function Get-PodeAuthFormType { + return { + param($options) + + # get user/pass keys to get from payload + $userField = $options.Fields.Username + $passField = $options.Fields.Password + + # get the user/pass + $username = $WebEvent.Data.$userField + $password = $WebEvent.Data.$passField + + # if either are empty, fail auth + if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) { + return @{ + Message = 'Username or Password not supplied' + Code = 401 + } + } + + # build the result + $result = @($username, $password) + + # convert to credential? + if ($options.AsCredential) { + $passSecure = ConvertTo-SecureString -String $password -AsPlainText -Force + $creds = [pscredential]::new($username, $passSecure) + $result = @($creds) + } + + # return data for calling validator + return $result + } +} + +<# +.SYNOPSIS + Authenticates a user based on a username and password provided as parameters. + +.DESCRIPTION + This function finds a user whose username matches the provided username, and checks the user's password. + If the password is correct, it converts the user into a hashtable and checks if the user is valid for any users/groups specified by the options parameter. If the user is valid, it returns a hashtable containing the user object. If the user is not valid, it returns a hashtable with a message indicating that the user is not authorized to access the website. + +.PARAMETER username + The username of the user to authenticate. + +.PARAMETER password + The password of the user to authenticate. + +.PARAMETER options + A hashtable containing options for the function. It can include the following keys: + - FilePath: The path to the JSON file containing user data. + - HmacSecret: The secret key for computing a HMAC-SHA256 hash of the password. + - Users: A list of valid users. + - Groups: A list of valid groups. + - ScriptBlock: A script block for additional validation. + +.EXAMPLE + Get-PodeAuthUserFileMethod -username "admin" -password "password123" -options @{ FilePath = "C:\Users.json"; HmacSecret = "secret"; Users = @("admin"); Groups = @("Administrators"); ScriptBlock = { param($user) $user.Name -eq "admin" } } + + This example authenticates a user with username "admin" and password "password123". It reads user data from the JSON file at "C:\Users.json", computes a HMAC-SHA256 hash of the password using "secret" as the secret key, and checks if the user is in the "admin" user or "Administrators" group. It also performs additional validation using a script block that checks if the user's name is "admin". +#> +function Get-PodeAuthUserFileMethod { + return { + param($username, $password, $options) + + # using pscreds? + if (($null -eq $options) -and ($username -is [pscredential])) { + $_username = ([pscredential]$username).UserName + $_password = ([pscredential]$username).GetNetworkCredential().Password + $_options = [hashtable]$password + } + else { + $_username = $username + $_password = $password + $_options = $options + } + + # load the file + $users = (Get-Content -Path $_options.FilePath -Raw | ConvertFrom-Json) + + # find the user by username - only use the first one + $user = @(foreach ($_user in $users) { + if ($_user.Username -ieq $_username) { + $_user + break + } + })[0] + + # fail if no user + if ($null -eq $user) { + return @{ Message = 'You are not authorised to access this website' } + } + + # check the user's password + if (![string]::IsNullOrWhiteSpace($_options.HmacSecret)) { + $hash = Invoke-PodeHMACSHA256Hash -Value $_password -Secret $_options.HmacSecret + } + else { + $hash = Invoke-PodeSHA256Hash -Value $_password + } + + if ($user.Password -ne $hash) { + return @{ Message = 'You are not authorised to access this website' } + } + + # convert the user to a hashtable + $user = @{ + Name = $user.Name + Username = $user.Username + Email = $user.Email + Groups = $user.Groups + Metadata = $user.Metadata + } + + # is the user valid for any users/groups? + if (!(Test-PodeAuthUserGroup -User $user -Users $_options.Users -Groups $_options.Groups)) { + return @{ Message = 'You are not authorised to access this website' } + } + + $result = @{ User = $user } + + # call additional scriptblock if supplied + if ($null -ne $_options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $_options.ScriptBlock.Script -UsingVariables $_options.ScriptBlock.UsingVariables + } + + # return final result, this could contain a user obj, or an error message from custom scriptblock + return $result + } +} + +function Get-PodeAuthWindowsADMethod { + return { + param($username, $password, $options) + + # using pscreds? + if (($null -eq $options) -and ($username -is [pscredential])) { + $_username = ([pscredential]$username).UserName + $_password = ([pscredential]$username).GetNetworkCredential().Password + $_options = [hashtable]$password + } + else { + $_username = $username + $_password = $password + $_options = $options + } + + # parse username to remove domains + $_username = (($_username -split '@')[0] -split '\\')[-1] + + # validate and retrieve the AD user + $noGroups = $_options.NoGroups + $directGroups = $_options.DirectGroups + $keepCredential = $_options.KeepCredential + + $result = Get-PodeAuthADResult ` + -Server $_options.Server ` + -Domain $_options.Domain ` + -SearchBase $_options.SearchBase ` + -Username $_username ` + -Password $_password ` + -Provider $_options.Provider ` + -NoGroups:$noGroups ` + -DirectGroups:$directGroups ` + -KeepCredential:$keepCredential + + # if there's a message, fail and return the message + if (![string]::IsNullOrWhiteSpace($result.Message)) { + return $result + } + + # if there's no user, then, err, oops + if (Test-PodeIsEmpty $result.User) { + return @{ Message = 'An unexpected error occured' } + } + + # is the user valid for any users/groups - if not, error! + if (!(Test-PodeAuthUserGroup -User $result.User -Users $_options.Users -Groups $_options.Groups)) { + return @{ Message = 'You are not authorised to access this website' } + } + + # call additional scriptblock if supplied + if ($null -ne $_options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $_options.ScriptBlock.Script -UsingVariables $_options.ScriptBlock.UsingVariables + } + + # return final result, this could contain a user obj, or an error message from custom scriptblock + return $result + } +} + +function Invoke-PodeAuthInbuiltScriptBlock { + param( + [Parameter(Mandatory = $true)] + [hashtable] + $User, + + [Parameter(Mandatory = $true)] + [scriptblock] + $ScriptBlock, + + [Parameter()] + $UsingVariables + ) + + return (Invoke-PodeScriptBlock -ScriptBlock $ScriptBlock -Arguments $User -UsingVariables $UsingVariables -Return) +} + +function Get-PodeAuthWindowsLocalMethod { + return { + param($username, $password, $options) + + # using pscreds? + if (($null -eq $options) -and ($username -is [pscredential])) { + $_username = ([pscredential]$username).UserName + $_password = ([pscredential]$username).GetNetworkCredential().Password + $_options = [hashtable]$password + } + else { + $_username = $username + $_password = $password + $_options = $options + } + + $user = @{ + UserType = 'Local' + AuthenticationType = 'WinNT' + Username = $_username + Name = [string]::Empty + Fqdn = $PodeContext.Server.ComputerName + Domain = 'localhost' + Groups = @() + } + + Add-Type -AssemblyName System.DirectoryServices.AccountManagement -ErrorAction Stop + $context = [System.DirectoryServices.AccountManagement.PrincipalContext]::new('Machine', $PodeContext.Server.ComputerName) + $valid = $context.ValidateCredentials($_username, $_password) + + if (!$valid) { + return @{ Message = 'Invalid credentials supplied' } + } + + try { + $tmpUsername = $_username -replace '\\', '/' + if ($_username -inotlike "$($PodeContext.Server.ComputerName)*") { + $tmpUsername = "$($PodeContext.Server.ComputerName)/$($_username)" + } + + $ad = [adsi]"WinNT://$($tmpUsername)" + $user.Name = @($ad.FullName)[0] + + if (!$_options.NoGroups) { + $cmd = "`$ad = [adsi]'WinNT://$($tmpUsername)'; @(`$ad.Groups() | Foreach-Object { `$_.GetType().InvokeMember('Name', 'GetProperty', `$null, `$_, `$null) })" + $user.Groups = [string[]](powershell -c $cmd) + } + } + finally { + Close-PodeDisposable -Disposable $ad -Close + } + + # is the user valid for any users/groups - if not, error! + if (!(Test-PodeAuthUserGroup -User $user -Users $_options.Users -Groups $_options.Groups)) { + return @{ Message = 'You are not authorised to access this website' } + } + + $result = @{ User = $user } + + # call additional scriptblock if supplied + if ($null -ne $_options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $_options.ScriptBlock.Script -UsingVariables $_options.ScriptBlock.UsingVariables + } + + # return final result, this could contain a user obj, or an error message from custom scriptblock + return $result + } +} + +function Get-PodeAuthWindowsADIISMethod { + return { + param($token, $options) + + # get the close handler + $win32Handler = Add-Type -Name Win32CloseHandle -PassThru -MemberDefinition @' + [DllImport("kernel32.dll", SetLastError = true)] + public static extern bool CloseHandle(IntPtr handle); +'@ + + try { + # parse the auth token and get the user + $winAuthToken = [System.IntPtr][Int]"0x$($token)" + $winIdentity = [System.Security.Principal.WindowsIdentity]::new($winAuthToken, 'Windows') + + # get user and domain + $username = ($winIdentity.Name -split '\\')[-1] + $domain = ($winIdentity.Name -split '\\')[0] + + # create base user object + $user = @{ + UserType = 'Domain' + Identity = @{ + AccessToken = $winIdentity.AccessToken + } + AuthenticationType = $winIdentity.AuthenticationType + DistinguishedName = [string]::Empty + Username = $username + Name = [string]::Empty + Email = [string]::Empty + Fqdn = [string]::Empty + Domain = $domain + Groups = @() + } + + # if the domain isn't local, attempt AD user + if (![string]::IsNullOrWhiteSpace($domain) -and (@('.', $PodeContext.Server.ComputerName) -inotcontains $domain)) { + # get the server's fdqn (and name/email) + try { + # Open ADSISearcher and change context to given domain + $searcher = [adsisearcher]'' + $searcher.SearchRoot = [adsi]"LDAP://$($domain)" + $searcher.Filter = "ObjectSid=$($winIdentity.User.Value.ToString())" + + # Query the ADSISearcher for the above defined SID + $ad = $searcher.FindOne() + + # Save it to our existing array for later usage + $user.DistinguishedName = @($ad.Properties.distinguishedname)[0] + $user.Name = @($ad.Properties.name)[0] + $user.Email = @($ad.Properties.mail)[0] + $user.Fqdn = (Get-PodeADServerFromDistinguishedName -DistinguishedName $user.DistinguishedName) + } + finally { + Close-PodeDisposable -Disposable $searcher + } + + try { + if (!$options.NoGroups) { + + # open a new connection + $result = (Open-PodeAuthADConnection -Server $user.Fqdn -Domain $domain -Provider $options.Provider) + if (!$result.Success) { + return @{ Message = "Failed to connect to Domain Server '$($user.Fqdn)' of $domain for $($user.DistinguishedName)." } + } + + # get the connection + $connection = $result.Connection + + # get the users groups + $directGroups = $options.DirectGroups + $user.Groups = (Get-PodeAuthADGroup -Connection $connection -DistinguishedName $user.DistinguishedName -Username $user.Username -Direct:$directGroups -Provider $options.Provider) + } + } + finally { + if ($null -ne $connection) { + Close-PodeDisposable -Disposable $connection.Searcher + Close-PodeDisposable -Disposable $connection.Entry -Close + $connection.Credential = $null + } + } + } + + # otherwise, get details of local user + else { + # get the user's name and groups + try { + $user.UserType = 'Local' + + if (!$options.NoLocalCheck) { + $localUser = $winIdentity.Name -replace '\\', '/' + $ad = [adsi]"WinNT://$($localUser)" + $user.Name = @($ad.FullName)[0] + + # dirty, i know :/ - since IIS runs using pwsh, the InvokeMember part fails + # we can safely call windows powershell here, as IIS is only on windows. + if (!$options.NoGroups) { + $cmd = "`$ad = [adsi]'WinNT://$($localUser)'; @(`$ad.Groups() | Foreach-Object { `$_.GetType().InvokeMember('Name', 'GetProperty', `$null, `$_, `$null) })" + $user.Groups = [string[]](powershell -c $cmd) + } + } + } + finally { + Close-PodeDisposable -Disposable $ad -Close + } + } + } + catch { + $_ | Write-PodeErrorLog + return @{ Message = 'Failed to retrieve user using Authentication Token' } + } + finally { + $win32Handler::CloseHandle($winAuthToken) + } + + # is the user valid for any users/groups - if not, error! + if (!(Test-PodeAuthUserGroup -User $user -Users $options.Users -Groups $options.Groups)) { + return @{ Message = 'You are not authorised to access this website' } + } + + $result = @{ User = $user } + + # call additional scriptblock if supplied + if ($null -ne $options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $options.ScriptBlock.Script -UsingVariables $options.ScriptBlock.UsingVariables + } + + # return final result, this could contain a user obj, or an error message from custom scriptblock + return $result + } +} + +<# +.SYNOPSIS + Authenticates a user based on group membership or specific user authorization. + +.DESCRIPTION + This function checks if a given user is authorized based on supplied lists of users and groups. The user is considered authorized if their username is directly specified in the list of users, or if they are a member of any of the specified groups. + +.PARAMETER User + A hashtable representing the user, expected to contain at least the 'Username' and 'Groups' keys. + +.PARAMETER Users + An optional array of usernames. If specified, the function checks if the user's username exists in this list. + +.PARAMETER Groups + An optional array of group names. If specified, the function checks if the user belongs to any of these groups. + +.EXAMPLE + $user = @{ Username = 'john.doe'; Groups = @('Administrators', 'Users') } + $authorizedUsers = @('john.doe', 'jane.doe') + $authorizedGroups = @('Administrators') + + Test-PodeAuthUserGroup -User $user -Users $authorizedUsers -Groups $authorizedGroups + # Returns true if John Doe is either listed as an authorized user or is a member of an authorized group. +#> +function Test-PodeAuthUserGroup { + param( + [Parameter(Mandatory = $true)] + [hashtable] + $User, + + [Parameter()] + [string[]] + $Users, + + [Parameter()] + [string[]] + $Groups + ) + + $haveUsers = (($null -ne $Users) -and ($Users.Length -gt 0)) + $haveGroups = (($null -ne $Groups) -and ($Groups.Length -gt 0)) + + # if there are no groups/users supplied, return user is valid + if (!$haveUsers -and !$haveGroups) { + return $true + } + + # before checking supplied groups, is the user in the supplied list of authorised users? + if ($haveUsers -and (@($Users) -icontains $User.Username)) { + return $true + } + + # if there are groups supplied, check the user is a member of one + if ($haveGroups) { + foreach ($group in $Groups) { + if (@($User.Groups) -icontains $group) { + return $true + } + } + } + + return $false +} + +function Invoke-PodeAuthValidation { + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + # get auth method + $auth = $PodeContext.Server.Authentications.Methods[$Name] + + # if it's a merged auth, re-call this function and check against "succeed" value + if ($auth.Merged) { + $results = @{} + foreach ($authName in $auth.Authentications) { + $result = Invoke-PodeAuthValidation -Name $authName + + # if the auth is trying to redirect, we need to bubble the this back now + if ($result.Redirected) { + return $result + } + + # if the auth passed, and we only need one auth to pass, return current result + if ($result.Success -and $auth.PassOne) { + return $result + } + + # if the auth failed, but we need all to pass, return current result + if (!$result.Success -and !$auth.PassOne) { + return $result + } + + # remember result if we need all to pass + if (!$auth.PassOne) { + $results[$authName] = $result + } + } + # if the last auth failed, and we only need one auth to pass, set failure and return + if (!$result.Success -and $auth.PassOne) { + return $result + } + + # if the last auth succeeded, and we need all to pass, merge users/headers and return result + if ($result.Success -and !$auth.PassOne) { + # invoke scriptblock, or use result of merge default + if ($null -ne $auth.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $results -ScriptBlock $auth.ScriptBlock.Script -UsingVariables $auth.ScriptBlock.UsingVariables + } + else { + $result = $results[$auth.MergeDefault] + } + + # reset default properties and return + $result.Success = $true + $result.Auth = $results.Keys + return $result + } + + # default failure + return @{ + Success = $false + StatusCode = 500 + } + } + + # main auth validation logic + $result = (Test-PodeAuthValidation -Name $Name) + $result.Auth = $Name + return $result +} + +<# +.SYNOPSIS + Tests the authentication validation for a specified authentication method. + +.DESCRIPTION + The `Test-PodeAuthValidation` function processes an authentication method by its name, + running the associated scripts, middleware, and validations to determine authentication success or failure. + +.PARAMETER Name + The name of the authentication method to validate. This parameter is mandatory. + +.PARAMETER NoMiddlewareAuthentication + A switch to indicate whether the function has to threat the authentication because no Middleware authentication has been executed. + +.OUTPUTS + A hashtable containing the authentication validation result, including success status, user details, + headers, and redirection information if applicable. + +.NOTES + This is an internal function and is subject to change in future versions of Pode. +#> +function Test-PodeAuthValidation { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $NoMiddlewareAuthentication + ) + + try { + # Retrieve authentication method configuration from Pode context + $auth = $PodeContext.Server.Authentications.Methods[$Name] + + # Initialize authentication result variable + $result = $null + + # Run pre-authentication middleware if defined + if ($null -ne $auth.Scheme.Middleware) { + if (!(Invoke-PodeMiddleware -Middleware $auth.Scheme.Middleware)) { + return @{ + Success = $false + } + } + } + + # Prepare arguments for the authentication scheme script + $_args = @(Merge-PodeScriptblockArguments -ArgumentList $auth.Scheme.Arguments -UsingVariables $auth.Scheme.ScriptBlock.UsingVariables) + + # Handle inner authentication schemes (if any) + if ($null -ne $auth.Scheme.InnerScheme) { + $schemes = @() + $_scheme = $auth.Scheme + + # Traverse through the inner schemes to collect them + $_inner = @(while ($null -ne $_scheme.InnerScheme) { + $_scheme = $_scheme.InnerScheme + $_scheme + }) + + # Process inner schemes in reverse order + for ($i = $_inner.Length - 1; $i -ge 0; $i--) { + $_tmp_args = @(Merge-PodeScriptblockArguments -ArgumentList $_inner[$i].Arguments -UsingVariables $_inner[$i].ScriptBlock.UsingVariables) + $_tmp_args += , $schemes + + $result = (Invoke-PodeScriptBlock -ScriptBlock $_inner[$i].ScriptBlock.Script -Arguments $_tmp_args -Return -Splat) + if ($result -is [hashtable]) { + break # Exit if a valid result is returned + } + + $schemes += , $result + $result = $null + } + + $_args += , $schemes + } + + # Execute the primary authentication script if no result from inner schemes and not a route script + if ($null -eq $result) { + $result = (Invoke-PodeScriptBlock -ScriptBlock $auth.Scheme.ScriptBlock.Script -Arguments $_args -Return -Splat) + } + + # Remove the Middleware processed data if code is 400 - no token + if ($NoMiddlewareAuthentication -and (($result.Code -eq 400) -or ($result.Code -eq 401))) { + $headers = $result.Headers + $result = '' + $code = 401 + } + write-podehost $result -Explode + # If authentication script returns a non-hashtable, perform further validation + if ($result -isnot [hashtable]) { + $original = $result + $_args = @($result) + @($auth.Arguments) + + # Run main authentication validation script + $result = (Invoke-PodeScriptBlock -ScriptBlock $auth.ScriptBlock -Arguments $_args -UsingVariables $auth.UsingVariables -Return -Splat) + + # Run post-authentication validation if applicable + if ([string]::IsNullOrEmpty($result.Code) -and ($null -ne $auth.Scheme.PostValidator.Script)) { + $_args = @($original) + @($result) + @($auth.Scheme.Arguments) + $result = (Invoke-PodeScriptBlock -ScriptBlock $auth.Scheme.PostValidator.Script -Arguments $_args -UsingVariables $auth.Scheme.PostValidator.UsingVariables -Return -Splat) + } + } + else { + # if ($result.Headers.ContainsKey('WWW-Authenticate')) { + # Add-PodeHeader -Name 'WWW-Authenticate' -Value $result.Headers['WWW-Authenticate'] + # } + } + write-podehost $result -Explode + # Handle authentication redirection scenarios (e.g., OAuth) + if ($result.IsRedirected) { + return @{ + Success = $false + Redirected = $true + } + } + + # Handle results when invoked from a route script + if ($NoMiddlewareAuthentication -and ($null -ne $result) -and ($result -is [hashtable])) { + if ($result.Success -is [bool]) { + $success = $result.Success + } + else { + $success = $false + [System.Exception]::new("The authentication Scriptblock must return an hashtable with a key named 'Success'") | Write-PodeErrorLog + } + + $ret = @{ + Success = $success + User = '' + Headers = $headers + IsAuthenticated = $success + IsAuthorised = $success + Store = !$auth.Sessionless + Name = $Name + } + foreach ($key in $result.Keys) { + $ret[$key] = $result[$key] # Overwrites if key exists + } + + return $ret + } + + # Authentication failure handling + if (($null -eq $result) -or ($result.Count -eq 0) -or (Test-PodeIsEmpty $result.User)) { + $code = (Protect-PodeValue -Value $result.Code -Default 401) + + # Set WWW-Authenticate header for appropriate HTTP response + $validCode = (($code -eq 401) -or ![string]::IsNullOrEmpty($result.Challenge)) + Write-podehost "validCode =$validCode" + if ($validCode) { + if ($null -eq $result) { + $result = @{} + } + + if ($null -eq $result.Headers) { + $result.Headers = @{} + } + + # Generate authentication challenge header + if (![string]::IsNullOrWhiteSpace($auth.Scheme.Name) -and !$result.Headers.ContainsKey('WWW-Authenticate')) { + write-podehost 'Get-PodeAuthWwwHeaderValue' + $authHeader = Get-PodeAuthWwwHeaderValue -Name $auth.Scheme.Name -Realm $auth.Scheme.Realm -Challenge $result.Challenge + $result.Headers['WWW-Authenticate'] = $authHeader + } + else { + $authHeader = Get-PodeAuthWwwHeaderValue -Name $auth.Scheme.Name -Realm $auth.Scheme.Realm -Challenge $result.Challenge + write-podehost $authHeader + $result.Headers['WWW-Authenticate'] = $authHeader + write-podehost 'no Get-PodeAuthWwwHeaderValue' + } + } + + return @{ + Success = $false + StatusCode = $code + Description = $result.Message + Headers = $result.Headers + FailureRedirect = [bool]$result.IsErrored + } + } + + # Authentication succeeded, return user and headers + return @{ + Success = $true + User = $result.User + Headers = $result.Headers + } + } + catch { + $_ | Write-PodeErrorLog + + # Handle unexpected errors and log them + return @{ + Success = $false + StatusCode = 500 + Exception = $_ + } + } +} + + +function Get-PodeAuthMiddlewareScript { + return { + param($opts) + + return Test-PodeAuthInternal ` + -Name $opts.Name ` + -Login:($opts.Login) ` + -Logout:($opts.Logout) ` + -AllowAnon:($opts.Anon) + } +} + +function Test-PodeAuthInternal { + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $Login, + + [switch] + $Logout, + + [switch] + $AllowAnon + ) + + # get the auth method + $auth = $PodeContext.Server.Authentications.Methods[$Name] + + # check for logout command + if ($Logout) { + Remove-PodeAuthSession + + if ($PodeContext.Server.Sessions.Info.UseHeaders) { + return Set-PodeAuthStatus ` + -StatusCode 401 ` + -Name $Name ` + -NoSuccessRedirect + } + else { + $auth.Failure.Url = (Protect-PodeValue -Value $auth.Failure.Url -Default $WebEvent.Request.Url.AbsolutePath) + return Set-PodeAuthStatus ` + -StatusCode 302 ` + -Name $Name ` + -NoSuccessRedirect + } + } + + # if the session already has a user/isAuth'd, then skip auth - or allow anon + if (Test-PodeSessionsInUse) { + # existing session auth'd + if (Test-PodeAuthUser) { + $WebEvent.Auth = $WebEvent.Session.Data.Auth + return Set-PodeAuthStatus ` + -Name $Name ` + -LoginRoute:($Login) ` + -NoSuccessRedirect + } + + # if we're allowing anon access, and using sessions, then stop here - as a session will be created from a login route for auth'ing users + if ($AllowAnon) { + if (!(Test-PodeIsEmpty $WebEvent.Session.Data.Auth)) { + Revoke-PodeSession + } + + return $true + } + } + + # check if the login flag is set, in which case just return and load a login get-page (allowing anon access) + if ($Login -and !$PodeContext.Server.Sessions.Info.UseHeaders -and ($WebEvent.Method -ieq 'get')) { + if (!(Test-PodeIsEmpty $WebEvent.Session.Data.Auth)) { + Revoke-PodeSession + } + + return $true + } + + try { + $result = Invoke-PodeAuthValidation -Name $Name + } + catch { + $_ | Write-PodeErrorLog + return Set-PodeAuthStatus ` + -StatusCode 500 ` + -Description $_.Exception.Message ` + -Name $Name + } + + # did the auth force a redirect? + if ($result.Redirected) { + $success = Get-PodeAuthSuccessInfo -Name $Name + Set-PodeAuthRedirectUrl -UseOrigin:($success.UseOrigin) + return $false + } + + # if auth failed, are we allowing anon access? + if (!$result.Success -and $AllowAnon) { + return $true + } + + # if auth failed, set appropriate response headers/redirects + if (!$result.Success) { + return Set-PodeAuthStatus ` + -StatusCode $result.StatusCode ` + -Description $result.Description ` + -Headers $result.Headers ` + -Name $Name ` + -LoginRoute:$Login ` + -NoFailureRedirect:($result.FailureRedirect) + } + + # if auth passed, assign the user to the session + $WebEvent.Auth = [ordered]@{ + User = $result.User + IsAuthenticated = $true + IsAuthorised = $true + Store = !$auth.Sessionless + Name = $result.Auth + } + # successful auth + $authName = $null + if ($auth.Merged -and !$auth.PassOne) { + $authName = $Name + } + else { + $authName = @($result.Auth)[0] + } + + return Set-PodeAuthStatus ` + -Headers $result.Headers ` + -Name $authName ` + -LoginRoute:$Login +} + +function Get-PodeAuthWwwHeaderValue { + param( + [Parameter()] + [string] + $Name, + + [Parameter()] + [string] + $Realm, + + [Parameter()] + [string] + $Challenge + ) + + if ([string]::IsNullOrWhiteSpace($Name)) { + return [string]::Empty + } + + $header = $Name + if (![string]::IsNullOrWhiteSpace($Realm)) { + $header += " realm=`"$($Realm)`"" + } + + if (![string]::IsNullOrWhiteSpace($Challenge)) { + $header += ", $($Challenge)" + } + + return $header +} + +function Remove-PodeAuthSession { + # blank out the auth + $WebEvent.Auth = @{} + + # if a session auth is found, blank it + if (!(Test-PodeIsEmpty $WebEvent.Session.Data.Auth)) { + $WebEvent.Session.Data.Remove('Auth') + } + + # Delete the current session (remove from store, blank it, and remove from Response) + Revoke-PodeSession +} + +function Get-PodeAuthFailureInfo { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [hashtable] + $Info, + + [Parameter()] + [string] + $BaseName + ) + + # base name + if ([string]::IsNullOrEmpty($BaseName)) { + $BaseName = $Name + } + + # get auth method + $auth = $PodeContext.Server.Authentications.Methods[$Name] + + # cached failure? + if ($null -ne $auth.Cache.Failure) { + return $auth.Cache.Failure + } + + # find failure info + if ($null -eq $Info) { + $Info = @{ + Url = $auth.Failure.Url + Message = $auth.Failure.Message + } + } + + if ([string]::IsNullOrEmpty($Info.Url)) { + $Info.Url = $auth.Failure.Url + } + + if ([string]::IsNullOrEmpty($Info.Message)) { + $Info.Message = $auth.Failure.Message + } + + if ((![string]::IsNullOrEmpty($Info.Url) -and ![string]::IsNullOrEmpty($Info.Message)) -or [string]::IsNullOrEmpty($auth.Parent)) { + $PodeContext.Server.Authentications.Methods[$BaseName].Cache.Failure = $Info + return $Info + } + + return (Get-PodeAuthFailureInfo -Name $auth.Parent -Info $Info -BaseName $BaseName) +} + +function Get-PodeAuthSuccessInfo { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [hashtable] + $Info, + + [Parameter()] + [string] + $BaseName + ) + + # base name + if ([string]::IsNullOrEmpty($BaseName)) { + $BaseName = $Name + } + + # get auth method + $auth = $PodeContext.Server.Authentications.Methods[$Name] + + # cached success? + if ($null -ne $auth.Cache.Success) { + return $auth.Cache.Success + } + + # find success info + if ($null -eq $Info) { + $Info = @{ + Url = $auth.Success.Url + UseOrigin = $auth.Success.UseOrigin + } + } + + if ([string]::IsNullOrEmpty($Info.Url)) { + $Info.Url = $auth.Success.Url + } + + if (!$Info.UseOrigin) { + $Info.UseOrigin = $auth.Success.UseOrigin + } + + if ((![string]::IsNullOrEmpty($Info.Url) -and $Info.UseOrigin) -or [string]::IsNullOrEmpty($auth.Parent)) { + $PodeContext.Server.Authentications.Methods[$BaseName].Cache.Success = $Info + return $Info + } + + return (Get-PodeAuthSuccessInfo -Name $auth.Parent -Info $Info -BaseName $BaseName) +} + +function Set-PodeAuthStatus { + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [int] + $StatusCode = 0, + + [Parameter()] + [string] + $Description, + + [Parameter()] + [hashtable] + $Headers, + + [switch] + $LoginRoute, + + [switch] + $NoSuccessRedirect, + + [switch] + $NoFailureRedirect + ) + + # if we have any headers, set them + if (($null -ne $Headers) -and ($Headers.Count -gt 0)) { + foreach ($key in $Headers.Keys) { + Set-PodeHeader -Name $key -Value $Headers[$key] + } + } + + # get auth method + $auth = $PodeContext.Server.Authentications.Methods[$Name] + + # get Success object from auth + $success = Get-PodeAuthSuccessInfo -Name $Name + + # if a statuscode supplied, assume failure + if ($StatusCode -gt 0) { + # get Failure object from auth + $failure = Get-PodeAuthFailureInfo -Name $Name + + # override description with the failureMessage if supplied + $Description = (Protect-PodeValue -Value $failure.Message -Default $Description) + + # add error to flash + if ($LoginRoute -and !$auth.Sessionless -and ![string]::IsNullOrWhiteSpace($Description)) { + Add-PodeFlashMessage -Name 'auth-error' -Message $Description + } + + # check if we have a failure url redirect + if (!$NoFailureRedirect -and ![string]::IsNullOrWhiteSpace($failure.Url)) { + Set-PodeAuthRedirectUrl -UseOrigin:($success.UseOrigin) + Move-PodeResponseUrl -Url $failure.Url + } + else { + Set-PodeResponseStatus -Code $StatusCode -Description $Description + } + + return $false + } + + # if no statuscode, success, so check if we have a success url redirect (but only for auto-login routes) + if (!$NoSuccessRedirect -or $LoginRoute) { + $url = Get-PodeAuthRedirectUrl -Url $success.Url -UseOrigin:($success.UseOrigin) + if (![string]::IsNullOrWhiteSpace($url)) { + Move-PodeResponseUrl -Url $url + return $false + } + } + + return $true +} + +function Get-PodeADServerFromDistinguishedName { + param( + [Parameter()] + [string] + $DistinguishedName + ) + + if ([string]::IsNullOrWhiteSpace($DistinguishedName)) { + return [string]::Empty + } + + $parts = @($DistinguishedName -split ',') + $name = @() + + foreach ($part in $parts) { + if ($part -imatch '^DC=(?.+)$') { + $name += $Matches['name'] + } + } + + return ($name -join '.') +} + +function Get-PodeAuthADResult { + param( + [Parameter()] + [string] + $Server, + + [Parameter()] + [string] + $Domain, + + [Parameter()] + [string] + $SearchBase, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [string] + $Password, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider, + + [switch] + $NoGroups, + + [switch] + $DirectGroups, + + [switch] + $KeepCredential + ) + + try { + # validate the user's AD creds + $result = (Open-PodeAuthADConnection -Server $Server -Domain $Domain -Username $Username -Password $Password -Provider $Provider) + if (!$result.Success) { + return @{ Message = 'Invalid credentials supplied' } + } + + # get the connection + $connection = $result.Connection + + # get the user + $user = (Get-PodeAuthADUser -Connection $connection -Username $Username -Provider $Provider) + if ($null -eq $user) { + return @{ Message = 'User not found in Active Directory' } + } + + # get the users groups + $groups = @() + if (!$NoGroups) { + $groups = (Get-PodeAuthADGroup -Connection $connection -DistinguishedName $user.DistinguishedName -Username $Username -Direct:$DirectGroups -Provider $Provider) + } + + # check if we want to keep the credentials in the User object + if ($KeepCredential) { + $credential = [pscredential]::new($($Domain + '\' + $Username), (ConvertTo-SecureString -String $Password -AsPlainText -Force)) + } + else { + $credential = $null + } + + # return the user + return @{ + User = @{ + UserType = 'Domain' + AuthenticationType = 'LDAP' + DistinguishedName = $user.DistinguishedName + Username = ($Username -split '\\')[-1] + Name = $user.Name + Email = $user.Email + Fqdn = $Server + Domain = $Domain + Groups = $groups + Credential = $credential + } + } + } + finally { + if ($null -ne $connection) { + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $connection.Username = $null + $connection.Password = $null + } + + 'activedirectory' { + $connection.Credential = $null + } + + 'directoryservices' { + Close-PodeDisposable -Disposable $connection.Searcher + Close-PodeDisposable -Disposable $connection.Entry -Close + } + } + } + } +} + +function Open-PodeAuthADConnection { + param( + [Parameter(Mandatory = $true)] + [string] + $Server, + + [Parameter()] + [string] + $Domain, + + [Parameter()] + [string] + $SearchBase, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [string] + $Password, + + [Parameter()] + [ValidateSet('LDAP', 'WinNT')] + [string] + $Protocol = 'LDAP', + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + $result = $true + $connection = $null + + # validate the user's AD creds + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + if (![string]::IsNullOrWhiteSpace($SearchBase)) { + $baseDn = $SearchBase + } + else { + $baseDn = "DC=$(($Server -split '\.') -join ',DC=')" + } + + $query = (Get-PodeAuthADQuery -Username $Username) + $hostname = "$($Protocol)://$($Server)" + + $user = $Username + if (!$Username.StartsWith($Domain)) { + $user = "$($Domain)\$($Username)" + } + + $null = (ldapsearch -x -LLL -H "$($hostname)" -D "$($user)" -w "$($Password)" -b "$($baseDn)" -o ldif-wrap=no "$($query)" dn) + if (!$? -or ($LASTEXITCODE -ne 0)) { + $result = $false + } + else { + $connection = @{ + Hostname = $hostname + Username = $user + BaseDN = $baseDn + Password = $Password + } + } + } + + 'activedirectory' { + try { + $creds = [pscredential]::new($Username, (ConvertTo-SecureString -String $Password -AsPlainText -Force)) + $null = Get-ADUser -Identity $Username -Credential $creds -ErrorAction Stop + $connection = @{ + Credential = $creds + } + } + catch { + $result = $false + } + } + + 'directoryservices' { + if ([string]::IsNullOrWhiteSpace($Password)) { + $ad = [System.DirectoryServices.DirectoryEntry]::new("$($Protocol)://$($Server)") + } + else { + $ad = [System.DirectoryServices.DirectoryEntry]::new("$($Protocol)://$($Server)", "$($Username)", "$($Password)") + } + + if (Test-PodeIsEmpty $ad.distinguishedName) { + $result = $false + } + else { + $connection = @{ + Entry = $ad + } + } + } + } + + return @{ + Success = $result + Connection = $connection + } +} + +function Get-PodeAuthADQuery { + param( + [Parameter(Mandatory = $true)] + [string] + $Username + ) + + return "(&(objectCategory=person)(samaccountname=$($Username)))" +} + +function Get-PodeAuthADUser { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter(Mandatory = $true)] + [string] + $Username, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + $query = (Get-PodeAuthADQuery -Username $Username) + $user = $null + + # generate query to find user + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" name mail) + if (!$? -or ($LASTEXITCODE -ne 0)) { + return $null + } + + $user = @{ + DistinguishedName = (Get-PodeOpenLdapValue -Lines $result -Property 'dn') + Name = (Get-PodeOpenLdapValue -Lines $result -Property 'name') + Email = (Get-PodeOpenLdapValue -Lines $result -Property 'mail') + } + } + + 'activedirectory' { + $result = Get-ADUser -LDAPFilter $query -Credential $Connection.Credential -Properties mail + $user = @{ + DistinguishedName = $result.DistinguishedName + Name = $result.Name + Email = $result.mail + } + } + + 'directoryservices' { + $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) + $Connection.Searcher.filter = $query + + $result = $Connection.Searcher.FindOne().Properties + if (Test-PodeIsEmpty $result) { + return $null + } + + $user = @{ + DistinguishedName = @($result.distinguishedname)[0] + Name = @($result.name)[0] + Email = @($result.mail)[0] + } + } + } + + return $user +} + +function Get-PodeOpenLdapValue { + param( + [Parameter()] + [string[]] + $Lines, + + [Parameter()] + [string] + $Property, + + [switch] + $All + ) + + foreach ($line in $Lines) { + if ($line -imatch "^$($Property)\:\s+(?<$($Property)>.+)$") { + # return the first found + if (!$All) { + return $Matches[$Property] + } + + # return array of all + $Matches[$Property] + } + } +} +<# +.SYNOPSIS + Retrieves Active Directory (AD) group information for a user. + +.DESCRIPTION + This function retrieves AD group information for a specified user. It supports two modes of operation: + 1. Direct: Retrieves groups directly associated with the user. + 2. All: Retrieves all groups within the specified distinguished name (DN). + +.PARAMETER Connection + The AD connection object or credentials for connecting to the AD server. + +.PARAMETER DistinguishedName + The distinguished name (DN) of the user or group. If not provided, the default DN is used. + +.PARAMETER Username + The username for which to retrieve group information. + +.PARAMETER Provider + The AD provider to use (e.g., 'DirectoryServices', 'ActiveDirectory', 'OpenLDAP'). + +.PARAMETER Direct + Switch parameter. If specified, retrieves only direct group memberships for the user. + +.OUTPUTS + Returns AD group information as needed based on the mode of operation. + +.EXAMPLE + Get-PodeAuthADGroup -Connection $adConnection -Username "john.doe" + # Retrieves all AD groups for the user "john.doe". + + Get-PodeAuthADGroup -Connection $adConnection -Username "jane.smith" -Direct + # Retrieves only direct group memberships for the user "jane.smith". +#> +function Get-PodeAuthADGroup { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter()] + [string] + $DistinguishedName, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider, + + [switch] + $Direct + ) + + if ($Direct) { + return (Get-PodeAuthADGroupDirect -Connection $Connection -Username $Username -Provider $Provider) + } + + return (Get-PodeAuthADGroupAll -Connection $Connection -DistinguishedName $DistinguishedName -Provider $Provider) +} + +function Get-PodeAuthADGroupDirect { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter()] + [string] + $Username, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + # create the query + $query = "(&(objectCategory=person)(samaccountname=$($Username)))" + $groups = @() + + # get the groups + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" memberof) + $groups = (Get-PodeOpenLdapValue -Lines $result -Property 'memberof' -All) + } + + 'activedirectory' { + $groups = (Get-ADPrincipalGroupMembership -Identity $Username -Credential $Connection.Credential).distinguishedName + } + + 'directoryservices' { + if ($null -eq $Connection.Searcher) { + $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) + } + + $Connection.Searcher.filter = $query + $groups = @($Connection.Searcher.FindOne().Properties.memberof) + } + } + + $groups = @(foreach ($group in $groups) { + if ($group -imatch '^CN=(?.+?),') { + $Matches['group'] + } + }) + + return $groups +} + +function Get-PodeAuthADGroupAll { + param( + [Parameter(Mandatory = $true)] + $Connection, + + [Parameter()] + [string] + $DistinguishedName, + + [Parameter()] + [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [string] + $Provider + ) + + # create the query + $query = "(member:1.2.840.113556.1.4.1941:=$($DistinguishedName))" + $groups = @() + + # get the groups + switch ($Provider.ToLowerInvariant()) { + 'openldap' { + $result = (ldapsearch -x -LLL -H "$($Connection.Hostname)" -D "$($Connection.Username)" -w "$($Connection.Password)" -b "$($Connection.BaseDN)" -o ldif-wrap=no "$($query)" samaccountname) + $groups = (Get-PodeOpenLdapValue -Lines $result -Property 'sAMAccountName' -All) + } + + 'activedirectory' { + $groups = (Get-ADObject -LDAPFilter $query -Credential $Connection.Credential).Name + } + + 'directoryservices' { + if ($null -eq $Connection.Searcher) { + $Connection.Searcher = [System.DirectoryServices.DirectorySearcher]::new($Connection.Entry) + } + + $null = $Connection.Searcher.PropertiesToLoad.Add('samaccountname') + $Connection.Searcher.filter = $query + $groups = @($Connection.Searcher.FindAll().Properties.samaccountname) + } + } + + return $groups +} + +function Get-PodeAuthDomainName { + $domain = $null + + if (Test-PodeIsMacOS) { + $domain = (scutil --dns | grep -m 1 'search domain\[0\]' | cut -d ':' -f 2) + } + elseif (Test-PodeIsUnix) { + $domain = (dnsdomainname) + if ([string]::IsNullOrWhiteSpace($domain)) { + $domain = (/usr/sbin/realm list --name-only) + } + } + else { + $domain = $env:USERDNSDOMAIN + if ([string]::IsNullOrWhiteSpace($domain)) { + $domain = (Get-CimInstance -Class Win32_ComputerSystem -Verbose:$false).Domain + } + } + + if (![string]::IsNullOrEmpty($domain)) { + $domain = $domain.Trim() + } + + return $domain +} + +function Find-PodeAuth { + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string] + $Name + ) + + return $PodeContext.Server.Authentications.Methods[$Name] +} + +<# +.SYNOPSIS + Expands a list of authentication names, including merged authentication methods. + +.DESCRIPTION + The Expand-PodeAuthMerge function takes an array of authentication names and expands it by resolving any merged authentication methods + into their individual components. It is particularly useful in scenarios where authentication methods are combined or merged, and there + is a need to process each individual method separately. + +.PARAMETER Names + An array of authentication method names. These names can include both discrete authentication methods and merged ones. + +.EXAMPLE + $expandedAuthNames = Expand-PodeAuthMerge -Names @('BasicAuth', 'CustomMergedAuth') + + Expands the provided authentication names, resolving 'CustomMergedAuth' into its constituent authentication methods if it's a merged one. +#> +function Expand-PodeAuthMerge { + param ( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string[]] + $Names + ) + + # Initialize a hashtable to store expanded authentication names + $authNames = @{} + + # Iterate over each authentication name + foreach ($authName in $Names) { + # Handle the special case of anonymous access + if ($authName -eq '%_allowanon_%') { + $authNames[$authName] = $true + } + else { + # Retrieve the authentication method from the Pode context + $_auth = $PodeContext.Server.Authentications.Methods[$authName] + + # Check if the authentication is a merged one and expand it + if ($_auth.merged) { + foreach ($key in (Expand-PodeAuthMerge -Names $_auth.Authentications)) { + $authNames[$key] = $true + } + } + else { + # If not merged, add the authentication name to the list + $authNames[$_auth.Name] = $true + } + } + } + + # Return the keys of the hashtable, which are the expanded authentication names + return $authNames.Keys +} + + +function Import-PodeAuthADModule { + if (!(Test-PodeIsWindows)) { + # Active Directory module only available on Windows + throw ($PodeLocale.adModuleWindowsOnlyExceptionMessage) + } + + if (!(Test-PodeModuleInstalled -Name ActiveDirectory)) { + # Active Directory module is not installed + throw ($PodeLocale.adModuleNotInstalledExceptionMessage) + } + + Import-Module -Name ActiveDirectory -Force -ErrorAction Stop + Export-PodeModule -Name ActiveDirectory +} + +function Get-PodeAuthADProvider { + param( + [switch] + $OpenLDAP, + + [switch] + $ADModule + ) + + # openldap (literal, or not windows) + if ($OpenLDAP -or !(Test-PodeIsWindows)) { + return 'OpenLDAP' + } + + # ad module + if ($ADModule) { + return 'ActiveDirectory' + } + + # ds + return 'DirectoryServices' +} + +function Set-PodeAuthRedirectUrl { + param( + [switch] + $UseOrigin + ) + + if ($UseOrigin -and ($WebEvent.Method -ieq 'get')) { + $null = Set-PodeCookie -Name 'pode.redirecturl' -Value $WebEvent.Request.Url.PathAndQuery + } +} + +function Get-PodeAuthRedirectUrl { + param( + [Parameter()] + [string] + $Url, + + [switch] + $UseOrigin + ) + + if (!$UseOrigin) { + return $Url + } + + $tmpUrl = Get-PodeCookieValue -Name 'pode.redirecturl' + Remove-PodeCookie -Name 'pode.redirecturl' + + if (![string]::IsNullOrWhiteSpace($tmpUrl)) { + $Url = $tmpUrl + } + + return $Url +} \ No newline at end of file diff --git a/my modified authentication - Public.ps1 b/my modified authentication - Public.ps1 new file mode 100644 index 000000000..2992520e2 --- /dev/null +++ b/my modified authentication - Public.ps1 @@ -0,0 +1,2690 @@ +<# +.SYNOPSIS +Create a new type of Authentication scheme. + +.DESCRIPTION +Create a new type of Authentication scheme, which is used to parse the Request for user credentials for validating. + +.PARAMETER Basic +If supplied, will use the inbuilt Basic Authentication credentials retriever. + +.PARAMETER Encoding +The Encoding to use when decoding the Basic Authorization header. + +.PARAMETER HeaderTag +The Tag name used in the Authorization header, ie: Basic, Bearer, Digest. + +.PARAMETER Form +If supplied, will use the inbuilt Form Authentication credentials retriever. + +.PARAMETER UsernameField +The name of the Username Field in the payload to retrieve the username. + +.PARAMETER PasswordField +The name of the Password Field in the payload to retrieve the password. + +.PARAMETER Custom +If supplied, will allow you to create a Custom Authentication credentials retriever. + +.PARAMETER ScriptBlock +The ScriptBlock is used to parse the request and retieve user credentials and other information. + +.PARAMETER ArgumentList +An array of arguments to supply to the Custom Authentication type's ScriptBlock. + +.PARAMETER Name +The Name of an Authentication type - such as Basic or NTLM. + +.PARAMETER Description +A short description for security scheme. CommonMark syntax MAY be used for rich text representation + +.PARAMETER Realm +The name of scope of the protected area. + +.PARAMETER Type +The scheme type for custom Authentication types. Default is HTTP. + +.PARAMETER Middleware +An array of ScriptBlocks for optional Middleware to run before the Scheme's scriptblock. + +.PARAMETER PostValidator +The PostValidator is a scriptblock that is invoked after user validation. + +.PARAMETER Digest +If supplied, will use the inbuilt Digest Authentication credentials retriever. + +.PARAMETER Bearer +If supplied, will use the inbuilt Bearer Authentication token retriever. + +.PARAMETER Algorithm: +The hashing algorithm used by Digest (e.g., MD5, SHA-256) default SHA-256. + +.PARAMETER ClientCertificate +If supplied, will use the inbuilt Client Certificate Authentication scheme. + +.PARAMETER ClientId +The Application ID generated when registering a new app for OAuth2. + +.PARAMETER ClientSecret +The Application Secret generated when registering a new app for OAuth2 (this is optional when using PKCE). + +.PARAMETER RedirectUrl +An optional OAuth2 Redirect URL (default: /oauth2/callback) + +.PARAMETER AuthoriseUrl +The OAuth2 Authorisation URL to authenticate a User. This is optional if you're using an InnerScheme like Basic/Form. + +.PARAMETER TokenUrl +The OAuth2 Token URL to acquire an access token. + +.PARAMETER UserUrl +An optional User profile URL to retrieve a user's details - for OAuth2 + +.PARAMETER UserUrlMethod +An optional HTTP method to use when calling the User profile URL - for OAuth2 (Default: Post) + +.PARAMETER CodeChallengeMethod +An optional method for sending a PKCE code challenge when calling the Authorise URL - for OAuth2 (Default: S256) + +.PARAMETER UsePKCE +If supplied, OAuth2 authentication will use PKCE code verifiers - for OAuth2 + +.PARAMETER OAuth2 +If supplied, will use the inbuilt OAuth2 Authentication scheme. + +.PARAMETER Scope +An optional array of Scopes for Bearer/OAuth2 Authentication. (These are case-sensitive) + +.PARAMETER ApiKey +If supplied, will use the inbuilt API key Authentication scheme. + +.PARAMETER Location +The Location to find an API key: Header, Query, or Cookie. (Default: Header) + +.PARAMETER LocationName +The Name of the Header, Query, or Cookie to find an API key. (Default depends on Location. Header/Cookie: X-API-KEY, Query: api_key) + +.PARAMETER InnerScheme +An optional authentication Scheme (from New-PodeAuthScheme) that will be called prior to this Scheme. + +.PARAMETER AsCredential +If supplied, username/password credentials for Basic/Form authentication will instead be supplied as a pscredential object. + +.PARAMETER AsJWT +If supplied, the token/key supplied for Bearer/API key authentication will be parsed as a JWT, and the payload supplied instead. + +.PARAMETER Secret +An optional Secret, used to sign/verify JWT signatures. + +.EXAMPLE +$basic_auth = New-PodeAuthScheme -Basic + +.EXAMPLE +$form_auth = New-PodeAuthScheme -Form -UsernameField 'Email' + +.EXAMPLE +$custom_auth = New-PodeAuthScheme -Custom -ScriptBlock { /* logic */ } +#> +function New-PodeAuthScheme { + [CmdletBinding(DefaultParameterSetName = 'Basic')] + [OutputType([hashtable])] + param( + [Parameter(ParameterSetName = 'Basic')] + [switch] + $Basic, + + [Parameter(ParameterSetName = 'Basic')] + [string] + $Encoding = 'ISO-8859-1', + + [Parameter(ParameterSetName = 'Basic')] + [Parameter(ParameterSetName = 'Bearer')] + [Parameter(ParameterSetName = 'Digest')] + [string] + $HeaderTag, + + [Parameter(ParameterSetName = 'Form')] + [switch] + $Form, + + [Parameter(ParameterSetName = 'Form')] + [string] + $UsernameField = 'username', + + [Parameter(ParameterSetName = 'Form')] + [string] + $PasswordField = 'password', + + [Parameter(ParameterSetName = 'Custom')] + [switch] + $Custom, + + [Parameter(Mandatory = $true, ParameterSetName = 'Custom')] + [ValidateScript({ + if (Test-PodeIsEmpty $_) { + # A non-empty ScriptBlock is required for the Custom authentication scheme + throw ($PodeLocale.nonEmptyScriptBlockRequiredForCustomAuthExceptionMessage) + } + + return $true + })] + [scriptblock] + $ScriptBlock, + + [Parameter(ParameterSetName = 'Custom')] + [hashtable] + $ArgumentList, + + [Parameter(ParameterSetName = 'Custom')] + [string] + $Name, + + [string] + $Description, + + [Parameter()] + [string] + $Realm, + + [Parameter(ParameterSetName = 'Custom')] + [ValidateSet('ApiKey', 'Http', 'OAuth2', 'OpenIdConnect')] + [string] + $Type = 'Http', + + [Parameter()] + [object[]] + $Middleware, + + [Parameter(ParameterSetName = 'Custom')] + [scriptblock] + $PostValidator = $null, + + [Parameter(ParameterSetName = 'Digest')] + [switch] + $Digest, + + [Parameter(ParameterSetName = 'Bearer')] + [switch] + $Bearer, + + [Parameter(ParameterSetName = 'Digest')] + [string] + $Algorithm = 'MD5', + + [Parameter(ParameterSetName = 'ClientCertificate')] + [switch] + $ClientCertificate, + + [Parameter(ParameterSetName = 'OAuth2', Mandatory = $true)] + [string] + $ClientId, + + [Parameter(ParameterSetName = 'OAuth2')] + [string] + $ClientSecret, + + [Parameter(ParameterSetName = 'OAuth2')] + [string] + $RedirectUrl, + + [Parameter(ParameterSetName = 'OAuth2')] + [string] + $AuthoriseUrl, + + [Parameter(ParameterSetName = 'OAuth2', Mandatory = $true)] + [string] + $TokenUrl, + + [Parameter(ParameterSetName = 'OAuth2')] + [string] + $UserUrl, + + [Parameter(ParameterSetName = 'OAuth2')] + [ValidateSet('Get', 'Post')] + [string] + $UserUrlMethod = 'Post', + + [Parameter(ParameterSetName = 'OAuth2')] + [ValidateSet('plain', 'S256')] + [string] + $CodeChallengeMethod = 'S256', + + [Parameter(ParameterSetName = 'OAuth2')] + [switch] + $UsePKCE, + + [Parameter(ParameterSetName = 'OAuth2')] + [switch] + $OAuth2, + + [Parameter(ParameterSetName = 'ApiKey')] + [switch] + $ApiKey, + + [Parameter(ParameterSetName = 'ApiKey')] + [ValidateSet('Header', 'Query', 'Cookie')] + [string] + $Location = 'Header', + + [Parameter(ParameterSetName = 'ApiKey')] + [string] + $LocationName, + + [Parameter(ParameterSetName = 'Bearer')] + [Parameter(ParameterSetName = 'OAuth2')] + [string[]] + $Scope, + + [Parameter(ValueFromPipeline = $true)] + [hashtable] + $InnerScheme, + + [Parameter(ParameterSetName = 'Basic')] + [Parameter(ParameterSetName = 'Form')] + [switch] + $AsCredential, + + [Parameter(ParameterSetName = 'Bearer')] + [Parameter(ParameterSetName = 'ApiKey')] + [switch] + $AsJWT, + + [Parameter(ParameterSetName = 'Bearer')] + [Parameter(ParameterSetName = 'ApiKey')] + [string] + $Secret + ) + begin { + $pipelineItemCount = 0 + } + + process { + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + # default realm + $_realm = 'User' + + # convert any middleware into valid hashtables + $Middleware = @(ConvertTo-PodeMiddleware -Middleware $Middleware -PSSession $PSCmdlet.SessionState) + + # configure the auth scheme + switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { + 'basic' { + return @{ + Name = (Protect-PodeValue -Value $HeaderTag -Default 'Basic') + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthBasicType) + UsingVariables = $null + } + PostValidator = $null + Middleware = $Middleware + InnerScheme = $InnerScheme + Scheme = 'http' + Arguments = @{ + Description = $Description + HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Basic') + Encoding = (Protect-PodeValue -Value $Encoding -Default 'ISO-8859-1') + AsCredential = $AsCredential + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + } + } + } + + 'clientcertificate' { + return @{ + Name = 'Mutual' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthClientCertificateType) + UsingVariables = $null + } + PostValidator = $null + Middleware = $Middleware + InnerScheme = $InnerScheme + Scheme = 'http' + Arguments = @{ + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + } + } + } + + 'digest' { + return @{ + Name = 'Digest' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthDigestType) + UsingVariables = $null + } + PostValidator = @{ + Script = (Get-PodeAuthDigestPostValidator) + UsingVariables = $null + } + Middleware = $Middleware + InnerScheme = $InnerScheme + Scheme = 'http' + Arguments = @{ + HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Digest') + # Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + # Algorithm = (Protect-PodeValue -Value $Algorithm -Default 'MD5') + } + } + } + + 'bearer' { + $secretBytes = $null + if (![string]::IsNullOrWhiteSpace($Secret)) { + $secretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) + } + + return @{ + Name = 'Bearer' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthBearerType) + UsingVariables = $null + } + PostValidator = @{ + Script = (Get-PodeAuthBearerPostValidator) + UsingVariables = $null + } + Middleware = $Middleware + Scheme = 'http' + InnerScheme = $InnerScheme + Arguments = @{ + Description = $Description + HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Bearer') + Scopes = $Scope + AsJWT = $AsJWT + Secret = $secretBytes + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + } + } + } + + 'form' { + return @{ + Name = 'Form' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthFormType) + UsingVariables = $null + } + PostValidator = $null + Middleware = $Middleware + InnerScheme = $InnerScheme + Scheme = 'http' + Arguments = @{ + Description = $Description + Fields = @{ + Username = (Protect-PodeValue -Value $UsernameField -Default 'username') + Password = (Protect-PodeValue -Value $PasswordField -Default 'password') + } + AsCredential = $AsCredential + } + } + } + + 'oauth2' { + if (($null -ne $InnerScheme) -and ($InnerScheme.Name -inotin @('basic', 'form'))) { + # OAuth2 InnerScheme can only be one of either Basic or Form authentication, but got: {0} + throw ($PodeLocale.oauth2InnerSchemeInvalidExceptionMessage -f $InnerScheme.Name) + } + + if (($null -eq $InnerScheme) -and [string]::IsNullOrWhiteSpace($AuthoriseUrl)) { + # OAuth2 requires an Authorise URL to be supplied + throw ($PodeLocale.oauth2RequiresAuthorizeUrlExceptionMessage) + } + + if ($UsePKCE -and !(Test-PodeSessionsEnabled)) { + # Sessions are required to use OAuth2 with PKCE + throw ($PodeLocale.sessionsRequiredForOAuth2WithPKCEExceptionMessage) + } + + if (!$UsePKCE -and [string]::IsNullOrEmpty($ClientSecret)) { + # OAuth2 requires a Client Secret when not using PKCE + throw ($PodeLocale.oauth2ClientSecretRequiredExceptionMessage) + } + return @{ + Name = 'OAuth2' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthOAuth2Type) + UsingVariables = $null + } + PostValidator = $null + Middleware = $Middleware + Scheme = 'oauth2' + InnerScheme = $InnerScheme + Arguments = @{ + Description = $Description + Scopes = $Scope + PKCE = @{ + Enabled = $UsePKCE + CodeChallenge = @{ + Method = $CodeChallengeMethod + } + } + Client = @{ + ID = $ClientId + Secret = $ClientSecret + } + Urls = @{ + Redirect = $RedirectUrl + Authorise = $AuthoriseUrl + Token = $TokenUrl + User = @{ + Url = $UserUrl + Method = (Protect-PodeValue -Value $UserUrlMethod -Default 'Post') + } + } + } + } + } + + 'apikey' { + # set default location name + if ([string]::IsNullOrWhiteSpace($LocationName)) { + $LocationName = (@{ + Header = 'X-API-KEY' + Query = 'api_key' + Cookie = 'X-API-KEY' + })[$Location] + } + + $secretBytes = $null + if (![string]::IsNullOrWhiteSpace($Secret)) { + $secretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) + } + + return @{ + Name = 'ApiKey' + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + ScriptBlock = @{ + Script = (Get-PodeAuthApiKeyType) + UsingVariables = $null + } + PostValidator = $null + Middleware = $Middleware + InnerScheme = $InnerScheme + Scheme = 'apiKey' + Arguments = @{ + Description = $Description + Location = $Location + LocationName = $LocationName + AsJWT = $AsJWT + Secret = $secretBytes + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + } + } + } + + 'custom' { + $ScriptBlock, $usingScriptVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + + if ($null -ne $PostValidator) { + $PostValidator, $usingPostVars = Convert-PodeScopedVariables -ScriptBlock $PostValidator -PSSession $PSCmdlet.SessionState + } + + return @{ + Name = $Name + Realm = (Protect-PodeValue -Value $Realm -Default $_realm) + InnerScheme = $InnerScheme + Scheme = $Type.ToLowerInvariant() + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingScriptVars + } + PostValidator = @{ + Script = $PostValidator + UsingVariables = $usingPostVars + } + Middleware = $Middleware + Arguments = $ArgumentList + } + } + } + } +} + +<# +.SYNOPSIS +Create an OAuth2 auth scheme for Azure AD. + +.DESCRIPTION +A wrapper for New-PodeAuthScheme and OAuth2, which builds an OAuth2 scheme for Azure AD. + +.PARAMETER Tenant +The Directory/Tenant ID from registering a new app (default: common). + +.PARAMETER ClientId +The Client ID from registering a new app. + +.PARAMETER ClientSecret +The Client Secret from registering a new app (this is optional when using PKCE). + +.PARAMETER RedirectUrl +An optional OAuth2 Redirect URL (default: /oauth2/callback) + +.PARAMETER InnerScheme +An optional authentication Scheme (from New-PodeAuthScheme) that will be called prior to this Scheme. + +.PARAMETER Middleware +An array of ScriptBlocks for optional Middleware to run before the Scheme's scriptblock. + +.PARAMETER UsePKCE +If supplied, OAuth2 authentication will use PKCE code verifiers. + +.EXAMPLE +New-PodeAuthAzureADScheme -Tenant 123-456-678 -ClientId some_id -ClientSecret 1234.abc + +.EXAMPLE +New-PodeAuthAzureADScheme -Tenant 123-456-678 -ClientId some_id -UsePKCE +#> +function New-PodeAuthAzureADScheme { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] + $Tenant = 'common', + + [Parameter(Mandatory = $true)] + [string] + $ClientId, + + [Parameter()] + [string] + $ClientSecret, + + [Parameter()] + [string] + $RedirectUrl, + + [Parameter(ValueFromPipeline = $true)] + [hashtable] + $InnerScheme, + + [Parameter()] + [object[]] + $Middleware, + + [switch] + $UsePKCE + ) + begin { + $pipelineItemCount = 0 + } + + process { + + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + return New-PodeAuthScheme ` + -OAuth2 ` + -ClientId $ClientId ` + -ClientSecret $ClientSecret ` + -AuthoriseUrl "https://login.microsoftonline.com/$($Tenant)/oauth2/v2.0/authorize" ` + -TokenUrl "https://login.microsoftonline.com/$($Tenant)/oauth2/v2.0/token" ` + -UserUrl 'https://graph.microsoft.com/oidc/userinfo' ` + -RedirectUrl $RedirectUrl ` + -InnerScheme $InnerScheme ` + -Middleware $Middleware ` + -UsePKCE:$UsePKCE + } +} + +<# +.SYNOPSIS +Create an OAuth2 auth scheme for Twitter. + +.DESCRIPTION +A wrapper for New-PodeAuthScheme and OAuth2, which builds an OAuth2 scheme for Twitter apps. + +.PARAMETER ClientId +The Client ID from registering a new app. + +.PARAMETER ClientSecret +The Client Secret from registering a new app (this is optional when using PKCE). + +.PARAMETER RedirectUrl +An optional OAuth2 Redirect URL (default: /oauth2/callback) + +.PARAMETER Middleware +An array of ScriptBlocks for optional Middleware to run before the Scheme's scriptblock. + +.PARAMETER UsePKCE +If supplied, OAuth2 authentication will use PKCE code verifiers. + +.EXAMPLE +New-PodeAuthTwitterScheme -ClientId some_id -ClientSecret 1234.abc + +.EXAMPLE +New-PodeAuthTwitterScheme -ClientId some_id -UsePKCE +#> +function New-PodeAuthTwitterScheme { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $ClientId, + + [Parameter()] + [string] + $ClientSecret, + + [Parameter()] + [string] + $RedirectUrl, + + [Parameter()] + [object[]] + $Middleware, + + [switch] + $UsePKCE + ) + + return New-PodeAuthScheme ` + -OAuth2 ` + -ClientId $ClientId ` + -ClientSecret $ClientSecret ` + -AuthoriseUrl 'https://twitter.com/i/oauth2/authorize' ` + -TokenUrl 'https://api.twitter.com/2/oauth2/token' ` + -UserUrl 'https://api.twitter.com/2/users/me' ` + -UserUrlMethod 'Get' ` + -RedirectUrl $RedirectUrl ` + -Middleware $Middleware ` + -Scope 'tweet.read', 'users.read' ` + -UsePKCE:$UsePKCE +} + +<# +.SYNOPSIS +Adds a custom Authentication method for verifying users. + +.DESCRIPTION +Adds a custom Authentication method for verifying users. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER Scheme +The authentication Scheme to use for retrieving credentials (From New-PodeAuthScheme). + +.PARAMETER ScriptBlock +The ScriptBlock defining logic that retrieves and verifys a user. + +.PARAMETER ArgumentList +An array of arguments to supply to the Custom Authentication's ScriptBlock. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. + +.PARAMETER Sessionless +If supplied, authenticated users will not be stored in sessions, and sessions will not be used. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuth -Name 'Main' -ScriptBlock { /* logic */ } +#> +function Add-PodeAuth { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable] + $Scheme, + + [Parameter(Mandatory = $true)] + [ValidateScript({ + if (Test-PodeIsEmpty $_) { + # A non-empty ScriptBlock is required for the authentication method + throw ($PodeLocale.nonEmptyScriptBlockRequiredForAuthMethodExceptionMessage) + } + + return $true + })] + [scriptblock] + $ScriptBlock, + + [Parameter()] + [object[]] + $ArgumentList, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [switch] + $Sessionless, + + [switch] + $SuccessUseOrigin + ) + begin { + $pipelineItemCount = 0 + } + + process { + + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: {0} + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # ensure the Scheme contains a scriptblock + if (Test-PodeIsEmpty $Scheme.ScriptBlock) { + # The supplied scheme for the '{0}' authentication validator requires a valid ScriptBlock + throw ($PodeLocale.schemeRequiresValidScriptBlockExceptionMessage -f $Name) + } + + # if we're using sessions, ensure sessions have been setup + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { + # Sessions are required to use session persistent authentication + throw ($PodeLocale.sessionsRequiredForSessionPersistentAuthExceptionMessage) + } + + # check for scoped vars + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + + # add auth method to server + $PodeContext.Server.Authentications.Methods[$Name] = @{ + Name = $Name + Scheme = $Scheme + ScriptBlock = $ScriptBlock + UsingVariables = $usingVars + Arguments = $ArgumentList + Sessionless = $Sessionless.IsPresent + Failure = @{ + Url = $FailureUrl + Message = $FailureMessage + } + Success = @{ + Url = $SuccessUrl + UseOrigin = $SuccessUseOrigin.IsPresent + } + Cache = @{} + Merged = $false + Parent = $null + } + + # if the scheme is oauth2, and there's no redirect, set up a default one + if (($Scheme.Name -ieq 'oauth2') -and ($null -eq $Scheme.InnerScheme) -and [string]::IsNullOrWhiteSpace($Scheme.Arguments.Urls.Redirect)) { + $path = '/oauth2/callback' + $Scheme.Arguments.Urls.Redirect = $path + Add-PodeRoute -Method Get -Path $path -Authentication $Name + } + } +} + +<# +.SYNOPSIS +Lets you merge multiple Authentication methods together, into a "single" Authentication method. + +.DESCRIPTION +Lets you merge multiple Authentication methods together, into a "single" Authentication method. +You can specify if only One or All of the methods need to pass to allow access, and you can also +merge other merged Authentication methods for more advanced scenarios. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER Authentication +Multiple Autentication method Names to be merged. + +.PARAMETER Valid +How many of the Authentication methods are required to be valid, One or All. (Default: One) + +.PARAMETER ScriptBlock +This is mandatory, and only used, when $Valid=All. A scriptblock to merge the mutliple users/headers returned by valid authentications into 1 user/header objects. +This scriptblock will receive a hashtable of all result objects returned from Authentication methods. The key for the hashtable will be the authentication names that passed. + +.PARAMETER Default +The Default Authentication method to use as a fallback for Failure URLs and other settings. + +.PARAMETER MergeDefault +The Default Authentication method's User details result object to use, when $Valid=All. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. +This will be used as fallback for the merged Authentication methods if not set on them. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. +This will be used as fallback for the merged Authentication methods if not set on them. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. +This will be used as fallback for the merged Authentication methods if not set on them. + +.PARAMETER Sessionless +If supplied, authenticated users will not be stored in sessions, and sessions will not be used. +This will be used as fallback for the merged Authentication methods if not set on them. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. +This will be used as fallback for the merged Authentication methods if not set on them. + +.EXAMPLE +Merge-PodeAuth -Name MergedAuth -Authentication ApiTokenAuth, BasicAuth -Valid All -ScriptBlock { ... } + +.EXAMPLE +Merge-PodeAuth -Name MergedAuth -Authentication ApiTokenAuth, BasicAuth -Valid All -MergeDefault BasicAuth + +.EXAMPLE +Merge-PodeAuth -Name MergedAuth -Authentication ApiTokenAuth, BasicAuth -FailureUrl 'http://localhost:8080/login' +#> +function Merge-PodeAuth { + [CmdletBinding(DefaultParameterSetName = 'ScriptBlock')] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true)] + [Alias('Auth')] + [string[]] + $Authentication, + + [Parameter()] + [ValidateSet('One', 'All')] + [string] + $Valid = 'One', + + [Parameter(ParameterSetName = 'ScriptBlock')] + [scriptblock] + $ScriptBlock, + + [Parameter()] + [string] + $Default, + + [Parameter(ParameterSetName = 'MergeDefault')] + [string] + $MergeDefault, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [switch] + $Sessionless, + + [switch] + $SuccessUseOrigin + ) + + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: { 0 } + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # ensure all the auth methods exist + foreach ($authName in $Authentication) { + if (!(Test-PodeAuthExists -Name $authName)) { + throw ($PodeLocale.authMethodNotExistForMergingExceptionMessage -f $authName) #"Authentication method does not exist for merging: $($authName)" + } + } + + # ensure the merge default is in the auth list + if (![string]::IsNullOrEmpty($MergeDefault) -and ($MergeDefault -inotin @($Authentication))) { + throw ($PodeLocale.mergeDefaultAuthNotInListExceptionMessage -f $MergeDefault) # "the MergeDefault Authentication '$($MergeDefault)' is not in the Authentication list supplied" + } + + # ensure the default is in the auth list + if (![string]::IsNullOrEmpty($Default) -and ($Default -inotin @($Authentication))) { + throw ($PodeLocale.defaultAuthNotInListExceptionMessage -f $Default) # "the Default Authentication '$($Default)' is not in the Authentication list supplied" + } + + # set default + if ([string]::IsNullOrEmpty($Default)) { + $Default = $Authentication[0] + } + + # get auth for default + $tmpAuth = $PodeContext.Server.Authentications.Methods[$Default] + + # check sessionless from default + if (!$Sessionless) { + $Sessionless = $tmpAuth.Sessionless + } + + # if we're using sessions, ensure sessions have been setup + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { + # Sessions are required to use session persistent authentication + throw ($PodeLocale.sessionsRequiredForSessionPersistentAuthExceptionMessage) + } + + # check failure url from default + if ([string]::IsNullOrEmpty($FailureUrl)) { + $FailureUrl = $tmpAuth.Failure.Url + } + + # check failure message from default + if ([string]::IsNullOrEmpty($FailureMessage)) { + $FailureMessage = $tmpAuth.Failure.Message + } + + # check success url from default + if ([string]::IsNullOrEmpty($SuccessUrl)) { + $SuccessUrl = $tmpAuth.Success.Url + } + + # check success use origin from default + if (!$SuccessUseOrigin) { + $SuccessUseOrigin = $tmpAuth.Success.UseOrigin + } + + # deal with using vars in scriptblock + if (($Valid -ieq 'all') -and [string]::IsNullOrEmpty($MergeDefault)) { + if ($null -eq $ScriptBlock) { + # A Scriptblock for merging multiple authenticated users into 1 object is required When Valid is All + throw ($PodeLocale.scriptBlockRequiredForMergingUsersExceptionMessage) + } + + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + } + else { + if ($null -ne $ScriptBlock) { + Write-Warning -Message 'The Scriptblock for merged authentications, when Valid=One, will be ignored' + } + } + + # set parent auth + foreach ($authName in $Authentication) { + $PodeContext.Server.Authentications.Methods[$authName].Parent = $Name + } + + # add auth method to server + $PodeContext.Server.Authentications.Methods[$Name] = @{ + Name = $Name + Authentications = @($Authentication) + PassOne = ($Valid -ieq 'one') + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingVars + } + Default = $Default + MergeDefault = $MergeDefault + Sessionless = $Sessionless.IsPresent + Failure = @{ + Url = $FailureUrl + Message = $FailureMessage + } + Success = @{ + Url = $SuccessUrl + UseOrigin = $SuccessUseOrigin.IsPresent + } + Cache = @{} + Merged = $true + Parent = $null + } +} + +<# +.SYNOPSIS +Gets an Authentication method. + +.DESCRIPTION +Gets an Authentication method. + +.PARAMETER Name +The Name of an Authentication method. + +.EXAMPLE +Get-PodeAuth -Name 'Main' +#> +function Get-PodeAuth { + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + # ensure the name exists + if (!(Test-PodeAuthExists -Name $Name)) { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage -f $Name) # "Authentication method not defined: $($Name)" + } + + # get auth method + return $PodeContext.Server.Authentications.Methods[$Name] +} + +<# +.SYNOPSIS +Test if an Authentication method exists. + +.DESCRIPTION +Test if an Authentication method exists. + +.PARAMETER Name +The Name of the Authentication method. + +.EXAMPLE +if (Test-PodeAuthExists -Name BasicAuth) { ... } +#> +function Test-PodeAuthExists { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '')] + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + return $PodeContext.Server.Authentications.Methods.ContainsKey($Name) +} + +<# +.SYNOPSIS +Test and invoke an Authentication method to verify a user. + +.DESCRIPTION +Test and invoke an Authentication method to verify a user. This will verify a user's credentials on the request. +When testing OAuth2 methods, the first attempt will trigger a redirect to the provider and $false will be returned. + +.PARAMETER Name +The Name of the Authentication method. + +.PARAMETER IgnoreSession +If supplied, authentication will be re-verified on each call even if a valid session exists on the request. + +.EXAMPLE +if (Test-PodeAuth -Name 'BasicAuth') { ... } + +.EXAMPLE +if (Test-PodeAuth -Name 'FormAuth' -IgnoreSession) { ... } +#> +function Test-PodeAuth { + [CmdletBinding()] + [OutputType([boolean])] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [switch] + $IgnoreSession + ) + + if (! (Test-PodeAuthExists -Name $Name)) { + throw ($PodeLocale.authMethodDoesNotExistExceptionMessage -f $Name) + } + + # if the session already has a user/isAuth'd, then skip auth - or allow anon + if (!$IgnoreSession -and (Test-PodeSessionsInUse) -and (Test-PodeAuthUser)) { + return $true + } + + try { + $result = Invoke-PodeAuthValidation -Name $Name + } + catch { + $_ | Write-PodeErrorLog + return $false + } + + # did the auth force a redirect? + if ($result.Redirected) { + return $false + } + + # if auth failed, set appropriate response headers/redirects + if (!$result.Success) { + return $false + } + + # successful auth + return $true +} + +<# +.SYNOPSIS + Invokes an authentication method in Pode. + +.DESCRIPTION + This function attempts to invoke an authentication method by its name, + ensuring that it exists and has not been merged. If the authentication + method does not exist or is merged, it throws an exception. + +.PARAMETER Name + The name of the authentication method to invoke. This parameter is mandatory. + +.OUTPUTS + A hashtable containing the authentication result, including success status,user information, and headers. + +#> +function Invoke-PodeAuth { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Name + ) + + # Check if the authentication method exists + if (! (Test-PodeAuthExists -Name $Name)) { + # Authentication method doesn't exist: + throw ($PodeLocale.authMethodDoesNotExistExceptionMessage -f $Name) + } + + # Ensure the authentication method is not merged + if ($PodeContext.Server.Authentications.Methods[$Name].Merged) { + # Authentication method {0} is merged + throw ($PodeLocale.authenticationMethodMergedExceptionMessage -f $Name) + } + try { + # Perform authentication validation + $WebEvent.Auth = Test-PodeAuthValidation -Name $Name -NoMiddlewareAuthentication + Add-PodeHeader -Name 'WWW-Authenticate' -Value $WebEvent.Auth.Headers['WWW-Authenticate'] + } + catch { + $_ | Write-PodeErrorLog + } + + return $WebEvent.Auth +} + + + +<# +.SYNOPSIS +Adds the inbuilt Windows AD Authentication method for verifying users. + +.DESCRIPTION +Adds the inbuilt Windows AD Authentication method for verifying users. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER Scheme +The Scheme to use for retrieving credentials (From New-PodeAuthScheme). + +.PARAMETER Fqdn +A custom FQDN for the DNS of the AD you wish to authenticate against. (Alias: Server) + +.PARAMETER Domain +(Unix Only) A custom NetBIOS domain name that is prepended onto usernames that are missing it (\). + +.PARAMETER SearchBase +(Unix Only) An optional searchbase to refine the LDAP query. This should be the full distinguished name. + +.PARAMETER Groups +An array of Group names to only allow access. + +.PARAMETER Users +An array of Usernames to only allow access. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. + +.PARAMETER ScriptBlock +Optional ScriptBlock that is passed the found user object for further validation. + +.PARAMETER Sessionless +If supplied, authenticated users will not be stored in sessions, and sessions will not be used. + +.PARAMETER NoGroups +If supplied, groups will not be retrieved for the user in AD. + +.PARAMETER DirectGroups +If supplied, only a user's direct groups will be retrieved rather than all groups recursively. + +.PARAMETER OpenLDAP +If supplied, and on Windows, OpenLDAP will be used instead (this is the default for Linux/MacOS). + +.PARAMETER ADModule +If supplied, and on Windows, the ActiveDirectory module will be used instead. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. + +.PARAMETER KeepCredential +If suplied pode will save the AD credential as a PSCredential object in $WebEvent.Auth.User.Credential + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthWindowsAd -Name 'WinAuth' + +.EXAMPLE +New-PodeAuthScheme -Basic | Add-PodeAuthWindowsAd -Name 'WinAuth' -Groups @('Developers') + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthWindowsAd -Name 'WinAuth' -NoGroups + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthWindowsAd -Name 'UnixAuth' -Server 'testdomain.company.com' -Domain 'testdomain' +#> +function Add-PodeAuthWindowsAd { + [CmdletBinding(DefaultParameterSetName = 'Groups')] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable] + $Scheme, + + [Parameter()] + [Alias('Server')] + [string] + $Fqdn, + + [Parameter()] + [string] + $Domain, + + [Parameter()] + [string] + $SearchBase, + + [Parameter(ParameterSetName = 'Groups')] + [string[]] + $Groups, + + [Parameter()] + [string[]] + $Users, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [Parameter()] + [scriptblock] + $ScriptBlock, + + [switch] + $Sessionless, + + [Parameter(ParameterSetName = 'NoGroups')] + [switch] + $NoGroups, + + [Parameter(ParameterSetName = 'Groups')] + [switch] + $DirectGroups, + + [switch] + $OpenLDAP, + + [switch] + $ADModule, + + [switch] + $SuccessUseOrigin, + + [switch] + $KeepCredential + ) + begin { + $pipelineItemCount = 0 + } + + process { + + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: {0} + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # ensure the Scheme contains a scriptblock + if (Test-PodeIsEmpty $Scheme.ScriptBlock) { + # The supplied Scheme for the '$($Name)' Windows AD authentication validator requires a valid ScriptBlock + throw ($PodeLocale.schemeRequiresValidScriptBlockExceptionMessage -f $Name) + } + + # if we're using sessions, ensure sessions have been setup + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { + # Sessions are required to use session persistent authentication + throw ($PodeLocale.sessionsRequiredForSessionPersistentAuthExceptionMessage) + } + + # if AD module set, ensure we're on windows and the module is available, then import/export it + if ($ADModule) { + Import-PodeAuthADModule + } + + # set server name if not passed + if ([string]::IsNullOrWhiteSpace($Fqdn)) { + $Fqdn = Get-PodeAuthDomainName + + if ([string]::IsNullOrWhiteSpace($Fqdn)) { + # No domain server name has been supplied for Windows AD authentication + throw ($PodeLocale.noDomainServerNameForWindowsAdAuthExceptionMessage) + } + } + + # set the domain if not passed + if ([string]::IsNullOrWhiteSpace($Domain)) { + $Domain = ($Fqdn -split '\.')[0] + } + + # if we have a scriptblock, deal with using vars + if ($null -ne $ScriptBlock) { + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + } + + # add Windows AD auth method to server + $PodeContext.Server.Authentications.Methods[$Name] = @{ + Name = $Name + Scheme = $Scheme + ScriptBlock = (Get-PodeAuthWindowsADMethod) + Arguments = @{ + Server = $Fqdn + Domain = $Domain + SearchBase = $SearchBase + Users = $Users + Groups = $Groups + NoGroups = $NoGroups + DirectGroups = $DirectGroups + KeepCredential = $KeepCredential + Provider = (Get-PodeAuthADProvider -OpenLDAP:$OpenLDAP -ADModule:$ADModule) + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingVars + } + } + Sessionless = $Sessionless + Failure = @{ + Url = $FailureUrl + Message = $FailureMessage + } + Success = @{ + Url = $SuccessUrl + UseOrigin = $SuccessUseOrigin + } + Cache = @{} + Merged = $false + Parent = $null + } + } +} + +<# +.SYNOPSIS +Adds the inbuilt Session Authentication method for verifying an authenticated session is present on Requests. + +.DESCRIPTION +Adds the inbuilt Session Authentication method for verifying an authenticated session is present on Requests. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. + +.PARAMETER ScriptBlock +Optional ScriptBlock that is passed the found user object for further validation. + +.PARAMETER Middleware +An array of ScriptBlocks for optional Middleware to run before the Scheme's scriptblock. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. + +.EXAMPLE +Add-PodeAuthSession -Name 'SessionAuth' -FailureUrl '/login' +#> +function Add-PodeAuthSession { + [CmdletBinding(DefaultParameterSetName = 'Groups')] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [Parameter()] + [scriptblock] + $ScriptBlock, + + [Parameter()] + [object[]] + $Middleware, + + [switch] + $SuccessUseOrigin + ) + + # if sessions haven't been setup, error + if (!(Test-PodeSessionsEnabled)) { + # Sessions have not been configured + throw ($PodeLocale.sessionsNotConfiguredExceptionMessage) + } + + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: { 0 } + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # if we have a scriptblock, deal with using vars + if ($null -ne $ScriptBlock) { + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + } + + # create the auth scheme for getting the session + $scheme = New-PodeAuthScheme -Custom -Middleware $Middleware -ScriptBlock { + param($options) + + # 401 if sessions not used + if (!(Test-PodeSessionsInUse)) { + Revoke-PodeSession + return @{ + Message = 'Sessions are not being used' + Code = 401 + } + } + + # 401 if no authenticated user + if (!(Test-PodeAuthUser)) { + Revoke-PodeSession + return @{ + Message = 'Session not authenticated' + Code = 401 + } + } + + # return user + return @($WebEvent.Session.Data.Auth) + } + + # add a custom auth method to return user back + $method = { + param($user, $options) + $result = @{ User = $user } + + # call additional scriptblock if supplied + if ($null -ne $options.ScriptBlock.Script) { + $result = Invoke-PodeAuthInbuiltScriptBlock -User $result.User -ScriptBlock $options.ScriptBlock.Script -UsingVariables $options.ScriptBlock.UsingVariables + } + + # return user back + return $result + } + + $scheme | Add-PodeAuth ` + -Name $Name ` + -ScriptBlock $method ` + -FailureUrl $FailureUrl ` + -FailureMessage $FailureMessage ` + -SuccessUrl $SuccessUrl ` + -SuccessUseOrigin:$SuccessUseOrigin ` + -ArgumentList @{ + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingVars + } + } +} + +<# +.SYNOPSIS +Remove a specific Authentication method. + +.DESCRIPTION +Remove a specific Authentication method. + +.PARAMETER Name +The Name of the Authentication method. + +.EXAMPLE +Remove-PodeAuth -Name 'Login' +#> +function Remove-PodeAuth { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [string] + $Name + ) + process { + $null = $PodeContext.Server.Authentications.Methods.Remove($Name) + } +} + +<# +.SYNOPSIS +Clear all defined Authentication methods. + +.DESCRIPTION +Clear all defined Authentication methods. + +.EXAMPLE +Clear-PodeAuth +#> +function Clear-PodeAuth { + [CmdletBinding()] + param() + + $PodeContext.Server.Authentications.Methods.Clear() +} + +<# +.SYNOPSIS +Adds an authentication method as global middleware. + +.DESCRIPTION +Adds an authentication method as global middleware. + +.PARAMETER Name +The Name of the Middleware. + +.PARAMETER Authentication +The Name of the Authentication method to use. + +.PARAMETER Route +A Route path for which Routes this Middleware should only be invoked against. + +.PARAMETER OADefinitionTag +An array of string representing the unique tag for the API specification. +This tag helps in distinguishing between different versions or types of API specifications within the application. +Use this tag to reference the specific API documentation, schema, or version that your function interacts with. + +.EXAMPLE +Add-PodeAuthMiddleware -Name 'GlobalAuth' -Authentication AuthName + +.EXAMPLE +Add-PodeAuthMiddleware -Name 'GlobalAuth' -Authentication AuthName -Route '/api/*' +#> +function Add-PodeAuthMiddleware { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true)] + [Alias('Auth')] + [string] + $Authentication, + + [Parameter()] + [string] + $Route, + + [string[]] + $OADefinitionTag + ) + + $DefinitionTag = Test-PodeOADefinitionTag -Tag $OADefinitionTag + + if (!(Test-PodeAuthExists -Name $Authentication)) { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage -f $Authentication) # "Authentication method does not exist: $($Authentication)" + } + + Get-PodeAuthMiddlewareScript | + New-PodeMiddleware -ArgumentList @{ Name = $Authentication } | + Add-PodeMiddleware -Name $Name -Route $Route + + Set-PodeOAGlobalAuth -DefinitionTag $DefinitionTag -Name $Authentication -Route $Route +} + +<# +.SYNOPSIS +Adds the inbuilt IIS Authentication method for verifying users passed to Pode from IIS. + +.DESCRIPTION +Adds the inbuilt IIS Authentication method for verifying users passed to Pode from IIS. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER Groups +An array of Group names to only allow access. + +.PARAMETER Users +An array of Usernames to only allow access. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. + +.PARAMETER ScriptBlock +Optional ScriptBlock that is passed the found user object for further validation. + +.PARAMETER Middleware +An array of ScriptBlocks for optional Middleware to run before the Scheme's scriptblock. + +.PARAMETER Sessionless +If supplied, authenticated users will not be stored in sessions, and sessions will not be used. + +.PARAMETER NoGroups +If supplied, groups will not be retrieved for the user in AD. + +.PARAMETER DirectGroups +If supplied, only a user's direct groups will be retrieved rather than all groups recursively. + +.PARAMETER ADModule +If supplied, and on Windows, the ActiveDirectory module will be used instead. + +.PARAMETER NoLocalCheck +If supplied, Pode will not at attempt to retrieve local User/Group information for the authenticated user. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. + +.EXAMPLE +Add-PodeAuthIIS -Name 'IISAuth' + +.EXAMPLE +Add-PodeAuthIIS -Name 'IISAuth' -Groups @('Developers') + +.EXAMPLE +Add-PodeAuthIIS -Name 'IISAuth' -NoGroups +#> +function Add-PodeAuthIIS { + [CmdletBinding(DefaultParameterSetName = 'Groups')] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(ParameterSetName = 'Groups')] + [string[]] + $Groups, + + [Parameter()] + [string[]] + $Users, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [Parameter()] + [scriptblock] + $ScriptBlock, + + [Parameter()] + [object[]] + $Middleware, + + [switch] + $Sessionless, + + [Parameter(ParameterSetName = 'NoGroups')] + [switch] + $NoGroups, + + [Parameter(ParameterSetName = 'Groups')] + [switch] + $DirectGroups, + + [switch] + $ADModule, + + [switch] + $NoLocalCheck, + + [switch] + $SuccessUseOrigin + ) + + # ensure we're on Windows! + if (!(Test-PodeIsWindows)) { + # IIS Authentication support is for Windows only + throw ($PodeLocale.iisAuthSupportIsForWindowsOnlyExceptionMessage) + } + + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: {0} + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # if AD module set, ensure we're on windows and the module is available, then import/export it + if ($ADModule) { + Import-PodeAuthADModule + } + + # if we have a scriptblock, deal with using vars + if ($null -ne $ScriptBlock) { + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + } + + # create the auth scheme for getting the token header + $scheme = New-PodeAuthScheme -Custom -Middleware $Middleware -ScriptBlock { + param($options) + + $header = 'MS-ASPNETCORE-WINAUTHTOKEN' + + # fail if no header + if (!(Test-PodeHeader -Name $header)) { + return @{ + Message = "No $($header) header found" + Code = 401 + } + } + + # return the header for validation + $token = Get-PodeHeader -Name $header + return @($token) + } + + # add a custom auth method to validate the user + $method = Get-PodeAuthWindowsADIISMethod + + $scheme | Add-PodeAuth ` + -Name $Name ` + -ScriptBlock $method ` + -FailureUrl $FailureUrl ` + -FailureMessage $FailureMessage ` + -SuccessUrl $SuccessUrl ` + -Sessionless:$Sessionless ` + -SuccessUseOrigin:$SuccessUseOrigin ` + -ArgumentList @{ + Users = $Users + Groups = $Groups + NoGroups = $NoGroups + DirectGroups = $DirectGroups + Provider = (Get-PodeAuthADProvider -ADModule:$ADModule) + NoLocalCheck = $NoLocalCheck + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingVars + } + } +} + +<# +.SYNOPSIS +Adds the inbuilt User File Authentication method for verifying users. + +.DESCRIPTION +Adds the inbuilt User File Authentication method for verifying users. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER Scheme +The Scheme to use for retrieving credentials (From New-PodeAuthScheme). + +.PARAMETER FilePath +A path to a users JSON file (Default: ./users.json) + +.PARAMETER Groups +An array of Group names to only allow access. + +.PARAMETER Users +An array of Usernames to only allow access. + +.PARAMETER HmacSecret +An optional secret if the passwords are HMAC SHA256 hashed. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. + +.PARAMETER ScriptBlock +Optional ScriptBlock that is passed the found user object for further validation. + +.PARAMETER Sessionless +If supplied, authenticated users will not be stored in sessions, and sessions will not be used. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthUserFile -Name 'Login' + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthUserFile -Name 'Login' -FilePath './custom/path/users.json' +#> +function Add-PodeAuthUserFile { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable] + $Scheme, + + [Parameter()] + [string] + $FilePath, + + [Parameter()] + [string[]] + $Groups, + + [Parameter()] + [string[]] + $Users, + + [Parameter(ParameterSetName = 'Hmac')] + [string] + $HmacSecret, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [Parameter()] + [scriptblock] + $ScriptBlock, + + [switch] + $Sessionless, + + [switch] + $SuccessUseOrigin + ) + begin { + $pipelineItemCount = 0 + } + + process { + + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: {0} + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # ensure the Scheme contains a scriptblock + if (Test-PodeIsEmpty $Scheme.ScriptBlock) { + # The supplied scheme for the '{0}' authentication validator requires a valid ScriptBlock. + throw ($PodeLocale.schemeRequiresValidScriptBlockExceptionMessage -f $Name) + } + + # if we're using sessions, ensure sessions have been setup + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { + # Sessions are required to use session persistent authentication + throw ($PodeLocale.sessionsRequiredForSessionPersistentAuthExceptionMessage) + } + + # set the file path if not passed + if ([string]::IsNullOrWhiteSpace($FilePath)) { + $FilePath = Join-PodeServerRoot -Folder '.' -FilePath 'users.json' + } + else { + $FilePath = Get-PodeRelativePath -Path $FilePath -JoinRoot -Resolve + } + + # ensure the user file exists + if (!(Test-PodePath -Path $FilePath -NoStatus -FailOnDirectory)) { + # The user file does not exist: {0} + throw ($PodeLocale.userFileDoesNotExistExceptionMessage -f $FilePath) + } + + # if we have a scriptblock, deal with using vars + if ($null -ne $ScriptBlock) { + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + } + + # add Windows AD auth method to server + $PodeContext.Server.Authentications.Methods[$Name] = @{ + Name = $Name + Scheme = $Scheme + ScriptBlock = (Get-PodeAuthUserFileMethod) + Arguments = @{ + FilePath = $FilePath + Users = $Users + Groups = $Groups + HmacSecret = $HmacSecret + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingVars + } + } + Sessionless = $Sessionless + Failure = @{ + Url = $FailureUrl + Message = $FailureMessage + } + Success = @{ + Url = $SuccessUrl + UseOrigin = $SuccessUseOrigin + } + Cache = @{} + Merged = $false + Parent = $null + } + } +} + +<# +.SYNOPSIS +Adds the inbuilt Windows Local User Authentication method for verifying users. + +.DESCRIPTION +Adds the inbuilt Windows Local User Authentication method for verifying users. + +.PARAMETER Name +A unique Name for the Authentication method. + +.PARAMETER Scheme +The Scheme to use for retrieving credentials (From New-PodeAuthScheme). + +.PARAMETER Groups +An array of Group names to only allow access. + +.PARAMETER Users +An array of Usernames to only allow access. + +.PARAMETER FailureUrl +The URL to redirect to when authentication fails. + +.PARAMETER FailureMessage +An override Message to throw when authentication fails. + +.PARAMETER SuccessUrl +The URL to redirect to when authentication succeeds when logging in. + +.PARAMETER ScriptBlock +Optional ScriptBlock that is passed the found user object for further validation. + +.PARAMETER Sessionless +If supplied, authenticated users will not be stored in sessions, and sessions will not be used. + +.PARAMETER NoGroups +If supplied, groups will not be retrieved for the user. + +.PARAMETER SuccessUseOrigin +If supplied, successful authentication from a login page will redirect back to the originating page instead of the FailureUrl. + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthWindowsLocal -Name 'WinAuth' + +.EXAMPLE +New-PodeAuthScheme -Basic | Add-PodeAuthWindowsLocal -Name 'WinAuth' -Groups @('Developers') + +.EXAMPLE +New-PodeAuthScheme -Form | Add-PodeAuthWindowsLocal -Name 'WinAuth' -NoGroups +#> +function Add-PodeAuthWindowsLocal { + [CmdletBinding(DefaultParameterSetName = 'Groups')] + param( + [Parameter(Mandatory = $true)] + [string] + $Name, + + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [hashtable] + $Scheme, + + [Parameter(ParameterSetName = 'Groups')] + [string[]] + $Groups, + + [Parameter()] + [string[]] + $Users, + + [Parameter()] + [string] + $FailureUrl, + + [Parameter()] + [string] + $FailureMessage, + + [Parameter()] + [string] + $SuccessUrl, + + [Parameter()] + [scriptblock] + $ScriptBlock, + + [switch] + $Sessionless, + + [Parameter(ParameterSetName = 'NoGroups')] + [switch] + $NoGroups, + + [switch] + $SuccessUseOrigin + ) + begin { + $pipelineItemCount = 0 + } + + process { + + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + # ensure we're on Windows! + if (!(Test-PodeIsWindows)) { + # Windows Local Authentication support is for Windows only + throw ($PodeLocale.windowsLocalAuthSupportIsForWindowsOnlyExceptionMessage) + } + + # ensure the name doesn't already exist + if (Test-PodeAuthExists -Name $Name) { + # Authentication method already defined: {0} + throw ($PodeLocale.authMethodAlreadyDefinedExceptionMessage -f $Name) + } + + # ensure the Scheme contains a scriptblock + if (Test-PodeIsEmpty $Scheme.ScriptBlock) { + # The supplied scheme for the '{0}' authentication validator requires a valid ScriptBlock. + throw ($PodeLocale.schemeRequiresValidScriptBlockExceptionMessage -f $Name) + } + + # if we're using sessions, ensure sessions have been setup + if (!$Sessionless -and !(Test-PodeSessionsEnabled)) { + # Sessions are required to use session persistent authentication + throw ($PodeLocale.sessionsRequiredForSessionPersistentAuthExceptionMessage) + } + + # if we have a scriptblock, deal with using vars + if ($null -ne $ScriptBlock) { + $ScriptBlock, $usingVars = Convert-PodeScopedVariables -ScriptBlock $ScriptBlock -PSSession $PSCmdlet.SessionState + } + + # add Windows Local auth method to server + $PodeContext.Server.Authentications.Methods[$Name] = @{ + Name = $Name + Scheme = $Scheme + ScriptBlock = (Get-PodeAuthWindowsLocalMethod) + Arguments = @{ + Users = $Users + Groups = $Groups + NoGroups = $NoGroups + ScriptBlock = @{ + Script = $ScriptBlock + UsingVariables = $usingVars + } + } + Sessionless = $Sessionless + Failure = @{ + Url = $FailureUrl + Message = $FailureMessage + } + Success = @{ + Url = $SuccessUrl + UseOrigin = $SuccessUseOrigin + } + Cache = @{} + Merged = $false + Parent = $null + } + } +} + +<# +.SYNOPSIS +Convert a Header/Payload into a JWT. + +.DESCRIPTION +Convert a Header/Payload hashtable into a JWT, with the option to sign it. + +.PARAMETER Header +A Hashtable containing the Header information for the JWT. + +.PARAMETER Payload +A Hashtable containing the Payload information for the JWT. + +.PARAMETER Secret +An Optional Secret for signing the JWT, should be a string or byte[]. This is mandatory if the Header algorithm isn't "none". + +.EXAMPLE +ConvertTo-PodeJwt -Header @{ alg = 'none' } -Payload @{ sub = '123'; name = 'John' } + +.EXAMPLE +ConvertTo-PodeJwt -Header @{ alg = 'hs256' } -Payload @{ sub = '123'; name = 'John' } -Secret 'abc' +#> +function ConvertTo-PodeJwt { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [hashtable] + $Header, + + [Parameter(Mandatory = $true)] + [hashtable] + $Payload, + + [Parameter()] + $Secret = $null + ) + + # validate header + if ([string]::IsNullOrWhiteSpace($Header.alg)) { + # No algorithm supplied in JWT Header + throw ($PodeLocale.noAlgorithmInJwtHeaderExceptionMessage) + } + + # convert the header + $header64 = ConvertTo-PodeBase64UrlValue -Value ($Header | ConvertTo-Json -Compress) + + # convert the payload + $payload64 = ConvertTo-PodeBase64UrlValue -Value ($Payload | ConvertTo-Json -Compress) + + # combine + $jwt = "$($header64).$($payload64)" + + # convert secret to bytes + if (($null -ne $Secret) -and ($Secret -isnot [byte[]])) { + $Secret = [System.Text.Encoding]::UTF8.GetBytes([string]$Secret) + } + + # make the signature + $sig = New-PodeJwtSignature -Algorithm $Header.alg -Token $jwt -SecretBytes $Secret + + # add the signature and return + $jwt += ".$($sig)" + return $jwt +} + +<# +.SYNOPSIS +Convert and return the payload of a JWT token. + +.DESCRIPTION +Convert and return the payload of a JWT token, verifying the signature by default with support to ignore the signature. + +.PARAMETER Token +The JWT token. + +.PARAMETER Secret +The Secret, as a string or byte[], to verify the token's signature. + +.PARAMETER IgnoreSignature +Skip signature verification, and return the decoded payload. + +.EXAMPLE +ConvertFrom-PodeJwt -Token "eyJ0eXAiOiJKV1QiLCJhbGciOiJoczI1NiJ9.eyJleHAiOjE2MjI1NTMyMTQsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMyJ9.LP-O8OKwix91a-SZwVK35gEClLZQmsORbW0un2Z4RkY" +#> +function ConvertFrom-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Secret')] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string] + $Token, + + [Parameter(ParameterSetName = 'Signed')] + $Secret = $null, + + [Parameter(ParameterSetName = 'Ignore')] + [switch] + $IgnoreSignature + ) + + # get the parts + $parts = ($Token -isplit '\.') + + # check number of parts (should be 3) + if ($parts.Length -ne 3) { + # Invalid JWT supplied + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + # convert to header + $header = ConvertFrom-PodeJwtBase64Value -Value $parts[0] + if ([string]::IsNullOrWhiteSpace($header.alg)) { + # Invalid JWT header algorithm supplied + throw ($PodeLocale.invalidJwtHeaderAlgorithmSuppliedExceptionMessage) + } + + # convert to payload + $payload = ConvertFrom-PodeJwtBase64Value -Value $parts[1] + + # get signature + if ($IgnoreSignature) { + return $payload + } + + $signature = $parts[2] + + # check "none" signature, and return payload if no signature + $isNoneAlg = ($header.alg -ieq 'none') + + if ([string]::IsNullOrWhiteSpace($signature) -and !$isNoneAlg) { + # No JWT signature supplied for {0} + throw ($PodeLocale.noJwtSignatureForAlgorithmExceptionMessage -f $header.alg) + } + + if (![string]::IsNullOrWhiteSpace($signature) -and $isNoneAlg) { + # Expected no JWT signature to be supplied + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + if ($isNoneAlg -and ($null -ne $Secret) -and ($Secret.Length -gt 0)) { + # Expected no JWT signature to be supplied + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + if ($isNoneAlg) { + return $payload + } + + # otherwise, we have an alg for the signature, so we need to validate it + if (($null -ne $Secret) -and ($Secret -isnot [byte[]])) { + $Secret = [System.Text.Encoding]::UTF8.GetBytes([string]$Secret) + } + + $sig = "$($parts[0]).$($parts[1])" + $sig = New-PodeJwtSignature -Algorithm $header.alg -Token $sig -SecretBytes $Secret + + if ($sig -ne $parts[2]) { + # Invalid JWT signature supplied + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + + # it's valid return the payload! + return $payload +} + +<# +.SYNOPSIS +Validates JSON Web Tokens (JWT) claims. + +.DESCRIPTION +Validates JSON Web Tokens (JWT) claims. Checks time related claims: 'exp' and 'nbf'. + +.PARAMETER Payload +Object containing JWT claims. Some of them are: + - exp (expiration time) + - nbf (not before) + +.EXAMPLE +Test-PodeJwt @{exp = 2696258821 } + +.EXAMPLE +Test-PodeJwt -Payload @{nbf = 1696258821 } +#> +function Test-PodeJwt { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] + $Payload + ) + + $now = [datetime]::UtcNow + $unixStart = [datetime]::new(1970, 1, 1, 0, 0, [DateTimeKind]::Utc) + + # validate expiry + if (![string]::IsNullOrWhiteSpace($Payload.exp)) { + if ($now -gt $unixStart.AddSeconds($Payload.exp)) { + # The JWT has expired + throw ($PodeLocale.jwtExpiredExceptionMessage) + } + } + + # validate not-before + if (![string]::IsNullOrWhiteSpace($Payload.nbf)) { + if ($now -lt $unixStart.AddSeconds($Payload.nbf)) { + # The JWT is not yet valid for use + throw ($PodeLocale.jwtNotYetValidExceptionMessage) + } + } +} + +<# +.SYNOPSIS +Automatically loads auth ps1 files + +.DESCRIPTION +Automatically loads auth ps1 files from either a /auth folder, or a custom folder. Saves space dot-sourcing them all one-by-one. + +.PARAMETER Path +Optional Path to a folder containing ps1 files, can be relative or literal. + +.EXAMPLE +Use-PodeAuth + +.EXAMPLE +Use-PodeAuth -Path './my-auth' +#> +function Use-PodeAuth { + [CmdletBinding()] + param( + [Parameter()] + [string] + $Path + ) + + Use-PodeFolder -Path $Path -DefaultPath 'auth' +} + +<# +.SYNOPSIS +Builds an OAuth2 scheme using an OpenID Connect Discovery URL. + +.DESCRIPTION +Builds an OAuth2 scheme using an OpenID Connect Discovery URL. + +.PARAMETER Url +The OpenID Connect Discovery URL, this must end with '/.well-known/openid-configuration' (if missing, it will be automatically appended). + +.PARAMETER Scope +A list of optional Scopes to use during the OAuth2 request. (Default: the supported list returned) + +.PARAMETER ClientId +The Client ID from registering a new app. + +.PARAMETER ClientSecret +The Client Secret from registering a new app (this is optional when using PKCE). + +.PARAMETER RedirectUrl +An optional OAuth2 Redirect URL (Default: /oauth2/callback) + +.PARAMETER InnerScheme +An optional authentication Scheme (from New-PodeAuthScheme) that will be called prior to this Scheme. + +.PARAMETER Middleware +An array of ScriptBlocks for optional Middleware to run before the Scheme's scriptblock. + +.PARAMETER UsePKCE +If supplied, OAuth2 authentication will use PKCE code verifiers. + +.EXAMPLE +ConvertFrom-PodeOIDCDiscovery -Url 'https://accounts.google.com/.well-known/openid-configuration' -ClientId some_id -UsePKCE + +.EXAMPLE +ConvertFrom-PodeOIDCDiscovery -Url 'https://accounts.google.com' -ClientId some_id -UsePKCE +#> +function ConvertFrom-PodeOIDCDiscovery { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $Url, + + [Parameter()] + [string[]] + $Scope, + + [Parameter(Mandatory = $true)] + [string] + $ClientId, + + [Parameter()] + [string] + $ClientSecret, + + [Parameter()] + [string] + $RedirectUrl, + + [Parameter(ValueFromPipeline = $true)] + [hashtable] + $InnerScheme, + + [Parameter()] + [object[]] + $Middleware, + + [switch] + $UsePKCE + ) + begin { + $pipelineItemCount = 0 + } + + process { + + $pipelineItemCount++ + } + + end { + if ($pipelineItemCount -gt 1) { + throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) + } + # get the discovery doc + if (!$Url.EndsWith('/.well-known/openid-configuration')) { + $Url += '/.well-known/openid-configuration' + } + + $config = Invoke-RestMethod -Method Get -Uri $Url + + # check it supports the code response_type + if ($config.response_types_supported -inotcontains 'code') { + # The OAuth2 provider does not support the 'code' response_type + throw ($PodeLocale.oauth2ProviderDoesNotSupportCodeResponseTypeExceptionMessage) + } + + # can we have an InnerScheme? + if (($null -ne $InnerScheme) -and ($config.grant_types_supported -inotcontains 'password')) { + # The OAuth2 provider does not support the 'password' grant_type required by using an InnerScheme + throw ($PodeLocale.oauth2ProviderDoesNotSupportPasswordGrantTypeExceptionMessage) + } + + # scopes + $scopes = $config.scopes_supported + + if (($null -ne $Scope) -and ($Scope.Length -gt 0)) { + $scopes = @(foreach ($s in $Scope) { + if ($s -iin $config.scopes_supported) { + $s + } + }) + } + + # pkce code challenge method + $codeMethod = 'S256' + if ($config.code_challenge_methods_supported -inotcontains $codeMethod) { + $codeMethod = 'plain' + } + + return New-PodeAuthScheme ` + -OAuth2 ` + -ClientId $ClientId ` + -ClientSecret $ClientSecret ` + -AuthoriseUrl $config.authorization_endpoint ` + -TokenUrl $config.token_endpoint ` + -UserUrl $config.userinfo_endpoint ` + -RedirectUrl $RedirectUrl ` + -Scope $scopes ` + -InnerScheme $InnerScheme ` + -Middleware $Middleware ` + -CodeChallengeMethod $codeMethod ` + -UsePKCE:$UsePKCE + } +} + +<# +.SYNOPSIS +Test whether the current WebEvent or Session has an authenticated user. + +.DESCRIPTION +Test whether the current WebEvent or Session has an authenticated user. Returns true if there is an authenticated user. + +.PARAMETER IgnoreSession +If supplied, only the Auth object in the WebEvent will be checked and the Session will be skipped. + +.EXAMPLE +if (Test-PodeAuthUser) { ... } +#> +function Test-PodeAuthUser { + [CmdletBinding()] + [OutputType([boolean])] + param( + [switch] + $IgnoreSession + ) + + # auth middleware + if (($null -ne $WebEvent.Auth) -and $WebEvent.Auth.IsAuthenticated) { + $auth = $WebEvent.Auth + } + + # session? + elseif (!$IgnoreSession -and ($null -ne $WebEvent.Session.Data.Auth) -and $WebEvent.Session.Data.Auth.IsAuthenticated) { + $auth = $WebEvent.Session.Data.Auth + } + + # null? + if (($null -eq $auth) -or ($null -eq $auth.User)) { + return $false + } + + return ($null -ne $auth.User) +} + +<# +.SYNOPSIS +Get the authenticated user from the WebEvent or Session. + +.DESCRIPTION +Get the authenticated user from the WebEvent or Session. This is similar to calling $Webevent.Auth.User. + +.PARAMETER IgnoreSession +If supplied, only the Auth object in the WebEvent will be used and the Session will be skipped. + +.EXAMPLE +$user = Get-PodeAuthUser +#> +function Get-PodeAuthUser { + [CmdletBinding()] + param( + [switch] + $IgnoreSession + ) + + # auth middleware + if (($null -ne $WebEvent.Auth) -and $WebEvent.Auth.IsAuthenticated) { + $auth = $WebEvent.Auth + } + + # session? + elseif (!$IgnoreSession -and ($null -ne $WebEvent.Session.Data.Auth) -and $WebEvent.Session.Data.Auth.IsAuthenticated) { + $auth = $WebEvent.Session.Data.Auth + } + + # null? + if (($null -eq $auth) -or ($null -eq $auth.User)) { + return $null + } + + return $auth.User +} \ No newline at end of file diff --git a/tests/unit/Authentication.Tests.ps1 b/tests/unit/Authentication.Tests.ps1 index a3ac4e7ff..daa7460f3 100644 --- a/tests/unit/Authentication.Tests.ps1 +++ b/tests/unit/Authentication.Tests.ps1 @@ -206,7 +206,7 @@ Describe 'Invoke-PodeAuth Tests' { } } } - + $WebEvent=@{} $PodeLocale = @{ authMethodDoesNotExistExceptionMessage = "Authentication method {0} does not exist" authenticationMethodMergedExceptionMessage = "Authentication method {0} is merged" @@ -216,6 +216,7 @@ Describe 'Invoke-PodeAuth Tests' { It 'Should successfully invoke a valid authentication method' { Mock Test-PodeAuthExists { $true } -ParameterFilter { $Name -eq 'ValidAuth' } Mock Test-PodeAuthValidation { @{ Success = $true; User = 'TestUser'; Headers = @{} } } + Mock Add-PodeHeader {} $result = Invoke-PodeAuth -Name 'ValidAuth' @@ -233,5 +234,5 @@ Describe 'Invoke-PodeAuth Tests' { { Invoke-PodeAuth -Name 'MergedAuth' } | Should -Throw ($PodeLocale.authenticationMethodMergedExceptionMessage -f 'MergedAuth' ) } - + }